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 }