Skip to main content

adk_payments/domain/
money.rs

1use std::cmp::Ordering;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Largest decimal scale that [`Money`] parsing and rescaling accept.
7///
8/// `10^18` is the largest power of ten that fits in `i64`, so a larger scale
9/// cannot represent even one major unit.
10pub const MAX_MONEY_SCALE: u32 = 18;
11
12/// ISO 4217 codes whose minor unit has no decimal places.
13const ZERO_DECIMAL_CURRENCIES: &[&str] = &[
14    "BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "UYI", "VND",
15    "VUV", "XAF", "XOF", "XPF",
16];
17
18/// ISO 4217 codes whose minor unit has three decimal places.
19const THREE_DECIMAL_CURRENCIES: &[&str] = &["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"];
20
21/// ISO 4217 codes whose minor unit has four decimal places.
22const FOUR_DECIMAL_CURRENCIES: &[&str] = &["CLF", "UYW"];
23
24/// ISO 4217 codes that define no minor unit (precious metals, funds, testing).
25const NO_MINOR_UNIT_CURRENCIES: &[&str] =
26    &["XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XDR", "XPD", "XPT", "XSU", "XTS", "XUA", "XXX"];
27
28/// Errors raised by exact [`Money`] parsing and arithmetic.
29#[derive(Debug, Error, Clone, PartialEq, Eq)]
30pub enum MoneyError {
31    #[error(
32        "`{value}` is not a valid decimal monetary value: {reason}. Send a plain decimal number such as `12.34`."
33    )]
34    InvalidDecimal { value: String, reason: String },
35
36    #[error(
37        "`{value}` requires {scale} fraction digits, more than the allowed {max_scale}. Round the amount to at most {max_scale} decimal places."
38    )]
39    PrecisionTooHigh { value: String, scale: u32, max_scale: u32 },
40
41    #[error(
42        "monetary arithmetic overflowed while computing {context}. Reduce the amount or its precision."
43    )]
44    Overflow { context: String },
45
46    #[error(
47        "currency `{found}` does not match `{expected}`. Express every amount in one cart or mandate in the same currency."
48    )]
49    CurrencyMismatch { expected: String, found: String },
50}
51
52/// Currency-denominated amount stored as minor units plus an explicit scale.
53///
54/// `amount_minor` keeps commerce arithmetic exact and avoids floating-point
55/// rounding drift. The value is `amount_minor / 10^scale` major units.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct Money {
59    pub currency: String,
60    pub amount_minor: i64,
61    pub scale: u32,
62}
63
64impl Money {
65    /// Creates a money value using explicit minor units and scale.
66    #[must_use]
67    pub fn new(currency: impl Into<String>, amount_minor: i64, scale: u32) -> Self {
68        Self { currency: currency.into(), amount_minor, scale }
69    }
70
71    /// Returns the ISO 4217 minor-unit exponent for `currency`.
72    ///
73    /// The lookup ignores ASCII case. Codes outside the zero-, three-, and
74    /// four-decimal ISO 4217 lists are treated as two-decimal currencies.
75    /// Returns `None` for codes that define no minor unit (such as `XXX` or
76    /// `XAU`) and for strings that are not three ASCII letters.
77    ///
78    /// # Example
79    ///
80    /// ```
81    /// use adk_payments::domain::Money;
82    ///
83    /// assert_eq!(Money::iso_minor_unit_scale("usd"), Some(2));
84    /// assert_eq!(Money::iso_minor_unit_scale("JPY"), Some(0));
85    /// assert_eq!(Money::iso_minor_unit_scale("KWD"), Some(3));
86    /// assert_eq!(Money::iso_minor_unit_scale("XXX"), None);
87    /// ```
88    #[must_use]
89    pub fn iso_minor_unit_scale(currency: &str) -> Option<u32> {
90        if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_alphabetic()) {
91            return None;
92        }
93        let code = currency.to_ascii_uppercase();
94        let code = code.as_str();
95        if NO_MINOR_UNIT_CURRENCIES.contains(&code) {
96            None
97        } else if ZERO_DECIMAL_CURRENCIES.contains(&code) {
98            Some(0)
99        } else if THREE_DECIMAL_CURRENCIES.contains(&code) {
100            Some(3)
101        } else if FOUR_DECIMAL_CURRENCIES.contains(&code) {
102            Some(4)
103        } else {
104            Some(2)
105        }
106    }
107
108    /// Parses a decimal string such as `-12.50` or `1.5e-3` without rounding.
109    ///
110    /// The sign is read from the string itself, so `-0.50` parses to `-50`
111    /// minor units. The resulting scale is the number of significant fraction
112    /// digits: trailing fractional zeros are dropped and integers have scale 0.
113    ///
114    /// # Example
115    ///
116    /// ```
117    /// use adk_payments::domain::Money;
118    ///
119    /// assert_eq!(Money::parse_decimal("USD", "-0.50").unwrap(), Money::new("USD", -5, 1));
120    /// assert_eq!(Money::parse_decimal("USD", "1.125").unwrap(), Money::new("USD", 1_125, 3));
121    /// ```
122    ///
123    /// # Errors
124    ///
125    /// Returns [`MoneyError::InvalidDecimal`] for malformed input,
126    /// [`MoneyError::PrecisionTooHigh`] when more than [`MAX_MONEY_SCALE`]
127    /// significant fraction digits remain, and [`MoneyError::Overflow`] when
128    /// the value does not fit in `i64` minor units.
129    pub fn parse_decimal(currency: impl Into<String>, value: &str) -> Result<Self, MoneyError> {
130        let invalid = |reason: &str| MoneyError::InvalidDecimal {
131            value: value.to_string(),
132            reason: reason.to_string(),
133        };
134        let (negative, unsigned) = match value.strip_prefix('-') {
135            Some(rest) => (true, rest),
136            None => (false, value),
137        };
138        let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
139            Some(index) => (&unsigned[..index], Some(&unsigned[index + 1..])),
140            None => (unsigned, None),
141        };
142        let (whole, fraction) = match mantissa.split_once('.') {
143            Some((whole, "")) => {
144                return Err(invalid(&format!("`{whole}.` has no digits after the decimal point")));
145            }
146            Some((whole, fraction)) => (whole, fraction),
147            None => (mantissa, ""),
148        };
149        if whole.is_empty() || !whole.bytes().all(|byte| byte.is_ascii_digit()) {
150            return Err(invalid("the integer part must contain only ASCII digits"));
151        }
152        if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
153            return Err(invalid("the fraction part must contain only ASCII digits"));
154        }
155        let exponent = match exponent {
156            None => 0_i64,
157            Some(exponent) => {
158                let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
159                if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
160                    return Err(invalid("the exponent must be an optionally signed integer"));
161                }
162                exponent.parse::<i64>().map_err(|_| MoneyError::Overflow {
163                    context: format!("the exponent of `{value}`"),
164                })?
165            }
166        };
167
168        let mut digits = format!("{whole}{fraction}");
169        let significant_start = digits.find(|digit| digit != '0').unwrap_or(digits.len());
170        digits.drain(..significant_start);
171        if digits.is_empty() {
172            return Ok(Self::new(currency, 0, 0));
173        }
174
175        let fraction_len = i64::try_from(fraction.len()).map_err(|_| MoneyError::Overflow {
176            context: format!("the fraction length of `{value}`"),
177        })?;
178        let mut scale = fraction_len
179            .checked_sub(exponent)
180            .ok_or_else(|| MoneyError::Overflow { context: format!("the scale of `{value}`") })?;
181        while scale > 0 && digits.ends_with('0') {
182            digits.pop();
183            scale -= 1;
184        }
185        if scale > i64::from(MAX_MONEY_SCALE) {
186            return Err(MoneyError::PrecisionTooHigh {
187                value: value.to_string(),
188                scale: u32::try_from(scale).unwrap_or(u32::MAX),
189                max_scale: MAX_MONEY_SCALE,
190            });
191        }
192
193        let overflow = || MoneyError::Overflow { context: format!("the minor units of `{value}`") };
194        let mut magnitude = digits.bytes().try_fold(0_i128, |acc, digit| {
195            acc.checked_mul(10)?.checked_add(i128::from(digit - b'0'))
196        });
197        if scale < 0 {
198            let factor = u32::try_from(-scale).ok().and_then(|power| 10_i128.checked_pow(power));
199            magnitude = magnitude.zip(factor).and_then(|(value, factor)| value.checked_mul(factor));
200            scale = 0;
201        }
202        let magnitude = magnitude.ok_or_else(overflow)?;
203        let signed = if negative { -magnitude } else { magnitude };
204        let amount_minor = i64::try_from(signed).map_err(|_| overflow())?;
205        let scale = u32::try_from(scale).map_err(|_| overflow())?;
206        Ok(Self::new(currency, amount_minor, scale))
207    }
208
209    /// Re-expresses this amount at `scale` without rounding.
210    ///
211    /// # Example
212    ///
213    /// ```
214    /// use adk_payments::domain::Money;
215    ///
216    /// let amount = Money::new("USD", 25, 1);
217    /// assert_eq!(amount.rescaled(3).unwrap(), Money::new("USD", 2_500, 3));
218    /// assert_eq!(Money::new("USD", 2_500, 3).rescaled(2).unwrap(), Money::new("USD", 250, 2));
219    /// assert!(Money::new("USD", 1_125, 3).rescaled(2).is_err());
220    /// ```
221    ///
222    /// # Errors
223    ///
224    /// Returns [`MoneyError::PrecisionTooHigh`] when lowering the scale would
225    /// drop non-zero digits or `scale` exceeds [`MAX_MONEY_SCALE`], and
226    /// [`MoneyError::Overflow`] when the rescaled value does not fit in `i64`.
227    pub fn rescaled(&self, scale: u32) -> Result<Self, MoneyError> {
228        if scale > MAX_MONEY_SCALE {
229            return Err(MoneyError::PrecisionTooHigh {
230                value: self.to_decimal_string(),
231                scale,
232                max_scale: MAX_MONEY_SCALE,
233            });
234        }
235        let overflow = || MoneyError::Overflow {
236            context: format!("`{}` at scale {scale}", self.to_decimal_string()),
237        };
238        let amount_minor = match scale.cmp(&self.scale) {
239            Ordering::Equal => self.amount_minor,
240            Ordering::Greater => {
241                let factor = 10_i64.checked_pow(scale - self.scale).ok_or_else(overflow)?;
242                self.amount_minor.checked_mul(factor).ok_or_else(overflow)?
243            }
244            Ordering::Less => {
245                let exact = 10_i64
246                    .checked_pow(self.scale - scale)
247                    .filter(|factor| self.amount_minor % factor == 0);
248                match exact {
249                    Some(factor) => self.amount_minor / factor,
250                    None if self.amount_minor == 0 => 0,
251                    None => {
252                        return Err(MoneyError::PrecisionTooHigh {
253                            value: self.to_decimal_string(),
254                            scale: self.scale,
255                            max_scale: scale,
256                        });
257                    }
258                }
259            }
260        };
261        Ok(Self::new(self.currency.clone(), amount_minor, scale))
262    }
263
264    /// Adds two amounts of the same currency, aligning them to the larger scale.
265    ///
266    /// # Example
267    ///
268    /// ```
269    /// use adk_payments::domain::Money;
270    ///
271    /// let sum = Money::new("USD", 1_125, 3).checked_add(&Money::new("USD", 25, 1)).unwrap();
272    /// assert_eq!(sum, Money::new("USD", 3_625, 3));
273    /// ```
274    ///
275    /// # Errors
276    ///
277    /// Returns [`MoneyError::CurrencyMismatch`] when the currencies differ
278    /// (ignoring ASCII case) and [`MoneyError::Overflow`] when the sum does not
279    /// fit in `i64` minor units.
280    pub fn checked_add(&self, other: &Self) -> Result<Self, MoneyError> {
281        let (left, right) = self.aligned_with(other)?;
282        let amount_minor = left.amount_minor.checked_add(right.amount_minor).ok_or_else(|| {
283            MoneyError::Overflow {
284                context: format!(
285                    "`{}` + `{}`",
286                    self.to_decimal_string(),
287                    other.to_decimal_string()
288                ),
289            }
290        })?;
291        Ok(Self::new(left.currency, amount_minor, left.scale))
292    }
293
294    /// Subtracts `other` from this amount, aligning both to the larger scale.
295    ///
296    /// # Example
297    ///
298    /// ```
299    /// use adk_payments::domain::Money;
300    ///
301    /// let delta = Money::new("USD", 500, 2).checked_sub(&Money::new("USD", 1_125, 3)).unwrap();
302    /// assert_eq!(delta, Money::new("USD", 3_875, 3));
303    /// ```
304    ///
305    /// # Errors
306    ///
307    /// Returns [`MoneyError::CurrencyMismatch`] when the currencies differ
308    /// (ignoring ASCII case) and [`MoneyError::Overflow`] when the difference
309    /// does not fit in `i64` minor units.
310    pub fn checked_sub(&self, other: &Self) -> Result<Self, MoneyError> {
311        let (left, right) = self.aligned_with(other)?;
312        let amount_minor = left.amount_minor.checked_sub(right.amount_minor).ok_or_else(|| {
313            MoneyError::Overflow {
314                context: format!(
315                    "`{}` - `{}`",
316                    self.to_decimal_string(),
317                    other.to_decimal_string()
318                ),
319            }
320        })?;
321        Ok(Self::new(left.currency, amount_minor, left.scale))
322    }
323
324    /// Compares two amounts numerically, independent of their scales.
325    ///
326    /// Returns `None` when the currencies differ (ignoring ASCII case) or the
327    /// values cannot be aligned without overflow.
328    ///
329    /// # Example
330    ///
331    /// ```
332    /// use std::cmp::Ordering;
333    ///
334    /// use adk_payments::domain::Money;
335    ///
336    /// let three_scale = Money::new("USD", 40_000, 3);
337    /// let two_scale = Money::new("USD", 5_000, 2);
338    /// assert_eq!(three_scale.compare_amount(&two_scale), Some(Ordering::Less));
339    /// assert_eq!(three_scale.compare_amount(&Money::new("JPY", 1, 0)), None);
340    /// ```
341    #[must_use]
342    pub fn compare_amount(&self, other: &Self) -> Option<Ordering> {
343        if !self.currency.eq_ignore_ascii_case(&other.currency) {
344            return None;
345        }
346        let scale = self.scale.max(other.scale);
347        let widen = |money: &Self| {
348            10_i128
349                .checked_pow(scale - money.scale)
350                .and_then(|factor| i128::from(money.amount_minor).checked_mul(factor))
351        };
352        Some(widen(self)?.cmp(&widen(other)?))
353    }
354
355    /// Renders the amount as a plain decimal string such as `-12.50`.
356    ///
357    /// Scales above [`MAX_MONEY_SCALE`] render in exponent form so a corrupt
358    /// scale cannot force a large allocation.
359    ///
360    /// # Example
361    ///
362    /// ```
363    /// use adk_payments::domain::Money;
364    ///
365    /// assert_eq!(Money::new("USD", -1_250, 2).to_decimal_string(), "-12.50");
366    /// assert_eq!(Money::new("JPY", 1_200, 0).to_decimal_string(), "1200");
367    /// ```
368    #[must_use]
369    pub fn to_decimal_string(&self) -> String {
370        let sign = if self.amount_minor < 0 { "-" } else { "" };
371        let magnitude = self.amount_minor.unsigned_abs();
372        if self.scale == 0 {
373            return format!("{sign}{magnitude}");
374        }
375        if self.scale > MAX_MONEY_SCALE {
376            return format!("{sign}{magnitude}e-{}", self.scale);
377        }
378        let width = usize::try_from(self.scale).unwrap_or(usize::MAX).saturating_add(1);
379        let digits = format!("{magnitude:0>width$}");
380        let (whole, fraction) = digits.split_at(digits.len() - (width - 1));
381        format!("{sign}{whole}.{fraction}")
382    }
383
384    fn aligned_with(&self, other: &Self) -> Result<(Self, Self), MoneyError> {
385        if !self.currency.eq_ignore_ascii_case(&other.currency) {
386            return Err(MoneyError::CurrencyMismatch {
387                expected: self.currency.clone(),
388                found: other.currency.clone(),
389            });
390        }
391        let scale = self.scale.max(other.scale);
392        Ok((self.rescaled(scale)?, other.rescaled(scale)?))
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn negative_fraction_below_one_keeps_its_sign() {
402        assert_eq!(Money::parse_decimal("USD", "-0.50").unwrap(), Money::new("USD", -5, 1));
403        assert_eq!(Money::parse_decimal("USD", "-0").unwrap(), Money::new("USD", 0, 0));
404    }
405
406    #[test]
407    fn parses_exponent_notation_exactly() {
408        assert_eq!(Money::parse_decimal("USD", "1.5e-7").unwrap(), Money::new("USD", 15, 8));
409        assert_eq!(
410            Money::parse_decimal("USD", "1e21").unwrap_err(),
411            MoneyError::Overflow { context: "the minor units of `1e21`".to_string() }
412        );
413        assert_eq!(Money::parse_decimal("USD", "12E+2").unwrap(), Money::new("USD", 1_200, 0));
414    }
415
416    #[test]
417    fn rejects_more_than_eighteen_significant_fraction_digits() {
418        assert_eq!(
419            Money::parse_decimal("USD", "0.0000000000000000001").unwrap_err(),
420            MoneyError::PrecisionTooHigh {
421                value: "0.0000000000000000001".to_string(),
422                scale: 19,
423                max_scale: MAX_MONEY_SCALE,
424            }
425        );
426        assert_eq!(
427            Money::parse_decimal("USD", "1.000000000000000000000").unwrap(),
428            Money::new("USD", 1, 0)
429        );
430    }
431
432    #[test]
433    fn rejects_malformed_decimals() {
434        for value in ["", "-", "1.", ".5", "1.2.3", "1e", "abc", "1_000", "+1", "1e+"] {
435            assert!(
436                matches!(
437                    Money::parse_decimal("USD", value),
438                    Err(MoneyError::InvalidDecimal { .. })
439                ),
440                "`{value}` should be rejected"
441            );
442        }
443    }
444
445    #[test]
446    fn rescaling_overflow_is_an_error_not_a_panic() {
447        assert_eq!(
448            Money::new("USD", i64::MAX, 0).rescaled(2).unwrap_err(),
449            MoneyError::Overflow { context: format!("`{}` at scale 2", i64::MAX) }
450        );
451        assert!(Money::new("USD", 1, 0).rescaled(19).is_err());
452    }
453
454    #[test]
455    fn checked_add_aligns_scales_and_rejects_other_currencies() {
456        assert_eq!(
457            Money::new("USD", 1_125, 3).checked_add(&Money::new("usd", 25, 1)).unwrap(),
458            Money::new("USD", 3_625, 3)
459        );
460        assert_eq!(
461            Money::new("USD", 1, 2).checked_add(&Money::new("EUR", 1, 2)).unwrap_err(),
462            MoneyError::CurrencyMismatch { expected: "USD".to_string(), found: "EUR".to_string() }
463        );
464    }
465
466    #[test]
467    fn formats_minimum_value_without_overflow() {
468        assert_eq!(Money::new("USD", i64::MIN, 2).to_decimal_string(), "-92233720368547758.08");
469    }
470}