Skip to main content

core_invoice/
amount.rs

1//! [`InvoiceAmount`] (≤2 fraction digits, refuse excess) and [`UnitPriceAmount`] (uncapped).
2
3use crate::error::AmountError;
4use rust_decimal::Decimal;
5use std::fmt;
6use std::str::FromStr;
7
8/// EN 16931 Amount.Type: at most two fraction digits. Never `f64`. Never rounds.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct InvoiceAmount(Decimal);
11
12/// 0.1.x alias. Prefer [`InvoiceAmount`].
13pub type Amount = InvoiceAmount;
14
15impl InvoiceAmount {
16    pub const ZERO: Self = Self(Decimal::ZERO);
17
18    pub fn from_minor(cents: i64) -> Self {
19        Self(Decimal::new(cents, 2))
20    }
21
22    pub fn try_new(value: Decimal) -> Result<Self, AmountError> {
23        if value.scale() > 2 {
24            return Err(AmountError::TooManyDecimals);
25        }
26        Ok(Self(value))
27    }
28
29    pub fn parse(s: &str) -> Result<Self, AmountError> {
30        let d = Decimal::from_str(s.trim()).map_err(|_| AmountError::TooManyDecimals)?;
31        Self::try_new(d)
32    }
33
34    pub fn raw(self) -> Decimal {
35        self.0
36    }
37
38    pub fn is_zero(self) -> bool {
39        self.0.is_zero()
40    }
41
42    pub fn checked_add(self, other: Self) -> Option<Self> {
43        self.0
44            .checked_add(other.0)
45            .and_then(|d| Self::try_new(d).ok())
46    }
47
48    pub fn checked_sub(self, other: Self) -> Option<Self> {
49        self.0
50            .checked_sub(other.0)
51            .and_then(|d| Self::try_new(d).ok())
52    }
53
54    pub fn abs(self) -> Self {
55        Self(self.0.abs())
56    }
57
58    pub fn checked_sum(amounts: impl IntoIterator<Item = Self>) -> Option<Self> {
59        let mut acc = Self::ZERO;
60        for a in amounts {
61            acc = acc.checked_add(a)?;
62        }
63        Some(acc)
64    }
65
66    /// Commercial rounding (half away from zero) to two decimals — producer
67    /// presentation. Validators use [`crate::arith::xpath_round`].
68    pub fn from_decimal_rounded(value: Decimal) -> Result<Self, AmountError> {
69        use rust_decimal::RoundingStrategy;
70        let rounded = value.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero);
71        Self::try_new(rounded)
72    }
73}
74
75impl fmt::Display for InvoiceAmount {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.0.round_dp(2))
78    }
79}
80
81impl FromStr for InvoiceAmount {
82    type Err = AmountError;
83    fn from_str(s: &str) -> Result<Self, Self::Err> {
84        Self::parse(s)
85    }
86}
87
88/// EN 16931 Unit Price Amount.Type — no 2-dp cap (example `10000.1234`).
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub struct UnitPriceAmount(Decimal);
91
92impl UnitPriceAmount {
93    pub const ZERO: Self = Self(Decimal::ZERO);
94
95    pub fn new(value: Decimal) -> Self {
96        Self(value)
97    }
98
99    pub fn parse(s: &str) -> Result<Self, rust_decimal::Error> {
100        Ok(Self(Decimal::from_str(s.trim())?))
101    }
102
103    pub fn raw(self) -> Decimal {
104        self.0
105    }
106}
107
108impl fmt::Display for UnitPriceAmount {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{}", self.0)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn refuses_third_decimal() {
120        assert!(InvoiceAmount::parse("0.005").is_err());
121        assert!(InvoiceAmount::try_new(Decimal::new(5, 3)).is_err());
122    }
123
124    #[test]
125    fn accepts_two_or_fewer() {
126        assert!(InvoiceAmount::parse("100.00").is_ok());
127        assert!(InvoiceAmount::parse("100").is_ok());
128        assert!(InvoiceAmount::parse("100.1").is_ok());
129        assert_eq!(
130            InvoiceAmount::from_minor(10000).raw(),
131            Decimal::new(10000, 2)
132        );
133    }
134
135    #[test]
136    fn unit_price_keeps_four_decimals() {
137        let p = UnitPriceAmount::parse("10000.1234").unwrap();
138        assert_eq!(p.to_string(), "10000.1234");
139    }
140
141    #[test]
142    fn checked_add_no_saturate() {
143        let a = InvoiceAmount::parse("1.00").unwrap();
144        let b = InvoiceAmount::parse("2.50").unwrap();
145        assert_eq!(a.checked_add(b).unwrap().to_string(), "3.50");
146    }
147}