envelope_cli/models/
money.rs1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct Money(i64);
17
18impl Money {
19 pub const fn from_cents(cents: i64) -> Self {
27 Self(cents)
28 }
29
30 pub const fn from_dollars_cents(dollars: i64, cents: i64) -> Self {
38 Self(dollars * 100 + cents)
39 }
40
41 pub const fn zero() -> Self {
43 Self(0)
44 }
45
46 pub const fn cents(&self) -> i64 {
48 self.0
49 }
50
51 pub const fn dollars(&self) -> i64 {
53 self.0 / 100
54 }
55
56 pub const fn cents_part(&self) -> i64 {
58 (self.0 % 100).abs()
59 }
60
61 pub const fn is_zero(&self) -> bool {
63 self.0 == 0
64 }
65
66 pub const fn is_positive(&self) -> bool {
68 self.0 > 0
69 }
70
71 pub const fn is_negative(&self) -> bool {
73 self.0 < 0
74 }
75
76 pub const fn abs(&self) -> Self {
78 Self(self.0.abs())
79 }
80
81 pub fn parse(s: &str) -> Result<Self, MoneyParseError> {
85 let s = s.trim();
86
87 let (negative, s) = if let Some(stripped) = s.strip_prefix('-') {
89 (true, stripped)
90 } else {
91 (false, s)
92 };
93
94 let s = s.strip_prefix('$').unwrap_or(s);
96
97 let cents = if s.contains('.') {
99 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 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 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 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#[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}