tiller_sync/model/
amount.rs

1//! Amount type for handling monetary values with optional dollar signs.
2//!
3//! This module provides the `Amount` type which wraps `Decimal` and handles
4//! parsing values that may or may not include a dollar and commas.
5
6use rust_decimal::prelude::ToPrimitive;
7use rust_decimal::Decimal;
8use schemars::{json_schema, JsonSchema, Schema, SchemaGenerator};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use std::borrow::Cow;
11use std::error::Error;
12use std::fmt;
13use std::fmt::{Debug, Display, Formatter};
14use std::str::FromStr;
15
16/// Represents how dollar amounts were (or should be) formatted.
17///
18/// # Examples
19///  - `AmountFormat{ dollar: true, commas: true }` -> `-$60,000.00`
20///  - `AmountFormat{ dollar: false, commas: true }` -> `-60,000.00`
21///  - `AmountFormat{ dollar: false, commas: false }` -> `-60000.00`
22///  - `AmountFormat{ dollar: true, commas: false }` -> `-$60000.00`
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct AmountFormat {
25    /// Whether a dollar sign is present in the formatting.
26    dollar: bool,
27    /// Whether commas are present as thousands separators in the formatting.
28    commas: bool,
29}
30
31impl Default for AmountFormat {
32    fn default() -> Self {
33        DEFAULT_FORMAT
34    }
35}
36
37/// The default format has a dollar sign and commas: e.g. `-$60,000.00`.
38const DEFAULT_FORMAT: AmountFormat = AmountFormat {
39    dollar: true,
40    commas: true,
41};
42
43/// Represents a dollar amount.
44///
45/// This type wraps `Decimal` and provides custom serialization/deserialization
46/// to handle amounts that may be formatted with or without dollar signs or commas.
47///
48/// Formatting is considered significant for the purposes of equality, so for numeric comparisons,
49/// you should access the `Decimal` value and use that.
50///
51/// # Examples
52///
53/// Parsing with dollar sign:
54/// ```
55/// # use tiller_sync::model::Amount;
56/// # use std::str::FromStr;
57/// let amount = Amount::from_str("-$50.00").unwrap();
58/// assert_eq!(amount.to_string(), "-$50.00");
59/// ```
60///
61/// Parsing without dollar sign:
62/// ```
63/// # use tiller_sync::model::Amount;
64/// # use std::str::FromStr;
65/// let amount = Amount::from_str("-50.00").unwrap();
66/// assert_ne!(amount.to_string(), "-$50.00");
67/// assert_eq!(amount.to_string(), "-50.00");
68/// ```
69///
70/// Value equivalency, but not absolute equivalency
71/// ```
72/// # use tiller_sync::model::Amount;
73/// # use std::str::FromStr;
74/// let a = Amount::from_str("-5000.00").unwrap();
75/// let b = Amount::from_str("-$5,000.00").unwrap();
76/// assert_ne!(a, b);
77/// assert_ne!(a.to_string(), b.to_string());
78/// assert_eq!(a.value(), b.value());
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
81pub struct Amount {
82    /// The parsed numerical value.
83    value: Decimal,
84    /// The way the numerical value was parsed from, or should be written to, a `String`.
85    format: AmountFormat,
86}
87
88impl Amount {
89    /// Creates a new Amount from a Decimal value with default `String` formatting.
90    pub const fn new(value: Decimal) -> Self {
91        Self {
92            value,
93            format: DEFAULT_FORMAT,
94        }
95    }
96
97    /// Creates a new Amount from a Decimal value with default specified formatting.
98    pub const fn new_with_format(value: Decimal, format: AmountFormat) -> Self {
99        Self { value, format }
100    }
101
102    /// Returns the underlying Decimal value.
103    pub fn value(&self) -> Decimal {
104        self.value
105    }
106
107    /// Returns true if the amount is zero.
108    pub fn is_zero(&self) -> bool {
109        self.value().is_zero()
110    }
111
112    /// Returns true if the amount is positive.
113    pub fn is_positive(&self) -> bool {
114        !self.is_zero() && self.value().is_sign_positive()
115    }
116
117    /// Returns true if the amount is negative.
118    pub fn is_negative(&self) -> bool {
119        self.value().is_sign_negative()
120    }
121}
122
123/// An error that can occur when parsing strings into `Decimal` values.
124pub struct AmountError(rust_decimal::Error);
125
126impl Debug for AmountError {
127    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
128        Debug::fmt(&self.0, f)
129    }
130}
131
132impl Display for AmountError {
133    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
134        Display::fmt(&self.0, f)
135    }
136}
137
138impl std::error::Error for AmountError {
139    fn source(&self) -> Option<&(dyn Error + 'static)> {
140        Some(&self.0)
141    }
142}
143
144impl FromStr for Amount {
145    type Err = AmountError;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        let mut dollar_sign = false;
149
150        // Remove whitespace
151        let trimmed = s.trim();
152
153        // Handle empty string
154        if trimmed.is_empty() {
155            return Ok(Amount::default());
156        }
157
158        // Remove dollar sign if present
159        let without_dollar = if let Some(after_minus) = trimmed.strip_prefix('-') {
160            // Negative number: could be "-$50.00" or "-50.00"
161            if let Some(after_dollar) = after_minus.strip_prefix('$') {
162                dollar_sign = true;
163                format!("-{after_dollar}")
164            } else {
165                trimmed.to_string()
166            }
167        } else if let Some(after_dollar) = trimmed.strip_prefix('$') {
168            // Positive number with dollar sign: "$50.00"
169            dollar_sign = true;
170            after_dollar.to_string()
171        } else {
172            // No dollar sign
173            trimmed.to_string()
174        };
175
176        // Remove commas (thousand separators)
177        let without_commas = without_dollar.replace(',', "");
178        let commas = without_commas.len() < without_dollar.len();
179
180        // Parse the decimal value
181        let value = Decimal::from_str(&without_commas).map_err(AmountError)?;
182        Ok(Amount {
183            value,
184            format: AmountFormat {
185                dollar: dollar_sign,
186                commas,
187            },
188        })
189    }
190}
191
192impl fmt::Display for Amount {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        let (sign, num) = if self.is_negative() {
195            (String::from("-"), self.value().abs())
196        } else {
197            (String::new(), self.value())
198        };
199
200        let dol = if self.format.dollar {
201            String::from("$")
202        } else {
203            String::new()
204        };
205
206        if self.format.commas {
207            write!(
208                f,
209                "{sign}{dol}{}",
210                format_num::format_num!(",.2", num.to_f64().unwrap_or_default())
211            )
212        } else {
213            write!(f, "{sign}{dol}{num}")
214        }
215    }
216}
217
218impl Serialize for Amount {
219    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
220    where
221        S: Serializer,
222    {
223        // Serialize as a string with dollar sign
224        serializer.serialize_str(&self.to_string())
225    }
226}
227
228impl<'de> Deserialize<'de> for Amount {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: Deserializer<'de>,
232    {
233        let s = String::deserialize(deserializer)?;
234        Amount::from_str(&s).map_err(serde::de::Error::custom)
235    }
236}
237
238impl From<Decimal> for Amount {
239    fn from(value: Decimal) -> Self {
240        Amount::new(value)
241    }
242}
243
244impl From<Amount> for Decimal {
245    fn from(amount: Amount) -> Self {
246        amount.value()
247    }
248}
249
250impl JsonSchema for Amount {
251    fn schema_name() -> Cow<'static, str> {
252        "Amount".into()
253    }
254
255    fn json_schema(_: &mut SchemaGenerator) -> Schema {
256        json_schema!({
257            "type": "string",
258            "description": "A decimal number, with or without a dollar sign, negative sign or \
259            commas. Examples: 1.0 or -1 or -$1.21 or $3,452.12",
260        })
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_parse_with_dollar_sign() {
270        let amount = Amount::from_str("$50.00").unwrap();
271        assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
272    }
273
274    #[test]
275    fn test_parse_without_dollar_sign() {
276        let amount = Amount::from_str("50.00").unwrap();
277        assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
278    }
279
280    #[test]
281    fn test_parse_negative_with_dollar_sign() {
282        let amount = Amount::from_str("-$50.00").unwrap();
283        assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
284    }
285
286    #[test]
287    fn test_parse_negative_without_dollar_sign() {
288        let amount = Amount::from_str("-50.00").unwrap();
289        assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
290    }
291
292    #[test]
293    fn test_parse_empty_string() {
294        let amount = Amount::from_str("").unwrap();
295        assert_eq!(amount.value(), Decimal::ZERO);
296    }
297
298    #[test]
299    fn test_parse_whitespace() {
300        let amount = Amount::from_str("  $50.00  ").unwrap();
301        assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
302    }
303
304    #[test]
305    fn test_display_positive() {
306        let amount = Amount::new(Decimal::from_str("50.00").unwrap());
307        assert_eq!(amount.to_string(), "$50.00");
308    }
309
310    #[test]
311    fn test_display_negative() {
312        let amount = Amount::new(Decimal::from_str("-50.00").unwrap());
313        assert_eq!(amount.to_string(), "-$50.00");
314    }
315
316    #[test]
317    fn test_display_zero() {
318        let amount = Amount::new(Decimal::ZERO);
319        assert_eq!(amount.to_string(), "$0.00");
320    }
321
322    #[test]
323    fn test_serialize() {
324        let amount = Amount::new(Decimal::from_str("50.00").unwrap());
325        let json = serde_json::to_string(&amount).unwrap();
326        assert_eq!(json, "\"$50.00\"");
327    }
328
329    #[test]
330    fn test_deserialize_with_dollar() {
331        let json = "\"$50.00\"";
332        let amount: Amount = serde_json::from_str(json).unwrap();
333        assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
334    }
335
336    #[test]
337    fn test_deserialize_without_dollar() {
338        let json = "\"50.00\"";
339        let amount: Amount = serde_json::from_str(json).unwrap();
340        assert_eq!(amount.value(), Decimal::from_str("50.00").unwrap());
341    }
342
343    #[test]
344    fn test_deserialize_negative() {
345        let json = "\"-$50.00\"";
346        let amount: Amount = serde_json::from_str(json).unwrap();
347        assert_eq!(amount.value(), Decimal::from_str("-50.00").unwrap());
348    }
349
350    #[test]
351    fn test_equality() {
352        let a1 = Amount::from_str("$50.00").unwrap();
353        let a2 = Amount::from_str("50.00").unwrap();
354        assert_ne!(a1, a2);
355        assert_eq!(a1.value(), a2.value());
356    }
357
358    #[test]
359    fn test_ordering() {
360        let a1 = Amount::from_str("$30.00").unwrap();
361        let a2 = Amount::from_str("$50.00").unwrap();
362        assert!(a1 < a2);
363    }
364
365    #[test]
366    fn test_is_zero() {
367        let zero = Amount::from_str("$0.00").unwrap();
368        assert!(zero.is_zero());
369
370        let non_zero = Amount::from_str("$50.00").unwrap();
371        assert!(!non_zero.is_zero());
372    }
373
374    #[test]
375    fn test_zero_is_not_positive_or_negative() {
376        let zero = Amount::from_str("$0.00").unwrap();
377        assert!(!zero.is_positive());
378        assert!(!zero.is_negative());
379        assert!(zero.is_zero());
380    }
381
382    #[test]
383    fn test_is_positive() {
384        let positive = Amount::from_str("$50.00").unwrap();
385        assert!(positive.is_positive());
386
387        let negative = Amount::from_str("-$50.00").unwrap();
388        assert!(!negative.is_positive());
389    }
390
391    #[test]
392    fn test_is_negative() {
393        let negative = Amount::from_str("-$50.00").unwrap();
394        assert!(negative.is_negative());
395
396        let positive = Amount::from_str("$50.00").unwrap();
397        assert!(!positive.is_negative());
398    }
399
400    #[test]
401    fn test_parse_with_commas() {
402        let amount = Amount::from_str("$1,000.00").unwrap();
403        assert_eq!(amount.value(), Decimal::from_str("1000.00").unwrap());
404    }
405
406    #[test]
407    fn test_parse_large_amount_with_commas() {
408        let amount = Amount::from_str("-$60,000.00").unwrap();
409        assert_eq!(amount.value(), Decimal::from_str("-60000.00").unwrap());
410    }
411
412    #[test]
413    fn test_parse_multiple_commas() {
414        let amount = Amount::from_str("$1,234,567.89").unwrap();
415        assert_eq!(amount.value(), Decimal::from_str("1234567.89").unwrap());
416    }
417
418    #[test]
419    fn test_parse_commas_without_dollar() {
420        let amount = Amount::from_str("1,000.00").unwrap();
421        assert_eq!(amount.value(), Decimal::from_str("1000.00").unwrap());
422    }
423
424    #[test]
425    fn test_parse_retain_commas_no_dollarsign() {
426        let s = "1,000,000.00";
427        let amount = Amount::from_str(s).unwrap();
428        let actual = amount.to_string();
429        assert_eq!(actual, s);
430    }
431
432    #[test]
433    fn test_parse_no_commas_retain_dollar_sign() {
434        let s = "-$1000000.00";
435        let amount = Amount::from_str(s).unwrap();
436        let actual = amount.to_string();
437        assert_eq!(actual, s);
438    }
439}