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