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        let product = i128::from(self.minor) * i128::from(numerator);
82        let minor = divide_rounded(product, i128::from(denominator)).ok_or_else(|| {
83            Error::Rule(format!(
84                "cannot scale {} {} by {numerator}/{denominator}: the denominator must be \
85                 positive and the result fit an amount",
86                self.minor, self.currency
87            ))
88        })?;
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        if self.currency != other.currency {
99            return Err(Error::Rule(format!(
100                "cannot {operation} {} and {} amounts",
101                self.currency, other.currency
102            )));
103        }
104        let minor = apply(self.minor, other.minor).ok_or_else(|| {
105            Error::Rule(format!(
106                "cannot {operation} {} and {} {}: the result overflows the amount",
107                self.minor, other.minor, self.currency
108            ))
109        })?;
110        Ok(Self::new(minor, self.currency))
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::testing::euros;
118
119    fn dollars(minor: i64) -> Money {
120        Money::new(minor, Currency::parse("USD").unwrap())
121    }
122
123    #[test]
124    fn adds_and_subtracts_in_the_same_currency() {
125        assert_eq!(euros(1000).checked_add(euros(250)).unwrap(), euros(1250));
126        assert_eq!(
127            euros(1000).checked_subtract(euros(1250)).unwrap(),
128            euros(-250)
129        );
130    }
131
132    #[test]
133    fn refuses_arithmetic_across_currencies() {
134        assert!(matches!(
135            euros(1).checked_add(dollars(1)),
136            Err(Error::Rule(_))
137        ));
138        assert!(matches!(
139            euros(1).checked_subtract(dollars(1)),
140            Err(Error::Rule(_))
141        ));
142    }
143
144    #[test]
145    fn refuses_overflow() {
146        assert!(matches!(
147            euros(i64::MAX).checked_add(euros(1)),
148            Err(Error::Rule(_))
149        ));
150        assert!(matches!(
151            euros(i64::MIN).checked_subtract(euros(1)),
152            Err(Error::Rule(_))
153        ));
154    }
155
156    #[test]
157    fn reports_its_sign() {
158        let zero = Money::zero(Currency::parse("EUR").unwrap());
159
160        assert!(zero.is_zero() && !zero.is_positive());
161        assert!(euros(1).is_positive() && !euros(1).is_zero());
162        assert!(!euros(-1).is_positive());
163    }
164
165    #[test]
166    fn parses_and_formats_major_units_by_its_currency() {
167        let yen = Currency::parse("JPY").unwrap();
168
169        assert_eq!(
170            Money::parse_major("149.9", euros(0).currency()).unwrap(),
171            euros(14990)
172        );
173        assert_eq!(
174            Money::parse_major("1500", yen).unwrap(),
175            Money::new(1500, yen)
176        );
177        assert!(matches!(
178            Money::parse_major("1.5", yen),
179            Err(Error::Invalid(_))
180        ));
181        assert_eq!(euros(14990).format_major(), "149.90");
182        assert_eq!(Money::new(-1500, yen).format_major(), "-1500");
183    }
184
185    #[test]
186    fn parses_by_the_currency_minor_unit() {
187        let refusal = |text: &str, code: &str| {
188            Money::parse_major(text, Currency::parse(code).unwrap())
189                .unwrap_err()
190                .to_string()
191        };
192
193        assert_eq!(
194            refusal("1500.0", "JPY"),
195            "invalid input: amount: JPY amounts have no decimals;"
196        );
197        assert_eq!(
198            refusal("1.0005", "KWD"),
199            "invalid input: amount: KWD amounts have at most 3 decimals;"
200        );
201        assert_eq!(
202            refusal("1,5", "EUR"),
203            "invalid input: amount: must use one `.` before the decimals and no other separator;"
204        );
205    }
206
207    #[test]
208    fn scales_by_a_ratio_exactly_before_rounding_once() {
209        assert_eq!(euros(1000).scale(1, 3).unwrap(), euros(333));
210        assert_eq!(euros(1000).scale(2, 3).unwrap(), euros(667));
211        assert_eq!(euros(i64::MAX).scale(3, 3).unwrap(), euros(i64::MAX));
212        assert!(matches!(euros(i64::MAX).scale(2, 1), Err(Error::Rule(_))));
213        assert!(matches!(euros(1).scale(1, 0), Err(Error::Rule(_))));
214        assert!(matches!(euros(1).scale(1, -2), Err(Error::Rule(_))));
215    }
216}