Skip to main content

decimal_money/
amount.rs

1use rust_decimal::prelude::*;
2use rust_decimal::Decimal;
3use std::ops::{Add, Sub};
4use std::str::FromStr;
5
6use crate::currency::Currency;
7use crate::error::{MoneyError, Result};
8
9/// A monetary amount with a currency.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[must_use]
12#[cfg_attr(feature = "serde_impl", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14pub struct CurrencyAmount {
15    /// The monetary amount.
16    pub amount: Decimal,
17    /// The currency.
18    pub currency: Currency,
19}
20
21impl CurrencyAmount {
22    /// Creates a new `CurrencyAmount` from an integer value.
23    pub fn new(amount: impl Into<Decimal>, currency: Currency) -> Self {
24        Self {
25            amount: amount.into(),
26            currency,
27        }
28    }
29
30    /// Creates a new `CurrencyAmount` from a string representation.
31    ///
32    /// # Arguments
33    /// * `amount` - A string like `"19.99"`
34    /// * `currency` - The currency code like `"USD"`
35    pub fn from_str_values(amount: &str, currency: Currency) -> Result<Self> {
36        let decimal = Decimal::from_str(amount).map_err(|e| {
37            MoneyError::InvalidAmount(format!("Invalid decimal '{amount}': {e}"))
38        })?;
39        Ok(Self::new(decimal, currency))
40    }
41
42    /// Returns true if the amount is zero.
43    pub fn is_zero(&self) -> bool {
44        self.amount.is_zero()
45    }
46
47    /// Returns true if the amount is positive.
48    pub fn is_positive(&self) -> bool {
49        self.amount.is_sign_positive() && !self.amount.is_zero()
50    }
51
52    /// Returns true if the amount is negative.
53    pub fn is_negative(&self) -> bool {
54        self.amount.is_sign_negative() && !self.amount.is_zero()
55    }
56
57    /// Returns the absolute value of the amount.
58    pub fn abs(&self) -> Self {
59        Self {
60            amount: self.amount.abs(),
61            currency: self.currency,
62        }
63    }
64
65    /// Negates the amount.
66    pub fn negate(&self) -> Self {
67        Self {
68            amount: -self.amount,
69            currency: self.currency,
70        }
71    }
72
73    /// Rounds the amount to the currency's decimal places.
74    pub fn round(&self) -> Self {
75        let places = self.currency.decimal_places();
76        Self {
77            amount: self.amount.round_dp(places),
78            currency: self.currency,
79        }
80    }
81
82    /// Rounds the amount to the specified number of decimal places.
83    pub fn round_to(&self, decimal_places: u32) -> Self {
84        Self {
85            amount: self.amount.round_dp(decimal_places),
86            currency: self.currency,
87        }
88    }
89
90    /// Returns the amount as a f64 (may lose precision).
91    pub fn to_f64(&self) -> Option<f64> {
92        self.amount.to_f64()
93    }
94
95    /// Returns the major amount (whole number part) and minor amount (fractional part).
96    pub fn parts(&self) -> (Decimal, Decimal) {
97        let rounded = self.round();
98        let major = rounded.amount.floor();
99        let minor = rounded.amount - major;
100        (major, minor)
101    }
102}
103
104impl Add for CurrencyAmount {
105    type Output = Result<Self>;
106
107    fn add(self, rhs: Self) -> Self::Output {
108        if self.currency != rhs.currency {
109            return Err(MoneyError::CurrencyMismatch {
110                left: self.currency.code().to_string(),
111                right: rhs.currency.code().to_string(),
112            });
113        }
114        let amount = self.amount + rhs.amount;
115        Ok(Self {
116            amount,
117            currency: self.currency,
118        })
119    }
120}
121
122impl Sub for CurrencyAmount {
123    type Output = Result<Self>;
124
125    fn sub(self, rhs: Self) -> Self::Output {
126        if self.currency != rhs.currency {
127            return Err(MoneyError::CurrencyMismatch {
128                left: self.currency.code().to_string(),
129                right: rhs.currency.code().to_string(),
130            });
131        }
132        let amount = self.amount - rhs.amount;
133        Ok(Self {
134            amount,
135            currency: self.currency,
136        })
137    }
138}
139
140impl std::fmt::Display for CurrencyAmount {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let symbol = self.currency.symbol();
143        let places = self.currency.decimal_places();
144        let places = places as usize;
145        let formatted = format!("{:.places$}", self.amount);
146        write!(f, "{symbol}{formatted}")
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_new_amount() {
156        let amount = CurrencyAmount::new(Decimal::from(100), Currency::USD);
157        assert_eq!(amount.amount, Decimal::from(100));
158        assert_eq!(amount.currency, Currency::USD);
159    }
160
161    #[test]
162    fn test_from_str_values() {
163        let amount = CurrencyAmount::from_str_values("19.99", Currency::USD).unwrap();
164        assert_eq!(amount.amount, Decimal::try_from("19.99").unwrap());
165        assert!(CurrencyAmount::from_str_values("abc", Currency::USD).is_err());
166    }
167
168    #[test]
169    fn test_predicates() {
170        let zero = CurrencyAmount::new(Decimal::ZERO, Currency::USD);
171        let positive = CurrencyAmount::new(Decimal::from(5), Currency::USD);
172        let negative = CurrencyAmount::new(Decimal::from(-5), Currency::USD);
173
174        assert!(zero.is_zero());
175        assert!(!zero.is_positive());
176        assert!(!zero.is_negative());
177        assert!(positive.is_positive());
178        assert!(negative.is_negative());
179    }
180
181    #[test]
182    fn test_addition() {
183        let a = CurrencyAmount::new(Decimal::from(10), Currency::USD);
184        let b = CurrencyAmount::new(Decimal::from(20), Currency::USD);
185        let result = (a + b).unwrap();
186        assert_eq!(result.amount, Decimal::from(30));
187    }
188
189    #[test]
190    fn test_addition_currency_mismatch() {
191        let a = CurrencyAmount::new(Decimal::from(10), Currency::USD);
192        let b = CurrencyAmount::new(Decimal::from(20), Currency::EUR);
193        assert!(matches!(a + b, Err(MoneyError::CurrencyMismatch { .. })));
194    }
195
196    #[test]
197    fn test_subtraction() {
198        let a = CurrencyAmount::new(Decimal::from(30), Currency::USD);
199        let b = CurrencyAmount::new(Decimal::from(10), Currency::USD);
200        let result = (a - b).unwrap();
201        assert_eq!(result.amount, Decimal::from(20));
202    }
203
204    #[test]
205    fn test_display() {
206        let amount = CurrencyAmount::new(Decimal::try_from("19.99").unwrap(), Currency::USD);
207        assert_eq!(format!("{amount}"), "$19.99");
208    }
209
210    #[test]
211    fn test_round() {
212        let amount = CurrencyAmount::new(Decimal::try_from("19.999").unwrap(), Currency::USD);
213        let rounded = amount.round();
214        assert_eq!(rounded.amount, Decimal::try_from("20.00").unwrap());
215    }
216}