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