#![doc = include_str!("../README.md")]
use std::cmp::Ordering;
pub use rust_decimal::Decimal;
use thiserror::Error;
mod format;
mod parse;
pub use format::Format;
pub use parse::{ParseMoneyError, Parser};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Money {
amount: Decimal,
currency_code: IsoNumericCode,
}
impl Money {
pub fn from_major(major: i64, currency: &Currency) -> Self {
let amount = Decimal::new(major, 0);
Self {
currency_code: currency.numeric_code,
amount,
}
}
pub fn from_minor(minor: i64, currency: &Currency) -> Self {
let amount = Decimal::new(minor, currency.minor_digits);
Self {
currency_code: currency.numeric_code,
amount,
}
}
pub fn from_decimal(amount: Decimal, currency: &Currency) -> Self {
Self {
currency_code: currency.numeric_code,
amount,
}
}
pub fn amount(&self) -> Decimal {
self.amount
}
pub fn currency_code(&self) -> IsoNumericCode {
self.currency_code
}
pub fn checked_add(&self, other: &Money) -> Result<Self, MoneyError> {
Self::check_currency_match(self, other)?;
Ok(Self {
amount: self
.amount
.checked_add(other.amount)
.ok_or(MoneyError::Overflow)?,
currency_code: self.currency_code,
})
}
pub fn checked_sub(&self, other: &Money) -> Result<Self, MoneyError> {
Self::check_currency_match(self, other)?;
Ok(Self {
amount: self
.amount
.checked_sub(other.amount)
.ok_or(MoneyError::Overflow)?,
currency_code: self.currency_code,
})
}
pub fn checked_mul<N: Into<Decimal>>(&self, other: N) -> Result<Self, MoneyError> {
Ok(Self {
amount: self
.amount
.checked_mul(other.into())
.ok_or(MoneyError::Overflow)?,
currency_code: self.currency_code,
})
}
pub fn checked_div<N: Into<Decimal>>(&self, other: N) -> Result<Self, MoneyError> {
let other_dec: Decimal = other.into();
if other_dec.is_zero() {
return Err(MoneyError::DivisionByZero);
}
Ok(Self {
amount: self
.amount
.checked_div(other_dec)
.ok_or(MoneyError::Overflow)?,
currency_code: self.currency_code,
})
}
pub fn round(&self, digits: u32, strategy: RoundingMode) -> Self {
let dec_strategy = match strategy {
RoundingMode::HalfUp => rust_decimal::RoundingStrategy::MidpointAwayFromZero,
RoundingMode::HalfDown => rust_decimal::RoundingStrategy::MidpointTowardZero,
RoundingMode::HalfEven => rust_decimal::RoundingStrategy::MidpointNearestEven,
};
let amount = self.amount.round_dp_with_strategy(digits, dec_strategy);
Self {
amount,
currency_code: self.currency_code,
}
}
fn check_currency_match(a: &Money, b: &Money) -> Result<(), MoneyError> {
if a.currency_code == b.currency_code {
Ok(())
} else {
Err(MoneyError::CurrencyMismatch)
}
}
}
impl std::ops::Add for Money {
type Output = Money;
fn add(self, rhs: Self) -> Self::Output {
self.checked_add(&rhs)
.unwrap_or_else(|e| panic!("addition error: {e}"))
}
}
impl std::ops::Sub for Money {
type Output = Money;
fn sub(self, rhs: Self) -> Self::Output {
self.checked_sub(&rhs)
.unwrap_or_else(|e| panic!("subtraction error: {e}"))
}
}
impl<N> std::ops::Mul<N> for Money
where
N: Into<Decimal>,
{
type Output = Money;
fn mul(self, rhs: N) -> Self::Output {
self.checked_mul(rhs)
.unwrap_or_else(|e| panic!("multiplication error: {e}"))
}
}
impl<N> std::ops::Div<N> for Money
where
N: Into<Decimal>,
{
type Output = Money;
fn div(self, rhs: N) -> Self::Output {
self.checked_div(rhs)
.unwrap_or_else(|e| panic!("division error: {e}"))
}
}
impl std::cmp::PartialOrd for Money {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.currency_code != other.currency_code {
return None;
}
Some(self.amount.cmp(&other.amount))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IsoNumericCode(u32);
impl IsoNumericCode {
pub fn value(&self) -> u32 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IsoAlphabeticCode([u8; 3]);
impl IsoAlphabeticCode {
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0).expect("alphabetic codes are ASCII")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Currency {
alphabetic_code: IsoAlphabeticCode,
numeric_code: IsoNumericCode,
minor_digits: u32,
symbol: &'static str,
}
impl Currency {
pub fn alphabetic_code(&self) -> IsoAlphabeticCode {
self.alphabetic_code
}
pub fn numeric_code(&self) -> IsoNumericCode {
self.numeric_code
}
pub fn minor_digits(&self) -> u32 {
self.minor_digits
}
pub fn symbol(&self) -> &'static str {
self.symbol
}
}
include!(concat!(env!("OUT_DIR"), "/iso_currencies.rs"));
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RoundingMode {
HalfUp,
HalfDown,
HalfEven,
}
#[derive(Error, Debug)]
pub enum MoneyError {
#[error("mismatched currencies")]
CurrencyMismatch,
#[error("overflow")]
Overflow,
#[error("division by zero")]
DivisionByZero,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::prelude::*;
#[test]
fn from_major_minor_decimal_are_equal_test() {
let from_minor = Money::from_minor(100, &Currency::USD);
let from_major = Money::from_major(1, &Currency::USD);
let from_decimal = Money::from_decimal(dec!(1.00), &Currency::USD);
assert_eq!(from_minor, from_major);
assert_eq!(from_major, from_decimal);
assert_eq!(from_minor, from_decimal);
}
#[test]
fn eq_with_different_currencies_are_not_equal() {
let a = Money::from_major(100, &Currency::USD);
let b = Money::from_major(100, &Currency::EUR);
assert_ne!(a, b);
}
#[test]
fn not_equal_with_different_amounts_test() {
let a = Money::from_major(100, &Currency::USD);
let b = Money::from_major(200, &Currency::USD);
assert_ne!(a, b);
}
#[test]
fn add_test() {
let a = Money::from_minor(100, &Currency::USD);
let b = Money::from_minor(200, &Currency::USD);
let expected = Money::from_minor(300, &Currency::USD);
assert_eq!(a + b, expected);
}
#[test]
fn add_overflow_test() {
let a = Money::from_decimal(Decimal::MAX, &Currency::USD);
let b = Money::from_major(1, &Currency::USD);
assert!(a.checked_add(&b).is_err());
}
#[test]
#[should_panic]
fn add_operator_overflow_panics_test() {
let a = Money::from_decimal(Decimal::MAX, &Currency::USD);
let b = Money::from_major(1, &Currency::USD);
let _ = a + b;
}
#[test]
fn add_mismatched_currencies_test() {
let a = Money::from_minor(100, &Currency::USD);
let b = Money::from_minor(100, &Currency::EUR);
assert!(a.checked_add(&b).is_err());
}
#[test]
#[should_panic]
fn add_operator_mismatched_currencies_panics_test() {
let _ = Money::from_minor(100, &Currency::USD) + Money::from_minor(100, &Currency::EUR);
}
#[test]
fn sub_test() {
let a = Money::from_minor(200, &Currency::USD);
let b = Money::from_minor(100, &Currency::USD);
assert_eq!(a - b, Money::from_minor(100, &Currency::USD));
}
#[test]
fn sub_overflow_test() {
let a = Money::from_decimal(Decimal::MIN, &Currency::USD);
let b = Money::from_major(1, &Currency::USD);
assert!(a.checked_sub(&b).is_err());
}
#[test]
fn sub_mismatched_currencies_test() {
let a = Money::from_minor(100, &Currency::USD);
let b = Money::from_minor(100, &Currency::EUR);
assert!(a.checked_sub(&b).is_err());
}
#[test]
#[should_panic]
fn sub_operator_mismatched_currencies_panics_test() {
let _ = Money::from_minor(100, &Currency::USD) - Money::from_minor(100, &Currency::EUR);
}
#[test]
fn mul_test() {
let a = Money::from_major(100, &Currency::USD);
assert_eq!(a * 5, Money::from_major(500, &Currency::USD));
}
#[test]
fn mul_overflow_test() {
let a = Money::from_decimal(Decimal::MAX, &Currency::USD);
assert!(a.checked_mul(2).is_err());
}
#[test]
fn div_test() {
let a = Money::from_major(100, &Currency::USD);
assert_eq!(a / 5, Money::from_major(20, &Currency::USD));
}
#[test]
fn div_overflow_test() {
let a = Money::from_decimal(Decimal::MAX, &Currency::USD);
assert!(a.checked_div(dec!(0.5)).is_err());
}
#[test]
fn div_by_zero_test() {
let a = Money::from_major(100, &Currency::USD);
assert!(a.checked_div(0).is_err());
}
#[test]
#[should_panic]
fn div_operator_by_zero_panics_test() {
let _ = Money::from_major(100, &Currency::USD) / 0;
}
#[test]
fn partial_cmp_test() {
let one = Money::from_major(1, &Currency::USD);
let two = Money::from_major(2, &Currency::USD);
assert_eq!(one.partial_cmp(&two), Some(Ordering::Less));
assert_eq!(one.partial_cmp(&one), Some(Ordering::Equal));
assert_eq!(two.partial_cmp(&one), Some(Ordering::Greater));
}
#[test]
fn partial_cmp_mismatched_currencies_test() {
let a = Money::from_major(1, &Currency::USD);
let b = Money::from_major(1, &Currency::EUR);
assert_eq!(a.partial_cmp(&b), None);
}
#[test]
#[allow(clippy::neg_cmp_op_on_partial_ord)]
fn ordering_operators_test() {
let one = Money::from_major(1, &Currency::USD);
let two = Money::from_major(2, &Currency::USD);
assert!(one < two);
assert!(one <= two);
assert!(one <= one);
assert!(two > one);
assert!(two >= one);
assert!(two >= two);
assert!(!(two < one));
assert!(!(one > two));
}
#[test]
#[allow(clippy::neg_cmp_op_on_partial_ord)]
fn ordering_operators_mismatched_currencies_test() {
let a = Money::from_major(1, &Currency::USD);
let b = Money::from_major(2, &Currency::EUR);
assert!(!(a < b));
assert!(!(a > b));
assert!(!(a <= b));
assert!(!(a >= b));
}
#[test]
fn round_half_up_test() {
let a = Money::from_decimal(dec!(0.125), &Currency::USD);
assert_eq!(
a.round(2, RoundingMode::HalfUp),
Money::from_decimal(dec!(0.13), &Currency::USD)
);
}
#[test]
fn round_half_down_test() {
let a = Money::from_decimal(dec!(0.125), &Currency::USD);
assert_eq!(
a.round(2, RoundingMode::HalfDown),
Money::from_decimal(dec!(0.12), &Currency::USD)
);
}
#[test]
fn round_half_even_test() {
assert_eq!(
Money::from_decimal(dec!(0.125), &Currency::USD).round(2, RoundingMode::HalfEven),
Money::from_decimal(dec!(0.12), &Currency::USD)
);
assert_eq!(
Money::from_decimal(dec!(0.135), &Currency::USD).round(2, RoundingMode::HalfEven),
Money::from_decimal(dec!(0.14), &Currency::USD)
);
}
#[test]
#[should_panic]
fn sub_operator_overflow_panics_test() {
let a = Money::from_decimal(Decimal::MIN, &Currency::USD);
let b = Money::from_major(1, &Currency::USD);
let _ = a - b;
}
#[test]
#[should_panic]
fn mul_operator_overflow_panics_test() {
let _ = Money::from_decimal(Decimal::MAX, &Currency::USD) * 2;
}
#[test]
#[should_panic]
fn div_operator_overflow_panics_test() {
let _ = Money::from_decimal(Decimal::MAX, &Currency::USD) / dec!(0.5);
}
#[test]
fn arithmetic_with_negative_amounts_test() {
let credit = Money::from_major(5, &Currency::USD);
let debit = Money::from_major(-2, &Currency::USD);
assert_eq!(credit + debit, Money::from_major(3, &Currency::USD));
assert_eq!(debit - credit, Money::from_major(-7, &Currency::USD));
assert!(debit < credit);
}
#[test]
fn mul_by_decimal_scalar_test() {
let a = Money::from_major(5, &Currency::USD);
assert_eq!(
a * dec!(0.5),
Money::from_decimal(dec!(2.5), &Currency::USD)
);
}
#[test]
fn div_keeps_fractional_result_test() {
let a = Money::from_major(10, &Currency::USD);
assert_eq!(a / 4, Money::from_decimal(dec!(2.5), &Currency::USD));
}
#[test]
fn round_negative_midpoint_goes_away_from_zero_test() {
let a = Money::from_decimal(dec!(-0.125), &Currency::USD);
assert_eq!(
a.round(2, RoundingMode::HalfUp),
Money::from_decimal(dec!(-0.13), &Currency::USD)
);
}
#[test]
fn round_beyond_scale_is_identity_test() {
let a = Money::from_decimal(dec!(1.5), &Currency::USD);
assert_eq!(a.round(2, RoundingMode::HalfUp), a);
}
#[test]
fn money_accessors_test() {
let a = Money::from_minor(150, &Currency::USD);
assert_eq!(a.amount(), dec!(1.50));
assert_eq!(a.currency_code(), Currency::USD.numeric_code());
}
#[test]
fn currency_accessors_test() {
let currency = Currency::USD;
assert_eq!(currency.alphabetic_code().as_str(), "USD");
assert_eq!(currency.numeric_code().value(), 840);
assert_eq!(currency.minor_digits(), 2);
assert_eq!(currency.symbol(), "$");
}
#[test]
fn display_test() {
assert_eq!(
Money::from_minor(150, &Currency::USD).to_string(),
"1.50 USD"
);
assert_eq!(
Money::from_major(-3, &Currency::USD).to_string(),
"-3.00 USD"
);
}
#[test]
fn currency_lookup_test() {
assert_eq!(Currency::from_alphabetic_code("USD"), Some(Currency::USD));
assert_eq!(Currency::from_alphabetic_code("ZZZ"), None);
assert_eq!(Currency::from_numeric_code(978), Some(Currency::EUR));
assert_eq!(Currency::from_numeric_code(1), None);
}
#[test]
fn currency_catalog_test() {
assert!(Currency::all().contains(&Currency::USD));
assert!(Currency::all().contains(&Currency::XAU));
assert_eq!(Currency::BHD.minor_digits(), 3);
assert_eq!(Currency::XAU.minor_digits(), 0);
}
#[test]
fn from_minor_with_three_minor_digits_test() {
let a = Money::from_minor(1500, &Currency::BHD);
assert_eq!(a.amount(), dec!(1.500));
}
}