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 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 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}