use core::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum MoneyError {
#[error("monetary overflow")]
Overflow,
#[error("cannot allocate across zero total weight")]
ZeroWeight,
#[error("value has more precision than scale {scale} can represent")]
PrecisionLoss {
scale: u8,
},
#[error("invalid ISO 4217 currency code")]
InvalidCurrency,
#[error("invalid monetary literal")]
InvalidLiteral,
#[error("cannot apply a ratio with a zero denominator")]
DivideByZero,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Rounding {
#[default]
HalfUp,
HalfEven,
HalfDown,
TowardZero,
AwayFromZero,
Floor,
Ceiling,
}
fn div_rounded(numerator: i128, denominator: i128, rounding: Rounding) -> Option<i128> {
if denominator == 0 {
return None;
}
let (n, d) = if denominator < 0 {
(numerator.checked_neg()?, denominator.checked_neg()?)
} else {
(numerator, denominator)
};
let quotient = n.checked_div(d)?;
let remainder = n.checked_rem(d)?;
if remainder == 0 {
return Some(quotient);
}
let away = if n < 0 {
quotient.checked_sub(1)?
} else {
quotient.checked_add(1)?
};
let doubled = remainder.checked_abs()?.checked_mul(2)?;
Some(match rounding {
Rounding::TowardZero => quotient,
Rounding::AwayFromZero => away,
Rounding::Floor => {
if n < 0 {
away
} else {
quotient
}
}
Rounding::Ceiling => {
if n < 0 {
quotient
} else {
away
}
}
Rounding::HalfUp => {
if doubled >= d {
away
} else {
quotient
}
}
Rounding::HalfDown => {
if doubled > d {
away
} else {
quotient
}
}
Rounding::HalfEven => match doubled.cmp(&d) {
core::cmp::Ordering::Greater => away,
core::cmp::Ordering::Less => quotient,
core::cmp::Ordering::Equal => {
if quotient.checked_rem(2)? == 0 {
quotient
} else {
away
}
}
},
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Currency([u8; 3]);
#[cfg(feature = "serde")]
impl serde::Serialize for Currency {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.code())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Currency {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'_, str> as serde::Deserialize>::deserialize(d)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
impl Currency {
pub const EUR: Self = Self(*b"EUR");
pub const USD: Self = Self(*b"USD");
pub const GBP: Self = Self(*b"GBP");
pub const CHF: Self = Self(*b"CHF");
pub const JPY: Self = Self(*b"JPY");
pub fn new(code: &str) -> Result<Self, MoneyError> {
let bytes = code.as_bytes();
let [a, b, c] = bytes else {
return Err(MoneyError::InvalidCurrency);
};
if !a.is_ascii_uppercase() || !b.is_ascii_uppercase() || !c.is_ascii_uppercase() {
return Err(MoneyError::InvalidCurrency);
}
Ok(Self([*a, *b, *c]))
}
#[must_use]
pub fn code(&self) -> &str {
core::str::from_utf8(&self.0).unwrap_or("???")
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 3] {
&self.0
}
#[must_use]
pub fn minor_units(&self) -> Option<u8> {
match &self.0 {
b"JPY" | b"KRW" | b"ISK" | b"CLP" | b"VND" | b"XAF" | b"XOF" | b"XPF" => Some(0),
b"BHD" | b"IQD" | b"JOD" | b"KWD" | b"LYD" | b"OMR" | b"TND" => Some(3),
b"EUR" | b"USD" | b"GBP" | b"CHF" | b"AUD" | b"CAD" | b"CNY" | b"CZK" | b"DKK"
| b"HUF" | b"NOK" | b"NZD" | b"PLN" | b"RON" | b"SEK" | b"TRY" | b"ZAR" => Some(2),
_ => None,
}
}
}
impl fmt::Display for Currency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.code())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Amount<const P: u8>(i64);
#[cfg(feature = "serde")]
impl<const P: u8> serde::Serialize for Amount<P> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
#[cfg(feature = "serde")]
impl<'de, const P: u8> serde::Deserialize<'de> for Amount<P> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'_, str> as serde::Deserialize>::deserialize(d)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
impl<const P: u8> Amount<P> {
pub const MAX_PRECISION: u8 = 18;
pub const ZERO: Self = Self::from_minor(0);
pub const MAX: Self = Self::from_minor(i64::MAX);
pub const MIN: Self = Self::from_minor(i64::MIN);
const PRECISION_GUARD: () = assert!(
P <= Self::MAX_PRECISION,
"Amount<P> requires P <= 18; 10^19 exceeds i64::MAX"
);
pub const SCALE: i64 = {
() = Self::PRECISION_GUARD;
pow10(P)
};
#[must_use]
pub const fn from_minor(minor: i64) -> Self {
() = Self::PRECISION_GUARD;
Self(minor)
}
#[must_use]
pub const fn to_minor(self) -> i64 {
self.0
}
pub fn from_major(n: i64) -> Result<Self, MoneyError> {
n.checked_mul(Self::SCALE)
.map(Self)
.ok_or(MoneyError::Overflow)
}
#[must_use]
pub const fn is_zero(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn is_negative(self) -> bool {
self.0 < 0
}
#[must_use]
pub const fn is_positive(self) -> bool {
self.0 > 0
}
pub fn checked_add(self, rhs: Self) -> Result<Self, MoneyError> {
self.0
.checked_add(rhs.0)
.map(Self)
.ok_or(MoneyError::Overflow)
}
pub fn checked_sub(self, rhs: Self) -> Result<Self, MoneyError> {
self.0
.checked_sub(rhs.0)
.map(Self)
.ok_or(MoneyError::Overflow)
}
pub fn checked_neg(self) -> Result<Self, MoneyError> {
self.0.checked_neg().map(Self).ok_or(MoneyError::Overflow)
}
pub fn checked_abs(self) -> Result<Self, MoneyError> {
self.0.checked_abs().map(Self).ok_or(MoneyError::Overflow)
}
pub fn checked_sum(iter: impl IntoIterator<Item = Self>) -> Result<Self, MoneyError> {
let mut acc = Self::ZERO;
for a in iter {
acc = acc.checked_add(a)?;
}
Ok(acc)
}
pub fn checked_mul_int(self, factor: i64) -> Result<Self, MoneyError> {
self.0
.checked_mul(factor)
.map(Self)
.ok_or(MoneyError::Overflow)
}
pub fn checked_mul_ratio(
self,
numerator: i64,
denominator: i64,
rounding: Rounding,
) -> Result<Self, MoneyError> {
if denominator == 0 {
return Err(MoneyError::DivideByZero);
}
let product = i128::from(self.0)
.checked_mul(i128::from(numerator))
.ok_or(MoneyError::Overflow)?;
let scaled =
div_rounded(product, i128::from(denominator), rounding).ok_or(MoneyError::Overflow)?;
i64::try_from(scaled)
.map(Self)
.map_err(|_| MoneyError::Overflow)
}
pub fn rescale<const Q: u8>(self, rounding: Rounding) -> Result<Amount<Q>, MoneyError> {
if Q >= P {
let factor = pow10_checked(Q.saturating_sub(P)).ok_or(MoneyError::Overflow)?;
self.0
.checked_mul(factor)
.map(Amount::<Q>::from_minor)
.ok_or(MoneyError::Overflow)
} else {
let divisor = pow10_checked(P.saturating_sub(Q)).ok_or(MoneyError::Overflow)?;
let scaled = div_rounded(i128::from(self.0), i128::from(divisor), rounding)
.ok_or(MoneyError::Overflow)?;
i64::try_from(scaled)
.map(Amount::<Q>::from_minor)
.map_err(|_| MoneyError::Overflow)
}
}
pub fn distribute(self, n: usize) -> Result<Vec<Self>, MoneyError> {
if n == 0 {
return Err(MoneyError::ZeroWeight);
}
self.allocate(&vec![1u64; n])
}
pub fn allocate(self, weights: &[u64]) -> Result<Vec<Self>, MoneyError> {
if weights.is_empty() {
return Err(MoneyError::ZeroWeight);
}
let mut total: u128 = 0;
for w in weights {
total = total
.checked_add(u128::from(*w))
.ok_or(MoneyError::Overflow)?;
}
if total == 0 {
return Err(MoneyError::ZeroWeight);
}
let negative = self.0 < 0;
let magnitude = u128::from(self.0.unsigned_abs());
let mut parts: Vec<u128> = Vec::with_capacity(weights.len());
let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len());
let mut assigned: u128 = 0;
for (i, w) in weights.iter().enumerate() {
let product = magnitude
.checked_mul(u128::from(*w))
.ok_or(MoneyError::Overflow)?;
let share = product.checked_div(total).ok_or(MoneyError::ZeroWeight)?;
let rem = product.checked_rem(total).ok_or(MoneyError::ZeroWeight)?;
assigned = assigned.checked_add(share).ok_or(MoneyError::Overflow)?;
parts.push(share);
remainders.push((rem, i));
}
let mut leftover = magnitude
.checked_sub(assigned)
.ok_or(MoneyError::Overflow)?;
remainders.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
for (_, idx) in &remainders {
if leftover == 0 {
break;
}
if let Some(slot) = parts.get_mut(*idx) {
*slot = slot.checked_add(1).ok_or(MoneyError::Overflow)?;
leftover = leftover.checked_sub(1).ok_or(MoneyError::Overflow)?;
}
}
parts
.into_iter()
.map(|p| {
let v = i64::try_from(p).map_err(|_| MoneyError::Overflow)?;
if negative {
v.checked_neg().map(Self).ok_or(MoneyError::Overflow)
} else {
Ok(Self(v))
}
})
.collect()
}
pub fn parse(s: &str) -> Result<Self, MoneyError> {
let s = s.trim();
let (negative, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
if digits.is_empty() {
return Err(MoneyError::InvalidLiteral);
}
let (int_part, frac_part) = match digits.split_once('.') {
Some((i, f)) => (i, f),
None => (digits, ""),
};
if int_part.is_empty() && frac_part.is_empty() {
return Err(MoneyError::InvalidLiteral);
}
if !int_part.bytes().all(|b| b.is_ascii_digit())
|| !frac_part.bytes().all(|b| b.is_ascii_digit())
{
return Err(MoneyError::InvalidLiteral);
}
let scale = usize::from(P);
let trimmed = frac_part.trim_end_matches('0');
if trimmed.len() > scale {
return Err(MoneyError::PrecisionLoss { scale: P });
}
let whole: i64 = if int_part.is_empty() {
0
} else {
int_part.parse().map_err(|_| MoneyError::Overflow)?
};
let mut frac: i64 = 0;
for i in 0..scale {
let digit = frac_part
.as_bytes()
.get(i)
.map_or(0, |b| i64::from(b.wrapping_sub(b'0')));
frac = frac
.checked_mul(10)
.and_then(|f| f.checked_add(digit))
.ok_or(MoneyError::Overflow)?;
}
let value = whole
.checked_mul(Self::SCALE)
.and_then(|w| w.checked_add(frac))
.ok_or(MoneyError::Overflow)?;
if negative {
value.checked_neg().map(Self).ok_or(MoneyError::Overflow)
} else {
Ok(Self(value))
}
}
}
const fn pow10_checked(exp: u8) -> Option<i64> {
let mut acc: i64 = 1;
let mut i = 0u8;
while i < exp {
match acc.checked_mul(10) {
Some(v) => acc = v,
None => return None,
}
i = i.wrapping_add(1);
}
Some(acc)
}
const fn pow10(exp: u8) -> i64 {
match pow10_checked(exp) {
Some(v) => v,
None => i64::MAX,
}
}
impl<const P: u8> fmt::Display for Amount<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let scale = usize::from(P);
if scale == 0 {
return write!(f, "{}", self.0);
}
let negative = self.0 < 0;
let magnitude = self.0.unsigned_abs();
let divisor = Self::SCALE.unsigned_abs();
let whole = magnitude.checked_div(divisor).unwrap_or(0);
let frac = magnitude.checked_rem(divisor).unwrap_or(0);
if negative {
f.write_str("-")?;
}
write!(f, "{whole}.{frac:0>scale$}")
}
}
#[cfg(test)]
mod tests {
use super::*;
type Eur = Amount<2>;
#[test]
fn parses_and_displays_round_trip() {
for s in ["0.00", "1.00", "-1.23", "1234.56", "0.07"] {
let a = Eur::parse(s).expect("parses");
assert_eq!(a.to_string(), s, "round trip for {s}");
}
}
#[test]
fn parse_accepts_shorter_fraction() {
assert_eq!(Eur::parse("1.5").expect("parses"), Eur::from_minor(150));
assert_eq!(Eur::parse("1").expect("parses"), Eur::from_minor(100));
}
#[test]
fn parse_rejects_excess_precision() {
assert_eq!(
Eur::parse("1.234"),
Err(MoneyError::PrecisionLoss { scale: 2 })
);
}
#[test]
fn parse_allows_insignificant_trailing_zeros() {
assert_eq!(Eur::parse("1.2300").expect("parses"), Eur::from_minor(123));
}
#[test]
fn parse_rejects_garbage() {
for s in ["", "abc", "1.2.3", "-", "1,5"] {
assert!(Eur::parse(s).is_err(), "should reject {s:?}");
}
}
#[test]
fn arithmetic_reports_overflow_instead_of_panicking() {
assert_eq!(
Eur::MAX.checked_add(Eur::from_minor(1)),
Err(MoneyError::Overflow)
);
assert_eq!(Eur::MIN.checked_neg(), Err(MoneyError::Overflow));
assert_eq!(Eur::MIN.checked_abs(), Err(MoneyError::Overflow));
}
#[test]
fn distribute_conserves_the_total() {
let total = Eur::from_minor(100);
let parts = total.distribute(3).expect("splits");
assert_eq!(parts.len(), 3);
assert_eq!(
Eur::checked_sum(parts.iter().copied()).expect("sums"),
total
);
}
#[test]
fn distribute_gives_leftover_to_leading_parts() {
let parts = Eur::from_minor(100).distribute(3).expect("splits");
assert_eq!(
parts,
vec![
Eur::from_minor(34),
Eur::from_minor(33),
Eur::from_minor(33)
]
);
}
#[test]
fn allocate_uses_largest_remainder() {
let parts = Eur::from_minor(5).allocate(&[1, 1, 1]).expect("splits");
assert_eq!(
parts,
vec![Eur::from_minor(2), Eur::from_minor(2), Eur::from_minor(1)]
);
}
#[test]
fn allocate_respects_weights() {
let parts = Eur::from_minor(1000).allocate(&[1, 4]).expect("splits");
assert_eq!(parts, vec![Eur::from_minor(200), Eur::from_minor(800)]);
}
#[test]
fn allocate_conserves_negative_totals() {
let total = Eur::from_minor(-100);
let parts = total.allocate(&[1, 1, 1]).expect("splits");
assert_eq!(
Eur::checked_sum(parts.iter().copied()).expect("sums"),
total
);
}
#[test]
fn allocate_rejects_zero_weight() {
assert_eq!(
Eur::from_minor(100).allocate(&[0, 0]),
Err(MoneyError::ZeroWeight)
);
assert_eq!(
Eur::from_minor(100).allocate(&[]),
Err(MoneyError::ZeroWeight)
);
assert_eq!(
Eur::from_minor(100).distribute(0),
Err(MoneyError::ZeroWeight)
);
}
#[test]
fn multiplying_by_a_whole_number_is_exact() {
assert_eq!(
Eur::from_minor(199).checked_mul_int(3).expect("fits"),
Eur::from_minor(597)
);
assert_eq!(Eur::MAX.checked_mul_int(2), Err(MoneyError::Overflow));
}
#[test]
fn a_ratio_applies_the_requested_rounding() {
let one_cent = Eur::from_minor(1);
let cases = [
(Rounding::HalfUp, 1),
(Rounding::HalfDown, 0),
(Rounding::HalfEven, 0),
(Rounding::TowardZero, 0),
(Rounding::AwayFromZero, 1),
(Rounding::Floor, 0),
(Rounding::Ceiling, 1),
];
for (mode, expected) in cases {
assert_eq!(
one_cent.checked_mul_ratio(1, 2, mode).expect("fits"),
Eur::from_minor(expected),
"{mode:?} on a positive tie"
);
}
}
#[test]
fn rounding_is_symmetric_about_zero_where_it_should_be() {
let minus = Eur::from_minor(-1);
for mode in [
Rounding::HalfUp,
Rounding::HalfDown,
Rounding::HalfEven,
Rounding::TowardZero,
Rounding::AwayFromZero,
] {
let positive = Eur::from_minor(1)
.checked_mul_ratio(1, 2, mode)
.expect("fits");
let negative = minus.checked_mul_ratio(1, 2, mode).expect("fits");
assert_eq!(
negative,
positive.checked_neg().expect("fits"),
"{mode:?} must not favour a sign"
);
}
assert_eq!(
minus
.checked_mul_ratio(1, 2, Rounding::Floor)
.expect("fits"),
Eur::from_minor(-1),
);
assert_eq!(
minus
.checked_mul_ratio(1, 2, Rounding::Ceiling)
.expect("fits"),
Eur::ZERO,
);
}
#[test]
fn half_even_alternates_on_consecutive_ties() {
let landed: Vec<i64> = (1..=7)
.step_by(2)
.map(|odd| {
Amount::<0>::from_minor(odd)
.checked_mul_ratio(1, 2, Rounding::HalfEven)
.expect("fits")
.to_minor()
})
.collect();
assert_eq!(landed, vec![0, 2, 2, 4]);
}
#[test]
fn a_ratio_is_exact_in_the_intermediate() {
assert_eq!(
Eur::MAX
.checked_mul_ratio(1_000_000, 1_000_000, Rounding::HalfUp)
.expect("the result fits even though the product does not"),
Eur::MAX
);
}
#[test]
fn a_ratio_reports_a_zero_denominator_and_an_unrepresentable_result() {
assert_eq!(
Eur::from_minor(1).checked_mul_ratio(1, 0, Rounding::HalfUp),
Err(MoneyError::DivideByZero)
);
assert_eq!(
Eur::MAX.checked_mul_ratio(2, 1, Rounding::HalfUp),
Err(MoneyError::Overflow)
);
}
#[test]
fn rescaling_wider_is_exact_and_reversible() {
let eur = Eur::parse("12.34").expect("parses");
let wide = eur.rescale::<6>(Rounding::HalfEven).expect("widens");
assert_eq!(wide, Amount::<6>::parse("12.340000").expect("parses"));
assert_eq!(wide.rescale::<2>(Rounding::HalfEven).expect("narrows"), eur);
}
#[test]
fn rescaling_narrower_rounds_and_can_overflow_widening() {
let precise = Amount::<4>::parse("1.2367").expect("parses");
assert_eq!(
precise.rescale::<2>(Rounding::HalfUp).expect("narrows"),
Eur::parse("1.24").expect("parses")
);
assert_eq!(
precise.rescale::<2>(Rounding::TowardZero).expect("narrows"),
Eur::parse("1.23").expect("parses")
);
assert_eq!(
Eur::MAX.rescale::<4>(Rounding::HalfUp),
Err(MoneyError::Overflow)
);
}
#[test]
fn rescaling_to_the_same_scale_is_the_identity() {
let eur = Eur::from_minor(-4321);
assert_eq!(eur.rescale::<2>(Rounding::Floor).expect("no-op"), eur);
}
#[test]
fn currency_validates_code() {
assert_eq!(Currency::new("EUR").expect("valid"), Currency::EUR);
assert!(Currency::new("eur").is_err());
assert!(Currency::new("EU").is_err());
assert!(Currency::new("EURO").is_err());
}
#[test]
fn currency_knows_minor_units() {
assert_eq!(Currency::EUR.minor_units(), Some(2));
assert_eq!(Currency::JPY.minor_units(), Some(0));
assert_eq!(Currency::new("XYZ").expect("valid").minor_units(), None);
}
#[test]
fn zero_scale_displays_as_integer() {
assert_eq!(Amount::<0>::from_minor(1234).to_string(), "1234");
}
#[test]
fn the_range_shrinks_as_the_scale_grows() {
assert!(Amount::<2>::from_major(92_000_000_000_000_000).is_ok());
assert!(Amount::<18>::from_major(9).is_ok());
assert_eq!(Amount::<18>::from_major(10), Err(MoneyError::Overflow));
}
}