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    /// Zero amount.
17    pub const ZERO: Self = Self(Decimal::ZERO);
18
19    /// Integer minor units (cents) as a two-decimal amount.
20    pub fn from_minor(cents: i64) -> Self {
21        Self(Decimal::new(cents, 2))
22    }
23
24    /// Accepts at most two fraction digits. Refuses excess; never rounds.
25    pub fn try_new(value: Decimal) -> Result<Self, AmountError> {
26        if value.scale() > 2 {
27            return Err(AmountError::TooManyDecimals);
28        }
29        Ok(Self(value))
30    }
31
32    /// Parse a decimal string. Excess fraction digits are refused; never rounds.
33    pub fn parse(s: &str) -> Result<Self, AmountError> {
34        let d = Decimal::from_str(s.trim()).map_err(|_| AmountError::TooManyDecimals)?;
35        Self::try_new(d)
36    }
37
38    /// Underlying decimal.
39    pub fn raw(self) -> Decimal {
40        self.0
41    }
42
43    /// Whether the value is zero.
44    pub fn is_zero(self) -> bool {
45        self.0.is_zero()
46    }
47
48    /// Sum, or `None` on overflow. Does not saturate.
49    pub fn checked_add(self, other: Self) -> Option<Self> {
50        self.0
51            .checked_add(other.0)
52            .and_then(|d| Self::try_new(d).ok())
53    }
54
55    /// Difference, or `None` on overflow. Does not saturate.
56    pub fn checked_sub(self, other: Self) -> Option<Self> {
57        self.0
58            .checked_sub(other.0)
59            .and_then(|d| Self::try_new(d).ok())
60    }
61
62    /// Absolute value.
63    pub fn abs(self) -> Self {
64        Self(self.0.abs())
65    }
66
67    /// Sum of amounts, or `None` on overflow. Does not saturate.
68    pub fn checked_sum(amounts: impl IntoIterator<Item = Self>) -> Option<Self> {
69        let mut acc = Self::ZERO;
70        for a in amounts {
71            acc = acc.checked_add(a)?;
72        }
73        Some(acc)
74    }
75
76    /// Commercial rounding (half away from zero) to two decimals — producer
77    /// presentation. Validators use [`crate::arith::xpath_round`].
78    pub fn from_decimal_rounded(value: Decimal) -> Result<Self, AmountError> {
79        use rust_decimal::RoundingStrategy;
80        let rounded = value.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero);
81        Self::try_new(rounded)
82    }
83}
84
85impl fmt::Display for InvoiceAmount {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "{}", self.0.round_dp(2))
88    }
89}
90
91impl FromStr for InvoiceAmount {
92    type Err = AmountError;
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        Self::parse(s)
95    }
96}
97
98/// EN 16931 Unit Price Amount.Type — no 2-dp cap (example `10000.1234`).
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub struct UnitPriceAmount(Decimal);
101
102impl UnitPriceAmount {
103    /// Zero unit price.
104    pub const ZERO: Self = Self(Decimal::ZERO);
105
106    /// Uncapped fraction digits (BT-146, BT-147, BT-148).
107    pub fn new(value: Decimal) -> Self {
108        Self(value)
109    }
110
111    /// Parse a decimal string. No scale cap.
112    pub fn parse(s: &str) -> Result<Self, rust_decimal::Error> {
113        Ok(Self(Decimal::from_str(s.trim())?))
114    }
115
116    /// Underlying decimal.
117    pub fn raw(self) -> Decimal {
118        self.0
119    }
120}
121
122impl fmt::Display for UnitPriceAmount {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", self.0)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn refuses_third_decimal() {
134        assert!(InvoiceAmount::parse("0.005").is_err());
135        assert!(InvoiceAmount::try_new(Decimal::new(5, 3)).is_err());
136    }
137
138    #[test]
139    fn accepts_two_or_fewer() {
140        assert!(InvoiceAmount::parse("100.00").is_ok());
141        assert!(InvoiceAmount::parse("100").is_ok());
142        assert!(InvoiceAmount::parse("100.1").is_ok());
143        assert_eq!(
144            InvoiceAmount::from_minor(10000).raw(),
145            Decimal::new(10000, 2)
146        );
147    }
148
149    #[test]
150    fn unit_price_keeps_four_decimals() {
151        let p = UnitPriceAmount::parse("10000.1234").unwrap();
152        assert_eq!(p.to_string(), "10000.1234");
153    }
154
155    #[test]
156    fn checked_add_no_saturate() {
157        let a = InvoiceAmount::parse("1.00").unwrap();
158        let b = InvoiceAmount::parse("2.50").unwrap();
159        assert_eq!(a.checked_add(b).unwrap().to_string(), "3.50");
160    }
161}