Skip to main content

use_money/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7use use_amount::{Amount, AmountError};
8use use_currency::CurrencyCode;
9
10/// Common money primitives.
11pub mod prelude {
12    pub use crate::{CurrencyMismatch, Money, MoneyError};
13}
14
15/// A currency-safe money value.
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Money {
18    amount: Amount,
19    currency: CurrencyCode,
20}
21
22impl Money {
23    /// Creates money from an amount and currency.
24    #[must_use]
25    pub const fn new(amount: Amount, currency: CurrencyCode) -> Self {
26        Self { amount, currency }
27    }
28
29    /// Returns the amount.
30    #[must_use]
31    pub const fn amount(&self) -> Amount {
32        self.amount
33    }
34
35    /// Returns the currency.
36    #[must_use]
37    pub const fn currency(&self) -> &CurrencyCode {
38        &self.currency
39    }
40
41    /// Returns whether the amount is zero.
42    #[must_use]
43    pub const fn is_zero(&self) -> bool {
44        self.amount.is_zero()
45    }
46
47    /// Adds money values when their currencies match.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`MoneyError::CurrencyMismatch`] when currencies differ and
52    /// [`MoneyError::Amount`] when amount addition fails.
53    pub fn checked_add(&self, other: &Self) -> Result<Self, MoneyError> {
54        self.ensure_same_currency(other)?;
55        Ok(Self::new(
56            self.amount
57                .checked_add(other.amount)
58                .map_err(MoneyError::Amount)?,
59            self.currency.clone(),
60        ))
61    }
62
63    /// Subtracts money values when their currencies match.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`MoneyError::CurrencyMismatch`] when currencies differ and
68    /// [`MoneyError::Amount`] when amount subtraction fails.
69    pub fn checked_sub(&self, other: &Self) -> Result<Self, MoneyError> {
70        self.ensure_same_currency(other)?;
71        Ok(Self::new(
72            self.amount
73                .checked_sub(other.amount)
74                .map_err(MoneyError::Amount)?,
75            self.currency.clone(),
76        ))
77    }
78
79    fn ensure_same_currency(&self, other: &Self) -> Result<(), MoneyError> {
80        if self.currency == other.currency {
81            Ok(())
82        } else {
83            Err(MoneyError::CurrencyMismatch(CurrencyMismatch {
84                expected: self.currency.clone(),
85                actual: other.currency.clone(),
86            }))
87        }
88    }
89}
90
91impl fmt::Display for Money {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(formatter, "{} {}", self.amount, self.currency)
94    }
95}
96
97/// A same-currency operation received different currencies.
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct CurrencyMismatch {
100    expected: CurrencyCode,
101    actual: CurrencyCode,
102}
103
104impl CurrencyMismatch {
105    /// Returns the expected currency.
106    #[must_use]
107    pub const fn expected(&self) -> &CurrencyCode {
108        &self.expected
109    }
110
111    /// Returns the actual currency.
112    #[must_use]
113    pub const fn actual(&self) -> &CurrencyCode {
114        &self.actual
115    }
116}
117
118impl fmt::Display for CurrencyMismatch {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(
121            formatter,
122            "currency mismatch: expected {}, got {}",
123            self.expected, self.actual
124        )
125    }
126}
127
128impl Error for CurrencyMismatch {}
129
130/// Errors returned by money helpers.
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub enum MoneyError {
133    /// A same-currency operation received different currencies.
134    CurrencyMismatch(CurrencyMismatch),
135    /// Amount arithmetic failed.
136    Amount(AmountError),
137}
138
139impl fmt::Display for MoneyError {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self {
142            Self::CurrencyMismatch(error) => error.fmt(formatter),
143            Self::Amount(error) => error.fmt(formatter),
144        }
145    }
146}
147
148impl Error for MoneyError {
149    fn source(&self) -> Option<&(dyn Error + 'static)> {
150        match self {
151            Self::CurrencyMismatch(error) => Some(error),
152            Self::Amount(error) => Some(error),
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use use_amount::Amount;
160    use use_currency::CurrencyCode;
161
162    use super::{Money, MoneyError};
163
164    #[test]
165    fn adds_and_subtracts_same_currency_money() -> Result<(), Box<dyn std::error::Error>> {
166        let usd = CurrencyCode::new("USD")?;
167        let left = Money::new(Amount::from_minor_units(10_000, 2)?, usd.clone());
168        let right = Money::new(Amount::from_minor_units(2_500, 2)?, usd);
169
170        assert_eq!(left.checked_add(&right)?.amount().minor_units(), 12_500);
171        assert_eq!(left.checked_sub(&right)?.amount().minor_units(), 7_500);
172        assert_eq!(left.to_string(), "100.00 USD");
173        Ok(())
174    }
175
176    #[test]
177    fn rejects_currency_mismatch() -> Result<(), Box<dyn std::error::Error>> {
178        let usd = Money::new(Amount::from_minor_units(100, 2)?, CurrencyCode::new("USD")?);
179        let eur = Money::new(Amount::from_minor_units(100, 2)?, CurrencyCode::new("EUR")?);
180
181        let error = usd.checked_add(&eur).expect_err("currencies should differ");
182        assert!(matches!(error, MoneyError::CurrencyMismatch(_)));
183        Ok(())
184    }
185}