quantity_price.rs (7061B)
1 use core::fmt; 2 3 use crate::{Decimal, Money, Quantity, Unit}; 4 5 #[cfg_attr(feature = "serde", derive(serde::Serialize))] 6 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))] 7 #[cfg_attr(all(test, feature = "std"), dto(export))] 8 #[derive(Clone, Debug, PartialEq, Eq)] 9 pub struct QuantityPrice { 10 amount: Money, 11 quantity: Quantity, 12 } 13 14 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 15 pub enum Error { 16 PerQuantityZero, 17 PerQuantityNegative, 18 NegativePrice, 19 NegativeRequestedQuantity, 20 UnitMismatch { have: Unit, want: Unit }, 21 NonConvertibleUnits { from: Unit, to: Unit }, 22 ArithmeticOverflow, 23 } 24 25 impl fmt::Display for Error { 26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 27 match self { 28 Self::PerQuantityZero => f.write_str("price quantity must be greater than zero"), 29 Self::PerQuantityNegative => f.write_str("price quantity must not be negative"), 30 Self::NegativePrice => f.write_str("price amount must not be negative"), 31 Self::NegativeRequestedQuantity => { 32 f.write_str("requested quantity must not be negative") 33 } 34 Self::UnitMismatch { have, want } => { 35 write!(f, "price quantity unit mismatch: have {have}, want {want}") 36 } 37 Self::NonConvertibleUnits { from, to } => { 38 write!(f, "price units are not convertible: {from} -> {to}") 39 } 40 Self::ArithmeticOverflow => f.write_str("price arithmetic overflow"), 41 } 42 } 43 } 44 45 #[cfg(feature = "std")] 46 impl std::error::Error for Error {} 47 48 fn map_conversion_error(error: crate::unit::ConvertError, from: Unit, to: Unit) -> Error { 49 match error { 50 crate::unit::ConvertError::ArithmeticOverflow { .. } => Error::ArithmeticOverflow, 51 _ => Error::NonConvertibleUnits { from, to }, 52 } 53 } 54 55 mod sealed { 56 pub trait Sealed {} 57 58 impl Sealed for super::QuantityPrice {} 59 } 60 61 /// Sealed pricing operations implemented by [`QuantityPrice`]. 62 /// 63 /// Downstream crates may call this trait but cannot implement it: 64 /// 65 /// ```compile_fail 66 /// use radroots_core::{Money, Quantity}; 67 /// use radroots_core::pricing::{Error, QuantityPriceOps}; 68 /// 69 /// struct ForeignPrice; 70 /// 71 /// impl QuantityPriceOps for ForeignPrice { 72 /// fn try_cost_for(&self, _: &Quantity) -> Result<Money, Error> { panic!() } 73 /// fn try_cost_for_rounded(&self, _: &Quantity) -> Result<Money, Error> { panic!() } 74 /// } 75 /// ``` 76 pub trait QuantityPriceOps: sealed::Sealed { 77 /// Calculates the requested cost without silently converting invalid 78 /// quantities, unit mismatches, or arithmetic failures into zero. 79 fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error>; 80 81 /// Calculates first, then rounds the final result to the currency exponent 82 /// with deterministic midpoint-away-from-zero rounding. 83 fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error>; 84 } 85 86 impl QuantityPrice { 87 #[inline] 88 pub fn try_new(amount: Money, quantity: Quantity) -> Result<Self, Error> { 89 amount 90 .ensure_non_negative() 91 .map_err(|_| Error::NegativePrice)?; 92 if quantity.amount().is_sign_negative() { 93 return Err(Error::PerQuantityNegative); 94 } 95 if quantity.amount().is_zero() { 96 return Err(Error::PerQuantityZero); 97 } 98 Ok(Self { amount, quantity }) 99 } 100 101 #[inline] 102 pub fn amount(&self) -> &Money { 103 &self.amount 104 } 105 106 #[inline] 107 pub fn quantity(&self) -> &Quantity { 108 &self.quantity 109 } 110 111 #[inline] 112 pub fn validate(&self) -> Result<(), Error> { 113 Self::try_new(self.amount.clone(), self.quantity.clone()).map(|_| ()) 114 } 115 116 #[inline] 117 pub fn try_cost_for_amount_in(&self, amount: Decimal, unit: Unit) -> Result<Money, Error> { 118 use crate::unit::convert_unit_decimal; 119 120 let target = self.quantity.unit(); 121 122 let normalized = if unit == target { 123 amount 124 } else { 125 convert_unit_decimal(amount, unit, target) 126 .map_err(|error| map_conversion_error(error, unit, target))? 127 }; 128 129 let qty = 130 Quantity::try_new(normalized, target).map_err(|_| Error::NegativeRequestedQuantity)?; 131 self.try_cost_for_rounded(&qty) 132 } 133 134 #[inline] 135 pub fn try_cost_for_quantity_in(&self, qty: &Quantity) -> Result<Money, Error> { 136 self.try_cost_for_amount_in(qty.amount(), qty.unit()) 137 } 138 139 #[inline] 140 pub fn is_price_per_canonical_unit(&self) -> bool { 141 self.quantity.unit() == self.quantity.unit().canonical_unit() 142 && self.quantity.amount() == Decimal::ONE 143 } 144 145 #[inline] 146 pub fn try_to_unit_price(&self, unit: Unit) -> Result<QuantityPrice, Error> { 147 use crate::unit::convert_unit_decimal; 148 149 self.validate()?; 150 151 let normalized = if self.quantity.unit() == unit { 152 self.quantity.amount() 153 } else { 154 convert_unit_decimal(self.quantity.amount(), self.quantity.unit(), unit) 155 .map_err(|error| map_conversion_error(error, self.quantity.unit(), unit))? 156 }; 157 158 if normalized.is_zero() { 159 return Err(Error::PerQuantityZero); 160 } 161 162 let amount = self 163 .amount 164 .checked_div_decimal(normalized) 165 .map_err(|_| Error::ArithmeticOverflow)?; 166 let quantity = 167 Quantity::try_new(Decimal::ONE, unit).map_err(|_| Error::ArithmeticOverflow)?; 168 Self::try_new(amount, quantity) 169 } 170 171 #[inline] 172 pub fn try_to_canonical_unit_price(&self) -> Result<QuantityPrice, Error> { 173 self.try_to_unit_price(self.quantity.unit().canonical_unit()) 174 } 175 } 176 177 #[cfg(feature = "serde")] 178 impl<'de> serde::Deserialize<'de> for QuantityPrice { 179 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 180 #[derive(serde::Deserialize)] 181 struct Wire { 182 amount: Money, 183 quantity: Quantity, 184 } 185 186 let wire = Wire::deserialize(deserializer)?; 187 Self::try_new(wire.amount, wire.quantity).map_err(serde::de::Error::custom) 188 } 189 } 190 191 impl QuantityPriceOps for QuantityPrice { 192 #[inline] 193 fn try_cost_for(&self, qty: &Quantity) -> Result<Money, Error> { 194 self.validate()?; 195 qty.ensure_non_negative() 196 .map_err(|_| Error::NegativeRequestedQuantity)?; 197 if qty.unit() != self.quantity.unit() { 198 return Err(Error::UnitMismatch { 199 have: qty.unit(), 200 want: self.quantity.unit(), 201 }); 202 } 203 let ratio = qty 204 .amount() 205 .checked_div(self.quantity.amount()) 206 .map_err(|_| Error::ArithmeticOverflow)?; 207 self.amount 208 .checked_mul_decimal(ratio) 209 .map_err(|_| Error::ArithmeticOverflow) 210 } 211 212 #[inline] 213 fn try_cost_for_rounded(&self, qty: &Quantity) -> Result<Money, Error> { 214 Ok(self.try_cost_for(qty)?.quantize_to_currency()) 215 } 216 }