Skip to main content

sofie_values/
money.rs

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