Skip to main content

paft_money/
amount.rs

1use crate::currency::Currency;
2use crate::decimal::{self, Decimal, RoundingStrategy};
3use crate::error::MoneyError;
4use crate::exact::{
5    CurrencyAmount, canonical_amount_format, checked_add_amounts, checked_div_decimal,
6    checked_mul_decimal, checked_sub_amounts, copy_decimal, decimal_from_scaled_units,
7    parse_canonical_decimal, round_to_money,
8};
9use crate::money::Money;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::hash::{Hash, Hasher};
13
14#[cfg(feature = "dataframe")]
15use df_derive_macros::ToDataFrame;
16
17/// Full-precision currency-denominated amount for totals and intermediate values.
18///
19/// `MonetaryAmount` always carries a [`Currency`] and never rounds to the
20/// currency's minor-unit exponent. Use it for exact totals that are not yet
21/// settlement-ready, such as price-times-quantity products, prorations, fee
22/// calculations, or FX intermediates. Convert to [`Money`] explicitly when
23/// final settlement rounding is required.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "dataframe", derive(ToDataFrame))]
26pub struct MonetaryAmount {
27    #[serde(with = "paft_decimal::serde::canonical_str")]
28    amount: Decimal,
29    #[cfg_attr(feature = "dataframe", df_derive(as_str))]
30    currency: Currency,
31}
32
33impl MonetaryAmount {
34    /// Creates a full-precision monetary amount.
35    #[must_use]
36    pub const fn new(amount: Decimal, currency: Currency) -> Self {
37        Self { amount, currency }
38    }
39
40    /// Parses a decimal string using [`decimal::parse_decimal`].
41    ///
42    /// # Errors
43    ///
44    /// Returns [`MoneyError::InvalidDecimal`] when the string cannot be parsed losslessly.
45    pub fn from_canonical_str(amount: &str, currency: Currency) -> Result<Self, MoneyError> {
46        let decimal = parse_canonical_decimal(amount)?;
47        Ok(Self::new(decimal, currency))
48    }
49
50    /// Creates a full-precision amount from integer units and an explicit scale.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`MoneyError::ConversionError`] when the active decimal backend
55    /// cannot represent the integer coefficient and scale. With the default
56    /// backend this can happen when the coefficient exceeds `rust_decimal`'s
57    /// mantissa or the scale exceeds its supported range; the `bigdecimal`
58    /// backend accepts every `i128` coefficient and `u32` scale.
59    pub fn from_scaled_units(
60        units: i128,
61        scale: u32,
62        currency: Currency,
63    ) -> Result<Self, MoneyError> {
64        Ok(Self::new(
65            decimal_from_scaled_units(units, scale)?,
66            currency,
67        ))
68    }
69
70    /// Returns the zero amount for the given currency.
71    #[must_use]
72    pub fn zero(currency: Currency) -> Self {
73        Self::new(decimal::zero(), currency)
74    }
75
76    /// Returns the underlying [`Decimal`], cloning when required by the backend.
77    #[must_use]
78    pub fn amount(&self) -> Decimal {
79        copy_decimal(&self.amount)
80    }
81
82    /// Returns the amount currency.
83    #[must_use]
84    pub const fn currency(&self) -> &Currency {
85        &self.currency
86    }
87
88    /// Returns a canonical string with currency code (`"<amount> <CODE>"`).
89    #[must_use]
90    pub fn format(&self) -> String {
91        canonical_amount_format(self)
92    }
93
94    /// Adds another amount with the same currency.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`MoneyError::CurrencyMismatch`] when currencies differ and
99    /// [`MoneyError::ConversionError`] when the active decimal backend overflows.
100    pub fn try_add(&self, rhs: &Self) -> Result<Self, MoneyError> {
101        let amount = checked_add_amounts(self, rhs)?;
102        Ok(Self::new(amount, self.currency.clone()))
103    }
104
105    /// Subtracts another amount with the same currency.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`MoneyError::CurrencyMismatch`] when currencies differ and
110    /// [`MoneyError::ConversionError`] when the active decimal backend overflows.
111    pub fn try_sub(&self, rhs: &Self) -> Result<Self, MoneyError> {
112        let amount = checked_sub_amounts(self, rhs)?;
113        Ok(Self::new(amount, self.currency.clone()))
114    }
115
116    /// Multiplies the amount by a decimal factor.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`MoneyError::ConversionError`] when the active decimal backend overflows.
121    pub fn try_mul(&self, factor: &Decimal) -> Result<Self, MoneyError> {
122        let amount = checked_mul_decimal(&self.amount, factor)?;
123        Ok(Self::new(amount, self.currency.clone()))
124    }
125
126    /// Divides the amount by a decimal divisor.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`MoneyError::DivisionByZero`] when `divisor` is zero and
131    /// [`MoneyError::ConversionError`] when the active decimal backend overflows.
132    pub fn try_div(&self, divisor: &Decimal) -> Result<Self, MoneyError> {
133        let amount = checked_div_decimal(&self.amount, divisor)?;
134        Ok(Self::new(amount, self.currency.clone()))
135    }
136
137    /// Converts the amount into [`Money`], rounding to the currency exponent with
138    /// [`RoundingStrategy::MidpointAwayFromZero`].
139    ///
140    /// # Errors
141    ///
142    /// Propagates the errors returned by [`Money::new`].
143    pub fn to_money(&self) -> Result<Money, MoneyError> {
144        self.to_money_with(RoundingStrategy::MidpointAwayFromZero, None)
145    }
146
147    /// Converts the amount into [`Money`] using an explicit rounding strategy and precision.
148    ///
149    /// # Errors
150    ///
151    /// - Returns [`MoneyError::MetadataNotFound`] when the currency is missing metadata.
152    /// - Returns [`MoneyError::ConversionError`] when `target_fraction_digits` exceeds the
153    ///   currency exponent.
154    pub fn to_money_with(
155        &self,
156        rounding: RoundingStrategy,
157        target_fraction_digits: Option<u32>,
158    ) -> Result<Money, MoneyError> {
159        round_to_money(
160            &self.amount,
161            self.currency.clone(),
162            rounding,
163            target_fraction_digits,
164        )
165    }
166}
167
168impl CurrencyAmount for MonetaryAmount {
169    fn raw_amount(&self) -> &Decimal {
170        &self.amount
171    }
172
173    fn raw_currency(&self) -> &Currency {
174        &self.currency
175    }
176}
177
178impl Hash for MonetaryAmount {
179    fn hash<H: Hasher>(&self, state: &mut H) {
180        self.currency.hash(state);
181        decimal::to_canonical_string(&self.amount).hash(state);
182    }
183}
184
185impl fmt::Display for MonetaryAmount {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.write_str(&self.format())
188    }
189}
190
191impl From<Money> for MonetaryAmount {
192    fn from(money: Money) -> Self {
193        Self::new(money.amount(), money.currency().clone())
194    }
195}