1use std::cmp::Ordering;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6pub const MAX_MONEY_SCALE: u32 = 18;
11
12const ZERO_DECIMAL_CURRENCIES: &[&str] = &[
14 "BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "UYI", "VND",
15 "VUV", "XAF", "XOF", "XPF",
16];
17
18const THREE_DECIMAL_CURRENCIES: &[&str] = &["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"];
20
21const FOUR_DECIMAL_CURRENCIES: &[&str] = &["CLF", "UYW"];
23
24const NO_MINOR_UNIT_CURRENCIES: &[&str] =
26 &["XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XDR", "XPD", "XPT", "XSU", "XTS", "XUA", "XXX"];
27
28#[derive(Debug, Error, Clone, PartialEq, Eq)]
30pub enum MoneyError {
31 #[error(
32 "`{value}` is not a valid decimal monetary value: {reason}. Send a plain decimal number such as `12.34`."
33 )]
34 InvalidDecimal { value: String, reason: String },
35
36 #[error(
37 "`{value}` requires {scale} fraction digits, more than the allowed {max_scale}. Round the amount to at most {max_scale} decimal places."
38 )]
39 PrecisionTooHigh { value: String, scale: u32, max_scale: u32 },
40
41 #[error(
42 "monetary arithmetic overflowed while computing {context}. Reduce the amount or its precision."
43 )]
44 Overflow { context: String },
45
46 #[error(
47 "currency `{found}` does not match `{expected}`. Express every amount in one cart or mandate in the same currency."
48 )]
49 CurrencyMismatch { expected: String, found: String },
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct Money {
59 pub currency: String,
60 pub amount_minor: i64,
61 pub scale: u32,
62}
63
64impl Money {
65 #[must_use]
67 pub fn new(currency: impl Into<String>, amount_minor: i64, scale: u32) -> Self {
68 Self { currency: currency.into(), amount_minor, scale }
69 }
70
71 #[must_use]
89 pub fn iso_minor_unit_scale(currency: &str) -> Option<u32> {
90 if currency.len() != 3 || !currency.bytes().all(|byte| byte.is_ascii_alphabetic()) {
91 return None;
92 }
93 let code = currency.to_ascii_uppercase();
94 let code = code.as_str();
95 if NO_MINOR_UNIT_CURRENCIES.contains(&code) {
96 None
97 } else if ZERO_DECIMAL_CURRENCIES.contains(&code) {
98 Some(0)
99 } else if THREE_DECIMAL_CURRENCIES.contains(&code) {
100 Some(3)
101 } else if FOUR_DECIMAL_CURRENCIES.contains(&code) {
102 Some(4)
103 } else {
104 Some(2)
105 }
106 }
107
108 pub fn parse_decimal(currency: impl Into<String>, value: &str) -> Result<Self, MoneyError> {
130 let invalid = |reason: &str| MoneyError::InvalidDecimal {
131 value: value.to_string(),
132 reason: reason.to_string(),
133 };
134 let (negative, unsigned) = match value.strip_prefix('-') {
135 Some(rest) => (true, rest),
136 None => (false, value),
137 };
138 let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
139 Some(index) => (&unsigned[..index], Some(&unsigned[index + 1..])),
140 None => (unsigned, None),
141 };
142 let (whole, fraction) = match mantissa.split_once('.') {
143 Some((whole, "")) => {
144 return Err(invalid(&format!("`{whole}.` has no digits after the decimal point")));
145 }
146 Some((whole, fraction)) => (whole, fraction),
147 None => (mantissa, ""),
148 };
149 if whole.is_empty() || !whole.bytes().all(|byte| byte.is_ascii_digit()) {
150 return Err(invalid("the integer part must contain only ASCII digits"));
151 }
152 if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
153 return Err(invalid("the fraction part must contain only ASCII digits"));
154 }
155 let exponent = match exponent {
156 None => 0_i64,
157 Some(exponent) => {
158 let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
159 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
160 return Err(invalid("the exponent must be an optionally signed integer"));
161 }
162 exponent.parse::<i64>().map_err(|_| MoneyError::Overflow {
163 context: format!("the exponent of `{value}`"),
164 })?
165 }
166 };
167
168 let mut digits = format!("{whole}{fraction}");
169 let significant_start = digits.find(|digit| digit != '0').unwrap_or(digits.len());
170 digits.drain(..significant_start);
171 if digits.is_empty() {
172 return Ok(Self::new(currency, 0, 0));
173 }
174
175 let fraction_len = i64::try_from(fraction.len()).map_err(|_| MoneyError::Overflow {
176 context: format!("the fraction length of `{value}`"),
177 })?;
178 let mut scale = fraction_len
179 .checked_sub(exponent)
180 .ok_or_else(|| MoneyError::Overflow { context: format!("the scale of `{value}`") })?;
181 while scale > 0 && digits.ends_with('0') {
182 digits.pop();
183 scale -= 1;
184 }
185 if scale > i64::from(MAX_MONEY_SCALE) {
186 return Err(MoneyError::PrecisionTooHigh {
187 value: value.to_string(),
188 scale: u32::try_from(scale).unwrap_or(u32::MAX),
189 max_scale: MAX_MONEY_SCALE,
190 });
191 }
192
193 let overflow = || MoneyError::Overflow { context: format!("the minor units of `{value}`") };
194 let mut magnitude = digits.bytes().try_fold(0_i128, |acc, digit| {
195 acc.checked_mul(10)?.checked_add(i128::from(digit - b'0'))
196 });
197 if scale < 0 {
198 let factor = u32::try_from(-scale).ok().and_then(|power| 10_i128.checked_pow(power));
199 magnitude = magnitude.zip(factor).and_then(|(value, factor)| value.checked_mul(factor));
200 scale = 0;
201 }
202 let magnitude = magnitude.ok_or_else(overflow)?;
203 let signed = if negative { -magnitude } else { magnitude };
204 let amount_minor = i64::try_from(signed).map_err(|_| overflow())?;
205 let scale = u32::try_from(scale).map_err(|_| overflow())?;
206 Ok(Self::new(currency, amount_minor, scale))
207 }
208
209 pub fn rescaled(&self, scale: u32) -> Result<Self, MoneyError> {
228 if scale > MAX_MONEY_SCALE {
229 return Err(MoneyError::PrecisionTooHigh {
230 value: self.to_decimal_string(),
231 scale,
232 max_scale: MAX_MONEY_SCALE,
233 });
234 }
235 let overflow = || MoneyError::Overflow {
236 context: format!("`{}` at scale {scale}", self.to_decimal_string()),
237 };
238 let amount_minor = match scale.cmp(&self.scale) {
239 Ordering::Equal => self.amount_minor,
240 Ordering::Greater => {
241 let factor = 10_i64.checked_pow(scale - self.scale).ok_or_else(overflow)?;
242 self.amount_minor.checked_mul(factor).ok_or_else(overflow)?
243 }
244 Ordering::Less => {
245 let exact = 10_i64
246 .checked_pow(self.scale - scale)
247 .filter(|factor| self.amount_minor % factor == 0);
248 match exact {
249 Some(factor) => self.amount_minor / factor,
250 None if self.amount_minor == 0 => 0,
251 None => {
252 return Err(MoneyError::PrecisionTooHigh {
253 value: self.to_decimal_string(),
254 scale: self.scale,
255 max_scale: scale,
256 });
257 }
258 }
259 }
260 };
261 Ok(Self::new(self.currency.clone(), amount_minor, scale))
262 }
263
264 pub fn checked_add(&self, other: &Self) -> Result<Self, MoneyError> {
281 let (left, right) = self.aligned_with(other)?;
282 let amount_minor = left.amount_minor.checked_add(right.amount_minor).ok_or_else(|| {
283 MoneyError::Overflow {
284 context: format!(
285 "`{}` + `{}`",
286 self.to_decimal_string(),
287 other.to_decimal_string()
288 ),
289 }
290 })?;
291 Ok(Self::new(left.currency, amount_minor, left.scale))
292 }
293
294 pub fn checked_sub(&self, other: &Self) -> Result<Self, MoneyError> {
311 let (left, right) = self.aligned_with(other)?;
312 let amount_minor = left.amount_minor.checked_sub(right.amount_minor).ok_or_else(|| {
313 MoneyError::Overflow {
314 context: format!(
315 "`{}` - `{}`",
316 self.to_decimal_string(),
317 other.to_decimal_string()
318 ),
319 }
320 })?;
321 Ok(Self::new(left.currency, amount_minor, left.scale))
322 }
323
324 #[must_use]
342 pub fn compare_amount(&self, other: &Self) -> Option<Ordering> {
343 if !self.currency.eq_ignore_ascii_case(&other.currency) {
344 return None;
345 }
346 let scale = self.scale.max(other.scale);
347 let widen = |money: &Self| {
348 10_i128
349 .checked_pow(scale - money.scale)
350 .and_then(|factor| i128::from(money.amount_minor).checked_mul(factor))
351 };
352 Some(widen(self)?.cmp(&widen(other)?))
353 }
354
355 #[must_use]
369 pub fn to_decimal_string(&self) -> String {
370 let sign = if self.amount_minor < 0 { "-" } else { "" };
371 let magnitude = self.amount_minor.unsigned_abs();
372 if self.scale == 0 {
373 return format!("{sign}{magnitude}");
374 }
375 if self.scale > MAX_MONEY_SCALE {
376 return format!("{sign}{magnitude}e-{}", self.scale);
377 }
378 let width = usize::try_from(self.scale).unwrap_or(usize::MAX).saturating_add(1);
379 let digits = format!("{magnitude:0>width$}");
380 let (whole, fraction) = digits.split_at(digits.len() - (width - 1));
381 format!("{sign}{whole}.{fraction}")
382 }
383
384 fn aligned_with(&self, other: &Self) -> Result<(Self, Self), MoneyError> {
385 if !self.currency.eq_ignore_ascii_case(&other.currency) {
386 return Err(MoneyError::CurrencyMismatch {
387 expected: self.currency.clone(),
388 found: other.currency.clone(),
389 });
390 }
391 let scale = self.scale.max(other.scale);
392 Ok((self.rescaled(scale)?, other.rescaled(scale)?))
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn negative_fraction_below_one_keeps_its_sign() {
402 assert_eq!(Money::parse_decimal("USD", "-0.50").unwrap(), Money::new("USD", -5, 1));
403 assert_eq!(Money::parse_decimal("USD", "-0").unwrap(), Money::new("USD", 0, 0));
404 }
405
406 #[test]
407 fn parses_exponent_notation_exactly() {
408 assert_eq!(Money::parse_decimal("USD", "1.5e-7").unwrap(), Money::new("USD", 15, 8));
409 assert_eq!(
410 Money::parse_decimal("USD", "1e21").unwrap_err(),
411 MoneyError::Overflow { context: "the minor units of `1e21`".to_string() }
412 );
413 assert_eq!(Money::parse_decimal("USD", "12E+2").unwrap(), Money::new("USD", 1_200, 0));
414 }
415
416 #[test]
417 fn rejects_more_than_eighteen_significant_fraction_digits() {
418 assert_eq!(
419 Money::parse_decimal("USD", "0.0000000000000000001").unwrap_err(),
420 MoneyError::PrecisionTooHigh {
421 value: "0.0000000000000000001".to_string(),
422 scale: 19,
423 max_scale: MAX_MONEY_SCALE,
424 }
425 );
426 assert_eq!(
427 Money::parse_decimal("USD", "1.000000000000000000000").unwrap(),
428 Money::new("USD", 1, 0)
429 );
430 }
431
432 #[test]
433 fn rejects_malformed_decimals() {
434 for value in ["", "-", "1.", ".5", "1.2.3", "1e", "abc", "1_000", "+1", "1e+"] {
435 assert!(
436 matches!(
437 Money::parse_decimal("USD", value),
438 Err(MoneyError::InvalidDecimal { .. })
439 ),
440 "`{value}` should be rejected"
441 );
442 }
443 }
444
445 #[test]
446 fn rescaling_overflow_is_an_error_not_a_panic() {
447 assert_eq!(
448 Money::new("USD", i64::MAX, 0).rescaled(2).unwrap_err(),
449 MoneyError::Overflow { context: format!("`{}` at scale 2", i64::MAX) }
450 );
451 assert!(Money::new("USD", 1, 0).rescaled(19).is_err());
452 }
453
454 #[test]
455 fn checked_add_aligns_scales_and_rejects_other_currencies() {
456 assert_eq!(
457 Money::new("USD", 1_125, 3).checked_add(&Money::new("usd", 25, 1)).unwrap(),
458 Money::new("USD", 3_625, 3)
459 );
460 assert_eq!(
461 Money::new("USD", 1, 2).checked_add(&Money::new("EUR", 1, 2)).unwrap_err(),
462 MoneyError::CurrencyMismatch { expected: "USD".to_string(), found: "EUR".to_string() }
463 );
464 }
465
466 #[test]
467 fn formats_minimum_value_without_overflow() {
468 assert_eq!(Money::new("USD", i64::MIN, 2).to_decimal_string(), "-92233720368547758.08");
469 }
470}