use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Currency {
Try,
Usd,
Eur,
Gbp,
Jpy,
Kwd,
Rub,
Chf,
Nok,
}
impl Currency {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::Try => "TRY",
Self::Usd => "USD",
Self::Eur => "EUR",
Self::Gbp => "GBP",
Self::Jpy => "JPY",
Self::Kwd => "KWD",
Self::Rub => "RUB",
Self::Chf => "CHF",
Self::Nok => "NOK",
}
}
#[must_use]
pub const fn numeric(self) -> &'static str {
match self {
Self::Try => "949",
Self::Usd => "840",
Self::Eur => "978",
Self::Gbp => "826",
Self::Jpy => "392",
Self::Kwd => "414",
Self::Rub => "643",
Self::Chf => "756",
Self::Nok => "578",
}
}
#[must_use]
pub const fn exponent(self) -> u32 {
match self {
Self::Jpy => 0,
Self::Try | Self::Usd | Self::Eur | Self::Gbp | Self::Rub | Self::Chf | Self::Nok => 2,
Self::Kwd => 3,
}
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unsupported currency code: {0}")]
pub struct UnknownCurrency(pub String);
impl FromStr for Currency {
type Err = UnknownCurrency;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
if !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit()) {
return match trimmed.trim_start_matches('0') {
"949" => Ok(Self::Try),
"840" => Ok(Self::Usd),
"978" => Ok(Self::Eur),
"826" => Ok(Self::Gbp),
"392" => Ok(Self::Jpy),
"414" => Ok(Self::Kwd),
"643" => Ok(Self::Rub),
"756" => Ok(Self::Chf),
"578" => Ok(Self::Nok),
_ => Err(UnknownCurrency(s.to_owned())),
};
}
match trimmed.to_ascii_uppercase().as_str() {
"TRY" => Ok(Self::Try),
"USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur),
"GBP" => Ok(Self::Gbp),
"JPY" => Ok(Self::Jpy),
"KWD" => Ok(Self::Kwd),
"RUB" => Ok(Self::Rub),
"CHF" => Ok(Self::Chf),
"NOK" => Ok(Self::Nok),
_ => Err(UnknownCurrency(s.to_owned())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Money {
minor_units: i64,
currency: Currency,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MoneyError {
#[error("`{0}` is not a decimal amount")]
NotDecimal(String),
#[error("`{value}` has more than {exponent} decimal places for {currency}")]
TooPrecise {
value: String,
currency: Currency,
exponent: u32,
},
#[error("`{0}` does not fit in 64 bits of minor units")]
Overflow(String),
#[error("amount must be positive, got {0}")]
NotPositive(i64),
#[error("cannot combine {left} with {right}")]
CurrencyMismatch {
left: Currency,
right: Currency,
},
}
impl Money {
#[must_use]
pub const fn from_minor_units(minor_units: i64, currency: Currency) -> Self {
Self {
minor_units,
currency,
}
}
pub fn parse(value: &str, currency: Currency) -> Result<Self, MoneyError> {
let text = value.trim();
let (sign, digits) = match text.strip_prefix('-') {
Some(rest) => (-1i64, rest),
None => (1i64, text.strip_prefix('+').unwrap_or(text)),
};
let (whole, frac) = match digits.split_once('.') {
Some((w, f)) => (w, f),
None => (digits, ""),
};
let numeric = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !numeric(whole) || (!frac.is_empty() && !numeric(frac)) {
return Err(MoneyError::NotDecimal(value.to_owned()));
}
let exponent = currency.exponent();
let places = u32::try_from(frac.len()).unwrap_or(u32::MAX);
if places > exponent {
return Err(MoneyError::TooPrecise {
value: value.to_owned(),
currency,
exponent,
});
}
let mut padded = String::with_capacity(whole.len() + frac.len() + 1);
padded.push_str(whole);
padded.push_str(frac);
for _ in 0..(exponent - places) {
padded.push('0');
}
let minor_units: i64 = padded
.parse()
.map_err(|_| MoneyError::Overflow(value.to_owned()))?;
Ok(Self {
minor_units: sign * minor_units,
currency,
})
}
#[must_use]
pub const fn minor_units(self) -> i64 {
self.minor_units
}
#[must_use]
pub const fn currency(self) -> Currency {
self.currency
}
pub fn require_positive(self) -> Result<Self, MoneyError> {
if self.minor_units > 0 {
Ok(self)
} else {
Err(MoneyError::NotPositive(self.minor_units))
}
}
pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
self.same_currency(other)?;
self.minor_units
.checked_add(other.minor_units)
.map(|minor_units| Self {
minor_units,
currency: self.currency,
})
.ok_or_else(|| MoneyError::Overflow(format!("{self} + {other}")))
}
pub fn checked_sub(self, other: Self) -> Result<Self, MoneyError> {
self.same_currency(other)?;
self.minor_units
.checked_sub(other.minor_units)
.map(|minor_units| Self {
minor_units,
currency: self.currency,
})
.ok_or_else(|| MoneyError::Overflow(format!("{self} - {other}")))
}
#[must_use]
pub const fn is_zero(self) -> bool {
self.minor_units == 0
}
fn same_currency(self, other: Self) -> Result<(), MoneyError> {
if self.currency == other.currency {
Ok(())
} else {
Err(MoneyError::CurrencyMismatch {
left: self.currency,
right: other.currency,
})
}
}
#[must_use]
pub fn to_decimal_string(self) -> String {
let exponent = self.currency.exponent();
let scale = 10u64.pow(exponent);
let sign = if self.minor_units < 0 { "-" } else { "" };
let magnitude = self.minor_units.unsigned_abs();
if exponent == 0 {
return format!("{sign}{magnitude}");
}
format!(
"{sign}{}.{:0>width$}",
magnitude / scale,
magnitude % scale,
width = usize::try_from(exponent).unwrap_or(usize::MAX)
)
}
}
impl PartialOrd for Money {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
(self.currency == other.currency).then(|| self.minor_units.cmp(&other.minor_units))
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.to_decimal_string(), self.currency)
}
}
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use super::{Currency, Money, MoneyError};
#[test]
fn parses_and_renders_a_two_place_amount() {
let money = Money::parse("10.50", Currency::Try).expect("valid amount");
assert_eq!(money.minor_units(), 1050);
assert_eq!(money.to_decimal_string(), "10.50");
}
#[test]
fn pads_a_missing_fractional_part() {
assert_eq!(
Money::parse("7", Currency::Usd)
.expect("valid")
.minor_units(),
700
);
assert_eq!(
Money::parse("7.5", Currency::Usd)
.expect("valid")
.minor_units(),
750
);
}
#[test]
fn renders_amounts_below_one_with_a_leading_zero() {
let money = Money::from_minor_units(5, Currency::Try);
assert_eq!(money.to_decimal_string(), "0.05");
}
#[test]
fn rejects_more_precision_than_the_currency_has() {
let err = Money::parse("10.505", Currency::Try).expect_err("too precise");
assert!(matches!(err, MoneyError::TooPrecise { .. }));
}
#[test]
fn rejects_text_that_is_not_a_number() {
assert!(matches!(
Money::parse("ten", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
assert!(matches!(
Money::parse("", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
assert!(matches!(
Money::parse("1.2.3", Currency::Try),
Err(MoneyError::NotDecimal(_))
));
}
#[test]
fn a_currency_with_no_minor_unit_never_grows_a_decimal_point() {
let money = Money::parse("1200", Currency::Jpy).expect("valid amount");
assert_eq!(money.minor_units(), 1200);
assert_eq!(money.to_decimal_string(), "1200");
assert!(matches!(
Money::parse("1200.50", Currency::Jpy),
Err(MoneyError::TooPrecise { .. })
));
}
#[test]
fn a_numeric_iso_code_reads_as_the_currency_it_names() {
assert_eq!("0949".parse(), Ok(Currency::Try));
assert_eq!("949".parse(), Ok(Currency::Try));
assert_eq!("643".parse(), Ok(Currency::Rub));
assert_eq!("TRY".parse(), Ok(Currency::Try));
assert_eq!("try".parse(), Ok(Currency::Try));
}
#[test]
fn a_number_that_names_no_currency_is_refused_rather_than_guessed() {
assert!("999".parse::<Currency>().is_err());
assert!("0".parse::<Currency>().is_err());
assert!("94".parse::<Currency>().is_err());
assert!("9X9".parse::<Currency>().is_err());
}
#[test]
fn a_three_place_currency_keeps_all_three() {
let money = Money::parse("1.500", Currency::Kwd).expect("valid amount");
assert_eq!(money.minor_units(), 1500);
assert_eq!(money.to_decimal_string(), "1.500");
assert_eq!(
Money::parse("1.5", Currency::Kwd)
.expect("valid amount")
.minor_units(),
1500
);
assert!(matches!(
Money::parse("1.5005", Currency::Kwd),
Err(MoneyError::TooPrecise { .. })
));
}
#[test]
fn round_trips_through_its_decimal_form() {
for currency in [
Currency::Try,
Currency::Usd,
Currency::Eur,
Currency::Gbp,
Currency::Jpy,
Currency::Kwd,
] {
for minor in [1i64, 5, 99, 100, 101, 1050, 123_456_789] {
let money = Money::from_minor_units(minor, currency);
let back = Money::parse(&money.to_decimal_string(), currency).expect("valid");
assert_eq!(back, money);
}
}
}
#[test]
fn amounts_in_one_currency_add_and_subtract() {
let ten = Money::parse("10.00", Currency::Try).expect("valid");
let three = Money::parse("3.50", Currency::Try).expect("valid");
assert_eq!(
ten.checked_add(three).expect("same currency"),
Money::parse("13.50", Currency::Try).expect("valid")
);
assert_eq!(
ten.checked_sub(three).expect("same currency"),
Money::parse("6.50", Currency::Try).expect("valid")
);
}
#[test]
fn combining_two_currencies_is_an_error_rather_than_a_sum() {
let lira = Money::parse("10.00", Currency::Try).expect("valid");
let dollars = Money::parse("10.00", Currency::Usd).expect("valid");
assert!(matches!(
lira.checked_add(dollars),
Err(MoneyError::CurrencyMismatch {
left: Currency::Try,
right: Currency::Usd,
})
));
assert!(lira.checked_sub(dollars).is_err());
}
#[test]
#[expect(
clippy::neg_cmp_op_on_partial_ord,
reason = "asserting that both directions are false is the whole test"
)]
fn two_currencies_have_no_order_in_either_direction() {
let lira = Money::parse("10.00", Currency::Try).expect("valid");
let dollars = Money::parse("10.00", Currency::Usd).expect("valid");
assert!(lira.partial_cmp(&dollars).is_none());
assert!(!(lira < dollars));
assert!(!(lira >= dollars));
assert_ne!(lira, dollars);
}
#[test]
fn one_currency_orders_by_amount() {
let small = Money::parse("3.50", Currency::Try).expect("valid");
let large = Money::parse("10.00", Currency::Try).expect("valid");
assert!(small < large);
assert!(large >= small);
assert_eq!(small.partial_cmp(&large), Some(Ordering::Less));
}
#[test]
fn subtracting_past_zero_is_negative_and_still_refused_where_it_matters() {
let three = Money::parse("3.50", Currency::Try).expect("valid");
let ten = Money::parse("10.00", Currency::Try).expect("valid");
let owed = three.checked_sub(ten).expect("same currency");
assert_eq!(owed.minor_units(), -650);
assert_eq!(owed.to_decimal_string(), "-6.50");
assert!(owed.require_positive().is_err());
}
#[test]
fn overflow_is_an_error_rather_than_a_wrap() {
let huge = Money::from_minor_units(i64::MAX, Currency::Try);
let one = Money::from_minor_units(1, Currency::Try);
assert!(matches!(
huge.checked_add(one),
Err(MoneyError::Overflow(_))
));
let lowest = Money::from_minor_units(i64::MIN, Currency::Try);
assert!(matches!(
lowest.checked_sub(one),
Err(MoneyError::Overflow(_))
));
}
#[test]
fn zero_knows_itself() {
assert!(Money::from_minor_units(0, Currency::Try).is_zero());
assert!(!Money::from_minor_units(-1, Currency::Try).is_zero());
}
#[test]
fn require_positive_rejects_zero() {
let zero = Money::from_minor_units(0, Currency::Try);
assert!(matches!(
zero.require_positive(),
Err(MoneyError::NotPositive(0))
));
}
}