envelope_cli/models/
money.rs

1//! Money type for representing currency amounts
2//!
3//! Internally stores amounts in cents (i64) to avoid floating-point precision
4//! issues. Provides safe arithmetic operations and formatting.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
9
10/// Represents a monetary amount stored as cents (hundredths of the currency unit)
11///
12/// Using i64 cents avoids floating-point precision issues and supports
13/// amounts up to approximately $92 quadrillion (both positive and negative).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct Money(i64);
17
18impl Money {
19    /// Create a Money amount from cents
20    ///
21    /// # Examples
22    /// ```
23    /// use envelope_cli::models::Money;
24    /// let amount = Money::from_cents(1050); // $10.50
25    /// ```
26    pub const fn from_cents(cents: i64) -> Self {
27        Self(cents)
28    }
29
30    /// Create a Money amount from dollars and cents
31    ///
32    /// # Examples
33    /// ```
34    /// use envelope_cli::models::Money;
35    /// let amount = Money::from_dollars_cents(10, 50); // $10.50
36    /// ```
37    pub const fn from_dollars_cents(dollars: i64, cents: i64) -> Self {
38        Self(dollars * 100 + cents)
39    }
40
41    /// Create a zero Money amount
42    pub const fn zero() -> Self {
43        Self(0)
44    }
45
46    /// Get the amount in cents
47    pub const fn cents(&self) -> i64 {
48        self.0
49    }
50
51    /// Get the whole dollars portion (truncated toward zero)
52    pub const fn dollars(&self) -> i64 {
53        self.0 / 100
54    }
55
56    /// Get the cents portion (0-99)
57    pub const fn cents_part(&self) -> i64 {
58        (self.0 % 100).abs()
59    }
60
61    /// Check if the amount is zero
62    pub const fn is_zero(&self) -> bool {
63        self.0 == 0
64    }
65
66    /// Check if the amount is positive
67    pub const fn is_positive(&self) -> bool {
68        self.0 > 0
69    }
70
71    /// Check if the amount is negative
72    pub const fn is_negative(&self) -> bool {
73        self.0 < 0
74    }
75
76    /// Get the absolute value
77    pub const fn abs(&self) -> Self {
78        Self(self.0.abs())
79    }
80
81    /// Parse a money amount from a string
82    ///
83    /// Accepts formats: "10.50", "-10.50", "$10.50", "10", "1050" (cents)
84    pub fn parse(s: &str) -> Result<Self, MoneyParseError> {
85        let s = s.trim();
86
87        // Handle negative sign at start
88        let (negative, s) = if let Some(stripped) = s.strip_prefix('-') {
89            (true, stripped)
90        } else {
91            (false, s)
92        };
93
94        // Remove currency symbol if present
95        let s = s.strip_prefix('$').unwrap_or(s);
96
97        // Parse based on format
98        let cents = if s.contains('.') {
99            // Decimal format: "10.50"
100            let parts: Vec<&str> = s.split('.').collect();
101            if parts.len() != 2 {
102                return Err(MoneyParseError::InvalidFormat(s.to_string()));
103            }
104
105            let dollars: i64 = parts[0]
106                .parse()
107                .map_err(|_| MoneyParseError::InvalidFormat(s.to_string()))?;
108
109            // Pad or truncate cents to 2 digits
110            let cents_str = parts[1];
111            let cents: i64 = match cents_str.len() {
112                0 => 0,
113                1 => {
114                    cents_str
115                        .parse::<i64>()
116                        .map_err(|_| MoneyParseError::InvalidFormat(s.to_string()))?
117                        * 10
118                }
119                _ => cents_str[..2]
120                    .parse()
121                    .map_err(|_| MoneyParseError::InvalidFormat(s.to_string()))?,
122            };
123
124            dollars * 100 + cents
125        } else {
126            // Integer format - assume dollars
127            s.parse::<i64>()
128                .map_err(|_| MoneyParseError::InvalidFormat(s.to_string()))?
129                * 100
130        };
131
132        Ok(Self(if negative { -cents } else { cents }))
133    }
134
135    /// Format with a currency symbol
136    pub fn format_with_symbol(&self, symbol: &str) -> String {
137        if self.is_negative() {
138            format!(
139                "-{}{}.{:02}",
140                symbol,
141                self.dollars().abs(),
142                self.cents_part()
143            )
144        } else {
145            format!("{}{}.{:02}", symbol, self.dollars(), self.cents_part())
146        }
147    }
148}
149
150impl Default for Money {
151    fn default() -> Self {
152        Self::zero()
153    }
154}
155
156impl fmt::Display for Money {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        let formatted = if self.is_negative() {
159            format!("-${}.{:02}", self.dollars().abs(), self.cents_part())
160        } else {
161            format!("${}.{:02}", self.dollars(), self.cents_part())
162        };
163
164        // Honor width and alignment from the formatter
165        if let Some(width) = f.width() {
166            if f.align() == Some(fmt::Alignment::Left) {
167                write!(f, "{:<width$}", formatted, width = width)
168            } else {
169                // Default to right alignment for money
170                write!(f, "{:>width$}", formatted, width = width)
171            }
172        } else {
173            write!(f, "{}", formatted)
174        }
175    }
176}
177
178impl Add for Money {
179    type Output = Self;
180
181    fn add(self, other: Self) -> Self {
182        Self(self.0 + other.0)
183    }
184}
185
186impl AddAssign for Money {
187    fn add_assign(&mut self, other: Self) {
188        self.0 += other.0;
189    }
190}
191
192impl Sub for Money {
193    type Output = Self;
194
195    fn sub(self, other: Self) -> Self {
196        Self(self.0 - other.0)
197    }
198}
199
200impl SubAssign for Money {
201    fn sub_assign(&mut self, other: Self) {
202        self.0 -= other.0;
203    }
204}
205
206impl Neg for Money {
207    type Output = Self;
208
209    fn neg(self) -> Self {
210        Self(-self.0)
211    }
212}
213
214impl std::iter::Sum for Money {
215    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
216        iter.fold(Money::zero(), |acc, m| acc + m)
217    }
218}
219
220/// Error type for money parsing
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum MoneyParseError {
223    InvalidFormat(String),
224}
225
226impl fmt::Display for MoneyParseError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            MoneyParseError::InvalidFormat(s) => write!(f, "Invalid money format: {}", s),
230        }
231    }
232}
233
234impl std::error::Error for MoneyParseError {}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_from_cents() {
242        let m = Money::from_cents(1050);
243        assert_eq!(m.cents(), 1050);
244        assert_eq!(m.dollars(), 10);
245        assert_eq!(m.cents_part(), 50);
246    }
247
248    #[test]
249    fn test_from_dollars_cents() {
250        let m = Money::from_dollars_cents(10, 50);
251        assert_eq!(m.cents(), 1050);
252    }
253
254    #[test]
255    fn test_display() {
256        assert_eq!(format!("{}", Money::from_cents(1050)), "$10.50");
257        assert_eq!(format!("{}", Money::from_cents(0)), "$0.00");
258        assert_eq!(format!("{}", Money::from_cents(-1050)), "-$10.50");
259        assert_eq!(format!("{}", Money::from_cents(5)), "$0.05");
260    }
261
262    #[test]
263    fn test_arithmetic() {
264        let a = Money::from_cents(1000);
265        let b = Money::from_cents(500);
266
267        assert_eq!((a + b).cents(), 1500);
268        assert_eq!((a - b).cents(), 500);
269        assert_eq!((-a).cents(), -1000);
270    }
271
272    #[test]
273    fn test_parse() {
274        assert_eq!(Money::parse("10.50").unwrap().cents(), 1050);
275        assert_eq!(Money::parse("$10.50").unwrap().cents(), 1050);
276        assert_eq!(Money::parse("-10.50").unwrap().cents(), -1050);
277        assert_eq!(Money::parse("10").unwrap().cents(), 1000);
278        assert_eq!(Money::parse("10.5").unwrap().cents(), 1050);
279        assert_eq!(Money::parse("0.05").unwrap().cents(), 5);
280    }
281
282    #[test]
283    fn test_comparison() {
284        let a = Money::from_cents(1000);
285        let b = Money::from_cents(500);
286        let c = Money::from_cents(1000);
287
288        assert!(a > b);
289        assert!(b < a);
290        assert_eq!(a, c);
291    }
292
293    #[test]
294    fn test_is_checks() {
295        assert!(Money::zero().is_zero());
296        assert!(Money::from_cents(100).is_positive());
297        assert!(Money::from_cents(-100).is_negative());
298    }
299
300    #[test]
301    fn test_sum() {
302        let amounts = vec![
303            Money::from_cents(100),
304            Money::from_cents(200),
305            Money::from_cents(300),
306        ];
307        let total: Money = amounts.into_iter().sum();
308        assert_eq!(total.cents(), 600);
309    }
310
311    #[test]
312    fn test_serialization() {
313        let m = Money::from_cents(1050);
314        let json = serde_json::to_string(&m).unwrap();
315        assert_eq!(json, "1050");
316
317        let deserialized: Money = serde_json::from_str(&json).unwrap();
318        assert_eq!(m, deserialized);
319    }
320}