lib

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

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 }