lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

money.rs (8830B)


      1 //! Non-negative monetary values and currency-aware quantization.
      2 
      3 use core::fmt;
      4 use rust_decimal::Decimal as BackendDecimal;
      5 use rust_decimal::prelude::ToPrimitive;
      6 
      7 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
      8 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
      9 #[cfg_attr(all(test, feature = "std"), dto(export))]
     10 #[derive(Clone, Debug, PartialEq, Eq)]
     11 pub struct Money {
     12     amount: crate::Decimal,
     13     currency: crate::Currency,
     14 }
     15 
     16 #[non_exhaustive]
     17 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     18 pub enum Error {
     19     NegativeAmount,
     20     NotWholeMinorUnits,
     21     AmountOverflow,
     22     CurrencyMismatch,
     23     ArithmeticOverflow,
     24     DivisionByZero,
     25     ScaleOutOfRange,
     26     PrecisionLoss,
     27 }
     28 
     29 impl fmt::Display for Error {
     30     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     31         match self {
     32             Self::NegativeAmount => write!(f, "money amount must be ≥ 0"),
     33             Self::NotWholeMinorUnits => write!(f, "money not a whole number of minor units"),
     34             Self::AmountOverflow => write!(f, "money minor-unit conversion overflow"),
     35             Self::CurrencyMismatch => write!(f, "money currency mismatch"),
     36             Self::ArithmeticOverflow => write!(f, "money arithmetic overflow"),
     37             Self::DivisionByZero => write!(f, "money division by zero"),
     38             Self::ScaleOutOfRange => write!(f, "money scale is outside the supported range"),
     39             Self::PrecisionLoss => write!(f, "money operation would lose precision"),
     40         }
     41     }
     42 }
     43 
     44 #[cfg(feature = "std")]
     45 impl std::error::Error for Error {}
     46 
     47 impl From<crate::decimal::Error> for Error {
     48     fn from(error: crate::decimal::Error) -> Self {
     49         match error {
     50             crate::decimal::Error::DivisionByZero => Self::DivisionByZero,
     51             crate::decimal::Error::ScaleOutOfRange => Self::ScaleOutOfRange,
     52             crate::decimal::Error::PrecisionLoss => Self::PrecisionLoss,
     53             _ => Self::ArithmeticOverflow,
     54         }
     55     }
     56 }
     57 
     58 impl Money {
     59     #[inline]
     60     pub fn try_new(amount: crate::Decimal, currency: crate::Currency) -> Result<Self, Error> {
     61         if amount.is_sign_negative() && !amount.is_zero() {
     62             return Err(Error::NegativeAmount);
     63         }
     64         Ok(Self {
     65             amount: if amount.is_zero() {
     66                 crate::Decimal::ZERO
     67             } else {
     68                 amount
     69             },
     70             currency,
     71         })
     72     }
     73 
     74     #[inline]
     75     pub const fn amount(&self) -> crate::Decimal {
     76         self.amount
     77     }
     78 
     79     #[inline]
     80     pub const fn currency(&self) -> crate::Currency {
     81         self.currency
     82     }
     83 
     84     #[inline]
     85     pub fn zero(currency: crate::Currency) -> Self {
     86         Self {
     87             amount: crate::Decimal::ZERO,
     88             currency,
     89         }
     90     }
     91 
     92     #[inline]
     93     pub fn is_zero(&self) -> bool {
     94         self.amount.is_zero()
     95     }
     96 
     97     #[inline]
     98     pub(crate) fn ensure_non_negative(&self) -> Result<(), Error> {
     99         if self.amount.is_sign_negative() && !self.amount.is_zero() {
    100             return Err(Error::NegativeAmount);
    101         }
    102         Ok(())
    103     }
    104 
    105     #[inline]
    106     pub fn quantize_to_currency(self) -> Self {
    107         let mut value = self;
    108         let e = value.currency.minor_unit_exponent();
    109         value.amount = crate::Decimal::from_backend(
    110             value
    111                 .amount
    112                 .into_backend()
    113                 .round_dp_with_strategy(e, rust_decimal::RoundingStrategy::MidpointAwayFromZero),
    114         );
    115         value
    116     }
    117 
    118     /// Changes the amount scale without rounding or changing its value.
    119     #[inline]
    120     pub fn try_with_scale_exact(mut self, scale: u32) -> Result<Self, Error> {
    121         self.amount.try_rescale_exact(scale)?;
    122         Ok(self)
    123     }
    124 
    125     #[inline]
    126     pub fn checked_add(&self, rhs: &Self) -> Result<Self, Error> {
    127         if self.currency != rhs.currency {
    128             return Err(Error::CurrencyMismatch);
    129         }
    130         Self::try_new(self.amount.checked_add(rhs.amount)?, self.currency)
    131     }
    132 
    133     #[inline]
    134     pub fn checked_sub(&self, rhs: &Self) -> Result<Self, Error> {
    135         if self.currency != rhs.currency {
    136             return Err(Error::CurrencyMismatch);
    137         }
    138         Self::try_new(self.amount.checked_sub(rhs.amount)?, self.currency)
    139     }
    140 
    141     #[inline]
    142     pub fn checked_mul_decimal(&self, factor: crate::Decimal) -> Result<Self, Error> {
    143         Self::try_new(self.amount.checked_mul(factor)?, self.currency)
    144     }
    145 
    146     #[inline]
    147     pub fn checked_div_decimal(&self, divisor: crate::Decimal) -> Result<Self, Error> {
    148         Self::try_new(self.amount.checked_div(divisor)?, self.currency)
    149     }
    150 
    151     #[inline]
    152     pub fn from_minor_units_u64(amount_minor: u64, currency: crate::Currency) -> Self {
    153         let e = currency.minor_unit_exponent();
    154         let major = BackendDecimal::from_i128_with_scale(amount_minor as i128, e);
    155         Self {
    156             amount: crate::Decimal::from_backend(major),
    157             currency,
    158         }
    159     }
    160 
    161     #[inline]
    162     pub fn from_minor_units_u32(amount_minor: u32, currency: crate::Currency) -> Self {
    163         Self::from_minor_units_u64(amount_minor as u64, currency)
    164     }
    165 
    166     #[inline]
    167     fn pow10(e: u32) -> BackendDecimal {
    168         match e {
    169             0 => BackendDecimal::ONE,
    170             1 => BackendDecimal::from(10u32),
    171             2 => BackendDecimal::from(100u32),
    172             3 => BackendDecimal::from(1_000u32),
    173             _ => {
    174                 let p = 10u128.pow(e.min(38));
    175                 BackendDecimal::from(p)
    176             }
    177         }
    178     }
    179 
    180     #[inline]
    181     pub fn to_minor_units_u64_exact(&self) -> Result<u64, Error> {
    182         let e = self.currency.minor_unit_exponent();
    183         let as_minor = self
    184             .amount
    185             .checked_mul(crate::Decimal::from_backend(Self::pow10(e)))
    186             .map_err(|_| Error::AmountOverflow)?
    187             .into_backend();
    188 
    189         if !as_minor.fract().is_zero() {
    190             return Err(Error::NotWholeMinorUnits);
    191         }
    192         as_minor.to_u64().ok_or(Error::AmountOverflow)
    193     }
    194 
    195     #[inline]
    196     pub fn to_minor_units_u64_rounded(&self) -> Result<u64, Error> {
    197         let e = self.currency.minor_unit_exponent();
    198         let scaled = self
    199             .amount
    200             .into_backend()
    201             .round_dp_with_strategy(e, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
    202         let as_minor = scaled
    203             .checked_mul(Self::pow10(e))
    204             .ok_or(Error::AmountOverflow)?;
    205         debug_assert!(as_minor.fract().is_zero());
    206         as_minor.to_u64().ok_or(Error::AmountOverflow)
    207     }
    208 
    209     #[inline]
    210     pub fn to_minor_units_u32_exact(&self) -> Result<u32, Error> {
    211         let v = self.to_minor_units_u64_exact()?;
    212         u32::try_from(v).map_err(|_| Error::AmountOverflow)
    213     }
    214 
    215     #[inline]
    216     pub fn to_minor_units_u32_rounded(&self) -> Result<u32, Error> {
    217         let v = self.to_minor_units_u64_rounded()?;
    218         u32::try_from(v).map_err(|_| Error::AmountOverflow)
    219     }
    220 }
    221 
    222 #[cfg(test)]
    223 mod invariant_tests {
    224     use super::*;
    225 
    226     fn negative_zero() -> crate::Decimal {
    227         crate::Decimal::from_backend(rust_decimal::Decimal::from_parts(0, 0, 0, true, 0))
    228     }
    229 
    230     #[test]
    231     fn internal_nonnegative_invariant_covers_invalid_and_signed_zero_states() {
    232         let invalid = Money {
    233             amount: crate::Decimal::from_backend(rust_decimal::Decimal::from_parts(
    234                 1, 0, 0, true, 0,
    235             )),
    236             currency: crate::Currency::USD,
    237         };
    238         assert_eq!(invalid.ensure_non_negative(), Err(Error::NegativeAmount));
    239 
    240         let canonical = Money::try_new(negative_zero(), crate::Currency::USD).unwrap();
    241         assert_eq!(canonical.amount(), crate::Decimal::ZERO);
    242         assert_eq!(canonical.ensure_non_negative(), Ok(()));
    243     }
    244 }
    245 
    246 #[cfg(feature = "serde")]
    247 impl<'de> serde::Deserialize<'de> for Money {
    248     fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
    249         #[derive(serde::Deserialize)]
    250         struct Wire {
    251             amount: crate::Decimal,
    252             currency: crate::Currency,
    253         }
    254 
    255         let wire = Wire::deserialize(deserializer)?;
    256         Self::try_new(wire.amount, wire.currency).map_err(serde::de::Error::custom)
    257     }
    258 }
    259 
    260 impl fmt::Display for Money {
    261     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    262         write!(f, "{} {}", self.amount, self.currency)
    263     }
    264 }
    265 
    266 #[cfg(test)]
    267 mod tests {
    268     use super::*;
    269 
    270     #[test]
    271     fn pow10_internal_paths_cover_fallback_branches() {
    272         assert_eq!(Money::pow10(0), BackendDecimal::ONE);
    273         assert_eq!(Money::pow10(1), BackendDecimal::from(10u32));
    274         assert_eq!(Money::pow10(2), BackendDecimal::from(100u32));
    275         assert_eq!(Money::pow10(3), BackendDecimal::from(1_000u32));
    276         assert_eq!(Money::pow10(6), BackendDecimal::from(1_000_000u32));
    277     }
    278 }