#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects
)]
use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use thiserror::Error;
const ZERO_EXPONENT_CURRENCIES: [&str; 16] = [
"BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV",
"XAF", "XOF", "XPF",
];
const THREE_EXPONENT_CURRENCIES: [&str; 7] = ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"];
#[derive(Copy, Clone, Eq, PartialEq, Hash)]
pub struct Currency([u8; 3]);
impl Currency {
pub const EUR: Currency = Currency(*b"EUR");
pub const USD: Currency = Currency(*b"USD");
pub const GBP: Currency = Currency(*b"GBP");
pub const JPY: Currency = Currency(*b"JPY");
pub fn new(code: &str) -> Result<Self, ParseCurrencyError> {
if !code.is_ascii() {
return Err(ParseCurrencyError::NotAsciiAlphabetic);
}
if code.len() != 3 {
return Err(ParseCurrencyError::InvalidLength(code.len()));
}
let bytes = code.as_bytes();
if !bytes.iter().all(u8::is_ascii_alphabetic) {
return Err(ParseCurrencyError::NotAsciiAlphabetic);
}
Ok(Self([
bytes[0].to_ascii_uppercase(),
bytes[1].to_ascii_uppercase(),
bytes[2].to_ascii_uppercase(),
]))
}
#[must_use]
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).unwrap_or_default()
}
#[must_use]
pub fn exponent(&self) -> u32 {
let code = self.as_str();
if ZERO_EXPONENT_CURRENCIES.contains(&code) {
0
} else if THREE_EXPONENT_CURRENCIES.contains(&code) {
3
} else {
2
}
}
}
impl FromStr for Currency {
type Err = ParseCurrencyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl fmt::Debug for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Currency").field(&self.as_str()).finish()
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for Currency {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Currency {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let code = String::deserialize(deserializer)?;
Self::new(&code).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Error)]
#[non_exhaustive]
pub enum ParseCurrencyError {
#[error("currency code must be exactly 3 characters, got {0}")]
InvalidLength(usize),
#[error("currency code must contain only ASCII alphabetic characters")]
NotAsciiAlphabetic,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Money {
minor_units: i64,
currency: Currency,
}
impl Money {
#[must_use]
pub fn from_minor(minor_units: i64, currency: Currency) -> Self {
Self {
minor_units,
currency,
}
}
#[must_use]
pub fn minor_units(&self) -> i64 {
self.minor_units
}
#[must_use]
pub fn currency(&self) -> Currency {
self.currency
}
#[must_use]
pub fn checked_add(self, other: Self) -> Option<Self> {
if self.currency != other.currency {
return None;
}
Some(Self {
minor_units: self.minor_units.checked_add(other.minor_units)?,
currency: self.currency,
})
}
#[must_use]
pub fn checked_sub(self, other: Self) -> Option<Self> {
if self.currency != other.currency {
return None;
}
Some(Self {
minor_units: self.minor_units.checked_sub(other.minor_units)?,
currency: self.currency,
})
}
#[must_use]
pub fn to_decimal_string(&self) -> String {
let exponent = self.currency.exponent();
if exponent == 0 {
return self.minor_units.to_string();
}
let scale: u128 = 10u128.checked_pow(exponent).unwrap_or(1);
let magnitude: u128 = i128::from(self.minor_units).unsigned_abs();
let whole = magnitude.checked_div(scale).unwrap_or(0);
let frac = magnitude.checked_rem(scale).unwrap_or(0);
let sign = if self.minor_units < 0 { "-" } else { "" };
let width = exponent as usize;
format!("{sign}{whole}.{frac:0width$}")
}
pub fn parse_decimal(value: &str, currency: Currency) -> Result<Self, ParseMoneyError> {
if value.is_empty() {
return Err(ParseMoneyError::Empty);
}
let negative = value.starts_with('-');
let unsigned = if negative { &value[1..] } else { value };
if unsigned.is_empty() || unsigned.starts_with('-') {
return Err(ParseMoneyError::InvalidFormat);
}
let mut split = unsigned.splitn(2, '.');
let int_part = split.next().unwrap_or_default();
let frac_part = split.next();
if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
return Err(ParseMoneyError::InvalidFormat);
}
let exponent = currency.exponent();
let frac_digits: &str = match frac_part {
None => "",
Some(f) => {
if f.is_empty() {
return Err(ParseMoneyError::InvalidFormat);
}
if !f.bytes().all(|b| b.is_ascii_digit()) {
return Err(ParseMoneyError::InvalidFormat);
}
let len = u32::try_from(f.len()).unwrap_or(u32::MAX);
if len > exponent {
let keep = exponent as usize;
let (kept, extra) = f.split_at(keep);
if !extra.bytes().all(|b| b == b'0') {
return Err(ParseMoneyError::FractionTooLong {
currency,
max_digits: exponent,
});
}
kept
} else {
f
}
}
};
let mut magnitude: u128 = 0;
for b in int_part.bytes().chain(frac_digits.bytes()) {
let digit = u128::from(b.saturating_sub(b'0'));
magnitude = magnitude
.checked_mul(10)
.and_then(|m| m.checked_add(digit))
.ok_or(ParseMoneyError::Overflow)?;
}
let frac_len = u32::try_from(frac_digits.len()).unwrap_or(0);
let pad_exponent = exponent.saturating_sub(frac_len);
let pad = 10u128
.checked_pow(pad_exponent)
.ok_or(ParseMoneyError::Overflow)?;
magnitude = magnitude
.checked_mul(pad)
.ok_or(ParseMoneyError::Overflow)?;
let signed = i128::try_from(magnitude).map_err(|_| ParseMoneyError::Overflow)?;
let signed = if negative {
signed.checked_neg().ok_or(ParseMoneyError::Overflow)?
} else {
signed
};
let minor_units = i64::try_from(signed).map_err(|_| ParseMoneyError::Overflow)?;
Ok(Self {
minor_units,
currency,
})
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.to_decimal_string(), self.currency)
}
}
impl PartialOrd for Money {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.currency != other.currency {
return None;
}
Some(self.minor_units.cmp(&other.minor_units))
}
}
#[derive(Debug, Clone, Eq, PartialEq, Error)]
#[non_exhaustive]
pub enum ParseMoneyError {
#[error("value is empty")]
Empty,
#[error("invalid decimal format")]
InvalidFormat,
#[error("fraction has more significant digits than {currency} allows ({max_digits} max)")]
FractionTooLong {
currency: Currency,
max_digits: u32,
},
#[error("amount overflows i64 minor units")]
Overflow,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::arithmetic_side_effects
)]
mod tests {
use super::*;
use proptest::prelude::*;
const CURRENCIES: [Currency; 5] = [
Currency::EUR,
Currency::USD,
Currency::GBP,
Currency::JPY, Currency(*b"BHD"), ];
#[test]
fn currency_new_normalizes_to_uppercase() {
assert_eq!(Currency::new("eur").unwrap(), Currency::EUR);
assert_eq!(Currency::new("Eur").unwrap().as_str(), "EUR");
}
#[test]
fn currency_new_rejects_wrong_length() {
assert_eq!(
Currency::new("EU").unwrap_err(),
ParseCurrencyError::InvalidLength(2)
);
assert_eq!(
Currency::new("EURO").unwrap_err(),
ParseCurrencyError::InvalidLength(4)
);
}
#[test]
fn currency_new_rejects_empty() {
assert_eq!(
Currency::new("").unwrap_err(),
ParseCurrencyError::InvalidLength(0)
);
}
#[test]
fn currency_new_rejects_digits() {
assert_eq!(
Currency::new("E1R").unwrap_err(),
ParseCurrencyError::NotAsciiAlphabetic
);
}
#[test]
fn currency_new_rejects_non_ascii() {
assert_eq!(
Currency::new("€UR").unwrap_err(),
ParseCurrencyError::NotAsciiAlphabetic
);
}
#[test]
fn currency_from_str_matches_new() {
assert_eq!("gbp".parse::<Currency>().unwrap(), Currency::GBP);
assert!("XX".parse::<Currency>().is_err());
}
#[test]
fn currency_display_and_debug() {
assert_eq!(Currency::EUR.to_string(), "EUR");
assert_eq!(format!("{:?}", Currency::EUR), "Currency(\"EUR\")");
}
#[test]
fn currency_serde_round_trip() {
let json = serde_json::to_string(&Currency::EUR).unwrap();
assert_eq!(json, "\"EUR\"");
let back: Currency = serde_json::from_str(&json).unwrap();
assert_eq!(back, Currency::EUR);
}
#[test]
fn currency_serde_rejects_invalid_code() {
let result: Result<Currency, _> = serde_json::from_str("\"EU\"");
assert!(result.is_err());
}
#[test]
#[cfg(feature = "serde")]
fn money_serde_round_trip() {
let money = Money::from_minor(1234, Currency::EUR);
let json = serde_json::to_string(&money).unwrap();
let back: Money = serde_json::from_str(&json).unwrap();
assert_eq!(back, money);
}
#[test]
#[cfg(feature = "serde")]
fn money_serde_round_trip_negative_amount() {
let money = Money::from_minor(-50, Currency::JPY);
let json = serde_json::to_string(&money).unwrap();
let back: Money = serde_json::from_str(&json).unwrap();
assert_eq!(back, money);
}
#[test]
fn exponent_defaults_to_two() {
assert_eq!(Currency::EUR.exponent(), 2);
assert_eq!(Currency::USD.exponent(), 2);
assert_eq!(Currency::GBP.exponent(), 2);
}
#[test]
fn exponent_zero_for_jpy_and_krw() {
assert_eq!(Currency::JPY.exponent(), 0);
assert_eq!(Currency::new("KRW").unwrap().exponent(), 0);
}
#[test]
fn exponent_three_for_three_decimal_currencies() {
for code in ["BHD", "KWD", "JOD", "TND", "OMR"] {
assert_eq!(Currency::new(code).unwrap().exponent(), 3, "{code}");
}
}
#[test]
fn to_decimal_string_eur_two_decimals() {
assert_eq!(
Money::from_minor(1000, Currency::EUR).to_decimal_string(),
"10.00"
);
assert_eq!(
Money::from_minor(0, Currency::EUR).to_decimal_string(),
"0.00"
);
assert_eq!(
Money::from_minor(1, Currency::EUR).to_decimal_string(),
"0.01"
);
assert_eq!(
Money::from_minor(-50, Currency::EUR).to_decimal_string(),
"-0.50"
);
}
#[test]
fn to_decimal_string_jpy_zero_decimals() {
assert_eq!(
Money::from_minor(1000, Currency::JPY).to_decimal_string(),
"1000"
);
assert_eq!(
Money::from_minor(-7, Currency::JPY).to_decimal_string(),
"-7"
);
}
#[test]
fn to_decimal_string_bhd_three_decimals() {
let bhd = Currency::new("BHD").unwrap();
assert_eq!(Money::from_minor(12345, bhd).to_decimal_string(), "12.345");
assert_eq!(Money::from_minor(5, bhd).to_decimal_string(), "0.005");
}
#[test]
fn parse_decimal_eur_basic() {
assert_eq!(
Money::parse_decimal("10.00", Currency::EUR).unwrap(),
Money::from_minor(1000, Currency::EUR)
);
assert_eq!(
Money::parse_decimal("0.01", Currency::EUR).unwrap(),
Money::from_minor(1, Currency::EUR)
);
assert_eq!(
Money::parse_decimal("-0.50", Currency::EUR).unwrap(),
Money::from_minor(-50, Currency::EUR)
);
}
#[test]
fn parse_decimal_allows_short_fraction() {
assert_eq!(
Money::parse_decimal("10.5", Currency::EUR).unwrap(),
Money::from_minor(1050, Currency::EUR)
);
}
#[test]
fn parse_decimal_jpy_no_fraction() {
assert_eq!(
Money::parse_decimal("1000", Currency::JPY).unwrap(),
Money::from_minor(1000, Currency::JPY)
);
}
#[test]
fn parse_decimal_bhd_three_digit_fraction() {
let bhd = Currency::new("BHD").unwrap();
assert_eq!(
Money::parse_decimal("12.345", bhd).unwrap(),
Money::from_minor(12345, bhd)
);
}
#[test]
fn parse_decimal_rejects_leading_plus() {
assert_eq!(
Money::parse_decimal("+5.00", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
}
#[test]
fn parse_decimal_rejects_repeated_sign() {
assert_eq!(
Money::parse_decimal("--5.00", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
}
#[test]
fn parse_decimal_rejects_empty_string() {
assert_eq!(
Money::parse_decimal("", Currency::EUR),
Err(ParseMoneyError::Empty)
);
}
#[test]
fn parse_decimal_rejects_whitespace() {
assert_eq!(
Money::parse_decimal(" 5.00", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
assert_eq!(
Money::parse_decimal("5.00 ", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
}
#[test]
fn parse_decimal_rejects_empty_integer_part() {
assert_eq!(
Money::parse_decimal(".5", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
}
#[test]
fn parse_decimal_rejects_trailing_dot() {
assert_eq!(
Money::parse_decimal("1.", Currency::EUR),
Err(ParseMoneyError::InvalidFormat)
);
}
#[test]
fn parse_decimal_rejects_fraction_longer_than_exponent() {
assert_eq!(
Money::parse_decimal("10.999", Currency::EUR),
Err(ParseMoneyError::FractionTooLong {
currency: Currency::EUR,
max_digits: 2,
})
);
}
#[test]
fn parse_decimal_rejects_nonzero_fraction_for_zero_exponent_currency() {
assert_eq!(
Money::parse_decimal("10.1", Currency::JPY),
Err(ParseMoneyError::FractionTooLong {
currency: Currency::JPY,
max_digits: 0,
})
);
}
#[test]
fn parse_decimal_accepts_redundant_trailing_zeros_eur() {
assert_eq!(
Money::parse_decimal("10.000", Currency::EUR).unwrap(),
Money::from_minor(1000, Currency::EUR)
);
assert_eq!(
Money::parse_decimal("0.010000", Currency::EUR).unwrap(),
Money::from_minor(1, Currency::EUR)
);
}
#[test]
fn parse_decimal_accepts_redundant_trailing_zeros_jpy() {
assert_eq!(
Money::parse_decimal("1000.00", Currency::JPY).unwrap(),
Money::from_minor(1000, Currency::JPY)
);
assert_eq!(
Money::parse_decimal("10.0", Currency::JPY).unwrap(),
Money::from_minor(10, Currency::JPY)
);
}
#[test]
fn parse_decimal_still_rejects_a_fraction_that_would_lose_precision() {
assert_eq!(
Money::parse_decimal("10.999", Currency::EUR),
Err(ParseMoneyError::FractionTooLong {
currency: Currency::EUR,
max_digits: 2,
})
);
assert_eq!(
Money::parse_decimal("10.001", Currency::EUR),
Err(ParseMoneyError::FractionTooLong {
currency: Currency::EUR,
max_digits: 2,
})
);
}
#[test]
fn parse_decimal_rejects_overflow_instead_of_wrapping() {
let whole = i64::MAX / 50; let naive_wrapped = whole.wrapping_mul(100);
assert_eq!(
naive_wrapped, -16,
"sanity check: naive unchecked math wraps this huge amount into a tiny, \
entirely plausible-looking one"
);
let crafted = format!("{whole}.00");
let result = Money::parse_decimal(&crafted, Currency::EUR);
assert_eq!(
result,
Err(ParseMoneyError::Overflow),
"an overflowing amount must be rejected, not silently wrapped to {naive_wrapped}"
);
}
#[test]
fn parse_decimal_rejects_absurdly_long_digit_strings() {
let huge = "9".repeat(60);
assert_eq!(
Money::parse_decimal(&huge, Currency::EUR),
Err(ParseMoneyError::Overflow)
);
}
#[test]
fn parse_decimal_rejects_i64_min_magnitude_minus_one() {
assert_eq!(
Money::parse_decimal("-9223372036854775809", Currency::JPY),
Err(ParseMoneyError::Overflow)
);
}
#[test]
fn round_trip_i64_max_and_min_eur() {
for minor in [i64::MAX, i64::MIN, 0, -1, 1] {
let money = Money::from_minor(minor, Currency::EUR);
let parsed = Money::parse_decimal(&money.to_decimal_string(), Currency::EUR).unwrap();
assert_eq!(parsed, money, "round-trip failed for {minor}");
}
}
#[test]
fn round_trip_i64_max_and_min_jpy() {
for minor in [i64::MAX, i64::MIN, 0] {
let money = Money::from_minor(minor, Currency::JPY);
let parsed = Money::parse_decimal(&money.to_decimal_string(), Currency::JPY).unwrap();
assert_eq!(parsed, money, "round-trip failed for {minor}");
}
}
#[test]
fn round_trip_i64_max_and_min_bhd() {
let bhd = Currency::new("BHD").unwrap();
for minor in [i64::MAX, i64::MIN, 0] {
let money = Money::from_minor(minor, bhd);
let parsed = Money::parse_decimal(&money.to_decimal_string(), bhd).unwrap();
assert_eq!(parsed, money, "round-trip failed for {minor}");
}
}
#[test]
fn checked_add_same_currency() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(50, Currency::EUR);
assert_eq!(
a.checked_add(b),
Some(Money::from_minor(150, Currency::EUR))
);
}
#[test]
fn checked_add_currency_mismatch_returns_none() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(50, Currency::USD);
assert_eq!(a.checked_add(b), None);
}
#[test]
fn checked_add_overflow_returns_none() {
let a = Money::from_minor(i64::MAX, Currency::EUR);
let b = Money::from_minor(1, Currency::EUR);
assert_eq!(a.checked_add(b), None);
}
#[test]
fn checked_sub_same_currency() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(50, Currency::EUR);
assert_eq!(a.checked_sub(b), Some(Money::from_minor(50, Currency::EUR)));
}
#[test]
fn checked_sub_overflow_returns_none() {
let a = Money::from_minor(i64::MIN, Currency::EUR);
let b = Money::from_minor(1, Currency::EUR);
assert_eq!(a.checked_sub(b), None);
}
#[test]
fn checked_sub_currency_mismatch_returns_none() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(50, Currency::USD);
assert_eq!(a.checked_sub(b), None);
}
#[test]
fn partial_cmp_orders_same_currency_amounts() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(150, Currency::EUR);
assert!(a < b);
assert!(b > a);
assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal));
}
#[test]
fn partial_cmp_currency_mismatch_returns_none() {
let a = Money::from_minor(100, Currency::EUR);
let b = Money::from_minor(100, Currency::USD);
assert_eq!(a.partial_cmp(&b), None);
}
#[test]
fn money_display_format() {
assert_eq!(
Money::from_minor(1000, Currency::EUR).to_string(),
"10.00 EUR"
);
assert_eq!(
Money::from_minor(1000, Currency::JPY).to_string(),
"1000 JPY"
);
}
proptest! {
#[test]
fn round_trip_arbitrary_amounts(minor in any::<i64>(), idx in 0usize..CURRENCIES.len()) {
let currency = CURRENCIES[idx];
let money = Money::from_minor(minor, currency);
let decimal = money.to_decimal_string();
let parsed = Money::parse_decimal(&decimal, currency).unwrap();
prop_assert_eq!(parsed, money);
}
#[test]
fn parse_decimal_never_panics_on_arbitrary_input(value in ".{0,40}") {
let _ = Money::parse_decimal(&value, Currency::EUR);
}
}
}