Skip to main content

sofie_values/
money.rs

1mod encoding;
2
3use sofie_core::{Error, Result};
4
5use crate::Currency;
6use crate::decimal::{Refusal, divide_rounded, parse_scaled, scaled_text, write_scaled};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub struct Money {
10    minor: i64,
11    currency: Currency,
12}
13
14impl Money {
15    #[must_use]
16    pub fn new(minor: i64, currency: Currency) -> Self {
17        Self { minor, currency }
18    }
19
20    #[must_use]
21    pub fn zero(currency: Currency) -> Self {
22        Self::new(0, currency)
23    }
24
25    pub fn parse_major(text: &str, currency: Currency) -> Result<Money> {
26        let digits = currency.minor_digits();
27        let minor = parse_scaled(text, digits).map_err(|refusal| {
28            let message = match refusal {
29                Refusal::TooManyDecimals if digits == 0 => {
30                    format!("{currency} amounts have no decimals")
31                }
32                Refusal::TooManyDecimals => {
33                    format!("{currency} amounts have at most {digits} decimals")
34                }
35                other => other.message().to_owned(),
36            };
37            Error::invalid("amount", message)
38        })?;
39        Ok(Self::new(minor, currency))
40    }
41
42    #[must_use]
43    pub fn format_major(&self) -> String {
44        scaled_text(self.minor, self.currency.minor_digits())
45    }
46
47    pub fn write_major(&self, out: &mut String) {
48        write_scaled(out, self.minor, self.currency.minor_digits());
49    }
50
51    #[must_use]
52    pub fn minor(&self) -> i64 {
53        self.minor
54    }
55
56    #[must_use]
57    pub fn currency(&self) -> Currency {
58        self.currency
59    }
60
61    #[must_use]
62    pub fn is_positive(&self) -> bool {
63        self.minor > 0
64    }
65
66    #[must_use]
67    pub fn is_zero(&self) -> bool {
68        self.minor == 0
69    }
70
71    pub fn checked_add(self, other: Money) -> Result<Money> {
72        self.combine(other, "add", i64::checked_add)
73    }
74
75    pub fn checked_subtract(self, other: Money) -> Result<Money> {
76        self.combine(other, "subtract", i64::checked_sub)
77    }
78
79    /// The product is exact before it is rounded once, half away from zero.
80    pub fn scale(self, numerator: i64, denominator: i64) -> Result<Money> {
81        if denominator <= 0 {
82            return Err(Error::internal_message(format!(
83                "cannot scale an amount by {numerator}/{denominator}: the denominator must be \
84                 positive"
85            )));
86        }
87        let product = i128::from(self.minor) * i128::from(numerator);
88        let minor = divide_rounded(product, i128::from(denominator)).ok_or_else(too_large)?;
89        Ok(Self::new(minor, self.currency))
90    }
91
92    fn combine(
93        self,
94        other: Money,
95        operation: &str,
96        apply: fn(i64, i64) -> Option<i64>,
97    ) -> Result<Money> {
98        // Amounts reach arithmetic already in one currency, so a mix is the
99        // caller's bug, not something the person asking can correct.
100        if self.currency != other.currency {
101            return Err(Error::internal_message(format!(
102                "cannot {operation} {} and {} amounts",
103                self.currency, other.currency
104            )));
105        }
106        let minor = apply(self.minor, other.minor).ok_or_else(too_large)?;
107        Ok(Self::new(minor, self.currency))
108    }
109}
110
111fn too_large() -> Error {
112    Error::Rule("The amount is too large.".to_owned())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::testing::euros;
119
120    fn dollars(minor: i64) -> Money {
121        Money::new(minor, Currency::parse("USD").unwrap())
122    }
123
124    #[test]
125    fn adds_and_subtracts_in_the_same_currency() {
126        assert_eq!(euros(1000).checked_add(euros(250)).unwrap(), euros(1250));
127        assert_eq!(
128            euros(1000).checked_subtract(euros(1250)).unwrap(),
129            euros(-250)
130        );
131    }
132
133    #[test]
134    fn refuses_arithmetic_across_currencies_as_a_bug() {
135        assert!(matches!(
136            euros(1).checked_add(dollars(1)),
137            Err(Error::Internal { .. })
138        ));
139        assert!(matches!(
140            euros(1).checked_subtract(dollars(1)),
141            Err(Error::Internal { .. })
142        ));
143    }
144
145    #[test]
146    fn refuses_overflow_without_showing_minor_units() {
147        assert_eq!(
148            format!("{:#}", euros(i64::MAX).checked_add(euros(1)).unwrap_err()),
149            "The amount is too large."
150        );
151        assert!(matches!(
152            euros(i64::MIN).checked_subtract(euros(1)),
153            Err(Error::Rule(_))
154        ));
155    }
156
157    #[test]
158    fn reports_its_sign() {
159        let zero = Money::zero(Currency::parse("EUR").unwrap());
160
161        assert!(zero.is_zero() && !zero.is_positive());
162        assert!(euros(1).is_positive() && !euros(1).is_zero());
163        assert!(!euros(-1).is_positive());
164    }
165
166    #[test]
167    fn parses_and_formats_major_units_by_its_currency() {
168        let yen = Currency::parse("JPY").unwrap();
169
170        assert_eq!(
171            Money::parse_major("149.9", euros(0).currency()).unwrap(),
172            euros(14990)
173        );
174        assert_eq!(
175            Money::parse_major("1500", yen).unwrap(),
176            Money::new(1500, yen)
177        );
178        assert!(matches!(
179            Money::parse_major("1.5", yen),
180            Err(Error::Invalid(_))
181        ));
182        assert_eq!(euros(14990).format_major(), "149.90");
183        assert_eq!(Money::new(-1500, yen).format_major(), "-1500");
184    }
185
186    #[test]
187    fn parses_by_the_currency_minor_unit() {
188        let refusal = |text: &str, code: &str| {
189            Money::parse_major(text, Currency::parse(code).unwrap())
190                .unwrap_err()
191                .to_string()
192        };
193
194        assert_eq!(
195            refusal("1500.0", "JPY"),
196            "invalid input: amount: JPY amounts have no decimals;"
197        );
198        assert_eq!(
199            refusal("1.0005", "KWD"),
200            "invalid input: amount: KWD amounts have at most 3 decimals;"
201        );
202        assert_eq!(
203            refusal("1,5", "EUR"),
204            "invalid input: amount: must use one `.` before the decimals and no other separator;"
205        );
206    }
207
208    #[test]
209    fn scales_by_a_ratio_exactly_before_rounding_once() {
210        assert_eq!(euros(1000).scale(1, 3).unwrap(), euros(333));
211        assert_eq!(euros(1000).scale(2, 3).unwrap(), euros(667));
212        assert_eq!(euros(i64::MAX).scale(3, 3).unwrap(), euros(i64::MAX));
213        assert!(matches!(euros(i64::MAX).scale(2, 1), Err(Error::Rule(_))));
214        assert!(matches!(euros(1).scale(1, 0), Err(Error::Internal { .. })));
215        assert!(matches!(euros(1).scale(1, -2), Err(Error::Internal { .. })));
216    }
217}