Skip to main content

core_invoice/
numeric.rs

1//! [`Quantity`] (signed, not money) and [`Percentage`] (per cent, not a fraction).
2
3use rust_decimal::Decimal;
4use std::fmt;
5use std::str::FromStr;
6
7/// Signed quantity (BT-129, BT-149). May be negative. Not money.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub struct Quantity(Decimal);
10
11impl Quantity {
12    /// Zero quantity.
13    pub const ZERO: Self = Self(Decimal::ZERO);
14    /// Quantity one.
15    pub const ONE: Self = Self(Decimal::ONE);
16
17    /// Signed quantity. May be negative. Not money.
18    pub fn new(value: Decimal) -> Self {
19        Self(value)
20    }
21
22    /// Parse a decimal string. Negatives allowed.
23    pub fn parse(s: &str) -> Result<Self, rust_decimal::Error> {
24        Ok(Self(Decimal::from_str(s.trim())?))
25    }
26
27    /// Underlying decimal.
28    pub fn raw(self) -> Decimal {
29        self.0
30    }
31
32    /// True when the value is strictly negative.
33    pub fn is_negative(self) -> bool {
34        self.0.is_sign_negative() && !self.0.is_zero()
35    }
36}
37
38impl fmt::Display for Quantity {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "{}", self.0)
41    }
42}
43
44/// Per cent (`19` means 19%), not a fraction. `19` and `19.00` compare equal.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct Percentage(Decimal);
47
48impl Percentage {
49    /// Zero per cent.
50    pub const ZERO: Self = Self(Decimal::ZERO);
51
52    /// Per cent (`19` means 19%), not a fraction.
53    pub fn new(percent: Decimal) -> Self {
54        Self(percent)
55    }
56
57    /// Convert a fraction (`0.19` → 19%). `None` on overflow.
58    pub fn from_fraction(fraction: Decimal) -> Option<Self> {
59        fraction
60            .checked_mul(Decimal::ONE_HUNDRED)
61            .map(|d| Self(d.normalize()))
62    }
63
64    /// Stored per-cent value (`19` for 19%).
65    pub fn as_percent(self) -> Decimal {
66        self.0
67    }
68
69    /// Fraction (`0.19` for 19%).
70    pub fn as_fraction(self) -> Decimal {
71        self.0 / Decimal::ONE_HUNDRED
72    }
73
74    /// Whether the rate is 0%.
75    pub fn is_zero(self) -> bool {
76        self.0.is_zero()
77    }
78
79    /// Whether the rate is strictly greater than 0%.
80    pub fn is_positive(self) -> bool {
81        self.0 > Decimal::ZERO
82    }
83
84    /// Whether the rate is strictly less than 0%.
85    pub fn is_negative(self) -> bool {
86        self.0 < Decimal::ZERO
87    }
88}
89
90impl fmt::Display for Percentage {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "{}", self.0.normalize())
93    }
94}
95
96impl From<Decimal> for Percentage {
97    fn from(value: Decimal) -> Self {
98        Self::new(value)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn quantity_may_be_negative() {
108        let q = Quantity::new(Decimal::NEGATIVE_ONE);
109        assert!(q.is_negative());
110        assert_eq!(q.to_string(), "-1");
111    }
112
113    #[test]
114    fn ten_percent_is_not_point_one() {
115        let p = Percentage::new(Decimal::from(10));
116        assert_eq!(p.as_percent(), Decimal::from(10));
117        assert_eq!(p.as_fraction(), Decimal::new(10, 2));
118        assert_eq!(
119            Percentage::new(Decimal::from(19)),
120            Percentage::new(Decimal::new(1900, 2))
121        );
122    }
123}