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::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::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        if self.is_negative() {
159            write!(f, "-${}.{:02}", self.dollars().abs(), self.cents_part())
160        } else {
161            write!(f, "${}.{:02}", self.dollars(), self.cents_part())
162        }
163    }
164}
165
166impl Add for Money {
167    type Output = Self;
168
169    fn add(self, other: Self) -> Self {
170        Self(self.0 + other.0)
171    }
172}
173
174impl AddAssign for Money {
175    fn add_assign(&mut self, other: Self) {
176        self.0 += other.0;
177    }
178}
179
180impl Sub for Money {
181    type Output = Self;
182
183    fn sub(self, other: Self) -> Self {
184        Self(self.0 - other.0)
185    }
186}
187
188impl SubAssign for Money {
189    fn sub_assign(&mut self, other: Self) {
190        self.0 -= other.0;
191    }
192}
193
194impl Neg for Money {
195    type Output = Self;
196
197    fn neg(self) -> Self {
198        Self(-self.0)
199    }
200}
201
202impl std::iter::Sum for Money {
203    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
204        iter.fold(Money::zero(), |acc, m| acc + m)
205    }
206}
207
208/// Error type for money parsing
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum MoneyParseError {
211    InvalidFormat(String),
212}
213
214impl fmt::Display for MoneyParseError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            MoneyParseError::InvalidFormat(s) => write!(f, "Invalid money format: {}", s),
218        }
219    }
220}
221
222impl std::error::Error for MoneyParseError {}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_from_cents() {
230        let m = Money::from_cents(1050);
231        assert_eq!(m.cents(), 1050);
232        assert_eq!(m.dollars(), 10);
233        assert_eq!(m.cents_part(), 50);
234    }
235
236    #[test]
237    fn test_from_dollars_cents() {
238        let m = Money::from_dollars_cents(10, 50);
239        assert_eq!(m.cents(), 1050);
240    }
241
242    #[test]
243    fn test_display() {
244        assert_eq!(format!("{}", Money::from_cents(1050)), "$10.50");
245        assert_eq!(format!("{}", Money::from_cents(0)), "$0.00");
246        assert_eq!(format!("{}", Money::from_cents(-1050)), "-$10.50");
247        assert_eq!(format!("{}", Money::from_cents(5)), "$0.05");
248    }
249
250    #[test]
251    fn test_arithmetic() {
252        let a = Money::from_cents(1000);
253        let b = Money::from_cents(500);
254
255        assert_eq!((a + b).cents(), 1500);
256        assert_eq!((a - b).cents(), 500);
257        assert_eq!((-a).cents(), -1000);
258    }
259
260    #[test]
261    fn test_parse() {
262        assert_eq!(Money::parse("10.50").unwrap().cents(), 1050);
263        assert_eq!(Money::parse("$10.50").unwrap().cents(), 1050);
264        assert_eq!(Money::parse("-10.50").unwrap().cents(), -1050);
265        assert_eq!(Money::parse("10").unwrap().cents(), 1000);
266        assert_eq!(Money::parse("10.5").unwrap().cents(), 1050);
267        assert_eq!(Money::parse("0.05").unwrap().cents(), 5);
268    }
269
270    #[test]
271    fn test_comparison() {
272        let a = Money::from_cents(1000);
273        let b = Money::from_cents(500);
274        let c = Money::from_cents(1000);
275
276        assert!(a > b);
277        assert!(b < a);
278        assert_eq!(a, c);
279    }
280
281    #[test]
282    fn test_is_checks() {
283        assert!(Money::zero().is_zero());
284        assert!(Money::from_cents(100).is_positive());
285        assert!(Money::from_cents(-100).is_negative());
286    }
287
288    #[test]
289    fn test_sum() {
290        let amounts = vec![
291            Money::from_cents(100),
292            Money::from_cents(200),
293            Money::from_cents(300),
294        ];
295        let total: Money = amounts.into_iter().sum();
296        assert_eq!(total.cents(), 600);
297    }
298
299    #[test]
300    fn test_serialization() {
301        let m = Money::from_cents(1050);
302        let json = serde_json::to_string(&m).unwrap();
303        assert_eq!(json, "1050");
304
305        let deserialized: Money = serde_json::from_str(&json).unwrap();
306        assert_eq!(m, deserialized);
307    }
308}