Skip to main content

stateset_primitives/
money.rs

1//! Monetary types with currency safety.
2//!
3//! The [`Money`] type pairs an amount with a [`CurrencyCode`], preventing
4//! accidental arithmetic between different currencies at the type level.
5
6use alloc::string::String;
7use core::fmt;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11/// A monetary amount paired with its currency.
12///
13/// This type ensures that amounts always carry their currency context,
14/// preventing accidental mixing of currencies in arithmetic operations.
15///
16/// # Example
17///
18/// ```rust
19/// use stateset_primitives::{Money, CurrencyCode};
20/// use rust_decimal_macros::dec;
21///
22/// let price = Money::new(dec!(29.99), CurrencyCode::USD);
23/// assert_eq!(price.amount(), dec!(29.99));
24/// assert_eq!(price.currency(), CurrencyCode::USD);
25/// assert_eq!(format!("{}", price), "29.99 USD");
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[must_use]
29pub struct Money {
30    amount: Decimal,
31    currency: CurrencyCode,
32}
33
34impl Money {
35    /// Create a new monetary value.
36    #[inline]
37    pub const fn new(amount: Decimal, currency: CurrencyCode) -> Self {
38        Self { amount, currency }
39    }
40
41    /// Create a zero amount in the given currency.
42    #[inline]
43    pub const fn zero(currency: CurrencyCode) -> Self {
44        Self { amount: Decimal::ZERO, currency }
45    }
46
47    /// Get the amount.
48    #[inline]
49    #[must_use]
50    pub const fn amount(&self) -> Decimal {
51        self.amount
52    }
53
54    /// Get the currency code.
55    #[inline]
56    #[must_use]
57    pub const fn currency(&self) -> CurrencyCode {
58        self.currency
59    }
60
61    /// Returns `true` if the amount is zero.
62    #[inline]
63    #[must_use]
64    pub const fn is_zero(&self) -> bool {
65        self.amount.is_zero()
66    }
67
68    /// Returns `true` if the amount is positive.
69    #[inline]
70    #[must_use]
71    pub const fn is_positive(&self) -> bool {
72        self.amount.is_sign_positive() && !self.amount.is_zero()
73    }
74
75    /// Returns `true` if the amount is negative.
76    #[inline]
77    #[must_use]
78    pub const fn is_negative(&self) -> bool {
79        self.amount.is_sign_negative() && !self.amount.is_zero()
80    }
81
82    /// Add two monetary values.
83    ///
84    /// Returns `None` if the currencies don't match or if the underlying
85    /// [`Decimal`] addition overflows. This method never panics.
86    #[inline]
87    #[must_use]
88    pub fn checked_add(self, other: Self) -> Option<Self> {
89        if self.currency != other.currency {
90            return None;
91        }
92        let amount = self.amount.checked_add(other.amount)?;
93        Some(Self { amount, currency: self.currency })
94    }
95
96    /// Subtract two monetary values.
97    ///
98    /// Returns `None` if the currencies don't match or if the underlying
99    /// [`Decimal`] subtraction overflows. This method never panics.
100    #[inline]
101    #[must_use]
102    pub fn checked_sub(self, other: Self) -> Option<Self> {
103        if self.currency != other.currency {
104            return None;
105        }
106        let amount = self.amount.checked_sub(other.amount)?;
107        Some(Self { amount, currency: self.currency })
108    }
109
110    /// Round to a given number of decimal places.
111    #[inline]
112    #[must_use = "returns a new Money with rounded amount"]
113    pub fn round_dp(self, dp: u32) -> Self {
114        Self { amount: self.amount.round_dp(dp), currency: self.currency }
115    }
116
117    /// Multiply by a scalar factor (e.g., quantity or tax rate). Currency is preserved.
118    ///
119    /// Returns `None` if the underlying [`Decimal`] multiplication overflows.
120    /// This method never panics.
121    #[inline]
122    #[must_use = "returns a new Money with scaled amount"]
123    pub fn checked_mul_scalar(self, factor: Decimal) -> Option<Self> {
124        let amount = self.amount.checked_mul(factor)?;
125        Some(Self { amount, currency: self.currency })
126    }
127
128    /// Divide by a scalar.
129    ///
130    /// Returns `None` if the divisor is zero or if the underlying [`Decimal`]
131    /// division overflows. This method never panics.
132    #[must_use]
133    pub fn checked_div_scalar(self, divisor: Decimal) -> Option<Self> {
134        if divisor.is_zero() {
135            return None;
136        }
137        let amount = self.amount.checked_div(divisor)?;
138        Some(Self { amount, currency: self.currency })
139    }
140
141    /// Return the absolute value of this monetary amount.
142    #[must_use = "returns a new Money with absolute amount"]
143    pub fn abs(self) -> Self {
144        Self { amount: self.amount.abs(), currency: self.currency }
145    }
146
147    /// Negate this monetary amount.
148    #[must_use = "returns a new Money with negated amount"]
149    pub fn negate(self) -> Self {
150        Self { amount: -self.amount, currency: self.currency }
151    }
152}
153
154impl fmt::Display for Money {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{} {}", self.amount, self.currency)
157    }
158}
159
160// ---------------------------------------------------------------------------
161// CurrencyCode
162// ---------------------------------------------------------------------------
163
164/// ISO 4217 three-letter currency code.
165///
166/// Stored as 3 ASCII uppercase bytes for zero-allocation comparisons and copies.
167///
168/// # Example
169///
170/// ```rust
171/// use stateset_primitives::CurrencyCode;
172///
173/// let usd = CurrencyCode::USD;
174/// assert_eq!(usd.as_str(), "USD");
175///
176/// let parsed: CurrencyCode = "EUR".parse().unwrap();
177/// assert_eq!(parsed, CurrencyCode::EUR);
178/// ```
179#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
180pub struct CurrencyCode([u8; 3]);
181
182impl Default for CurrencyCode {
183    /// Defaults to USD — the most common commerce currency.
184    fn default() -> Self {
185        Self::USD
186    }
187}
188
189impl CurrencyCode {
190    // Common currency code constants
191    /// United States Dollar
192    pub const USD: Self = Self(*b"USD");
193    /// Euro
194    pub const EUR: Self = Self(*b"EUR");
195    /// British Pound Sterling
196    pub const GBP: Self = Self(*b"GBP");
197    /// Japanese Yen
198    pub const JPY: Self = Self(*b"JPY");
199    /// Canadian Dollar
200    pub const CAD: Self = Self(*b"CAD");
201    /// Australian Dollar
202    pub const AUD: Self = Self(*b"AUD");
203    /// Swiss Franc
204    pub const CHF: Self = Self(*b"CHF");
205    /// Chinese Yuan
206    pub const CNY: Self = Self(*b"CNY");
207
208    /// Create a currency code from 3 ASCII uppercase bytes.
209    ///
210    /// Returns `None` if any byte is not ASCII uppercase.
211    #[must_use]
212    pub const fn from_bytes(bytes: [u8; 3]) -> Option<Self> {
213        if bytes[0].is_ascii_uppercase()
214            && bytes[1].is_ascii_uppercase()
215            && bytes[2].is_ascii_uppercase()
216        {
217            Some(Self(bytes))
218        } else {
219            None
220        }
221    }
222
223    /// Get the currency code as a string slice.
224    #[inline]
225    #[must_use]
226    pub const fn as_str(&self) -> &str {
227        match core::str::from_utf8(&self.0) {
228            Ok(code) => code,
229            Err(_) => panic!("CurrencyCode always stores validated ASCII uppercase bytes"),
230        }
231    }
232}
233
234impl fmt::Debug for CurrencyCode {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write!(f, "CurrencyCode({})", self.as_str())
237    }
238}
239
240impl fmt::Display for CurrencyCode {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.write_str(self.as_str())
243    }
244}
245
246impl core::str::FromStr for CurrencyCode {
247    type Err = CurrencyCodeError;
248
249    fn from_str(s: &str) -> Result<Self, Self::Err> {
250        let bytes = s.as_bytes();
251        if bytes.len() != 3 {
252            return Err(CurrencyCodeError::InvalidLength(s.len()));
253        }
254        let arr = [bytes[0], bytes[1], bytes[2]];
255        // Uppercase before validating
256        let arr =
257            [arr[0].to_ascii_uppercase(), arr[1].to_ascii_uppercase(), arr[2].to_ascii_uppercase()];
258        Self::from_bytes(arr).ok_or(CurrencyCodeError::InvalidCharacters)
259    }
260}
261
262impl Serialize for CurrencyCode {
263    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
264        serializer.serialize_str(self.as_str())
265    }
266}
267
268impl<'de> Deserialize<'de> for CurrencyCode {
269    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
270        let s = String::deserialize(deserializer)?;
271        s.parse().map_err(serde::de::Error::custom)
272    }
273}
274
275/// Error parsing a [`CurrencyCode`].
276#[derive(Debug, Clone)]
277#[non_exhaustive]
278pub enum CurrencyCodeError {
279    /// Currency code must be exactly 3 characters.
280    InvalidLength(usize),
281    /// Currency code must contain only ASCII letters.
282    InvalidCharacters,
283}
284
285impl fmt::Display for CurrencyCodeError {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        match self {
288            Self::InvalidLength(len) => {
289                write!(f, "currency code must be exactly 3 characters, got {len}")
290            }
291            Self::InvalidCharacters => f.write_str("currency code must contain only ASCII letters"),
292        }
293    }
294}
295
296#[cfg(feature = "std")]
297impl std::error::Error for CurrencyCodeError {}
298
299// ---------------------------------------------------------------------------
300// rusqlite integration
301// ---------------------------------------------------------------------------
302
303#[cfg(feature = "rusqlite")]
304impl rusqlite::types::FromSql for CurrencyCode {
305    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
306        let s = value.as_str()?;
307        s.parse::<Self>().map_err(|e| rusqlite::types::FromSqlError::Other(Box::new(e)))
308    }
309}
310
311#[cfg(feature = "rusqlite")]
312impl rusqlite::types::ToSql for CurrencyCode {
313    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
314        Ok(rusqlite::types::ToSqlOutput::Borrowed(rusqlite::types::ValueRef::Text(
315            self.as_str().as_bytes(),
316        )))
317    }
318}
319
320// ---------------------------------------------------------------------------
321// sqlx-postgres integration
322// ---------------------------------------------------------------------------
323
324#[cfg(feature = "sqlx-postgres")]
325impl sqlx::Type<sqlx::Postgres> for CurrencyCode {
326    fn type_info() -> sqlx::postgres::PgTypeInfo {
327        <&str as sqlx::Type<sqlx::Postgres>>::type_info()
328    }
329
330    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
331        <&str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
332    }
333}
334
335#[cfg(feature = "sqlx-postgres")]
336impl<'q> sqlx::Encode<'q, sqlx::Postgres> for CurrencyCode {
337    fn encode_by_ref(
338        &self,
339        buf: &mut sqlx::postgres::PgArgumentBuffer,
340    ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
341        <&str as sqlx::Encode<'q, sqlx::Postgres>>::encode_by_ref(&self.as_str(), buf)
342    }
343}
344
345#[cfg(feature = "sqlx-postgres")]
346impl<'r> sqlx::Decode<'r, sqlx::Postgres> for CurrencyCode {
347    fn decode(
348        value: sqlx::postgres::PgValueRef<'r>,
349    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
350        let s = <&str as sqlx::Decode<'r, sqlx::Postgres>>::decode(value)?;
351        s.parse::<Self>().map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use proptest::prelude::*;
359    use rust_decimal_macros::dec;
360
361    fn arb_currency() -> impl Strategy<Value = CurrencyCode> {
362        prop_oneof![
363            Just(CurrencyCode::USD),
364            Just(CurrencyCode::EUR),
365            Just(CurrencyCode::GBP),
366            Just(CurrencyCode::JPY),
367            Just(CurrencyCode::CAD),
368            Just(CurrencyCode::AUD),
369            Just(CurrencyCode::CHF),
370            Just(CurrencyCode::CNY),
371        ]
372    }
373
374    #[test]
375    fn money_display() {
376        let m = Money::new(dec!(42.50), CurrencyCode::USD);
377        assert_eq!(m.to_string(), "42.50 USD");
378    }
379
380    #[test]
381    fn money_checked_add_same_currency() {
382        let a = Money::new(dec!(10.00), CurrencyCode::USD);
383        let b = Money::new(dec!(5.50), CurrencyCode::USD);
384        let sum = a.checked_add(b).unwrap();
385        assert_eq!(sum.amount(), dec!(15.50));
386    }
387
388    #[test]
389    fn money_checked_add_different_currency() {
390        let a = Money::new(dec!(10.00), CurrencyCode::USD);
391        let b = Money::new(dec!(5.50), CurrencyCode::EUR);
392        assert!(a.checked_add(b).is_none());
393    }
394
395    #[test]
396    fn currency_code_parse() {
397        let usd: CurrencyCode = "USD".parse().unwrap();
398        assert_eq!(usd, CurrencyCode::USD);
399
400        let lower: CurrencyCode = "eur".parse().unwrap();
401        assert_eq!(lower, CurrencyCode::EUR);
402    }
403
404    #[test]
405    fn currency_code_invalid() {
406        assert!("US".parse::<CurrencyCode>().is_err()); // too short
407        assert!("USDX".parse::<CurrencyCode>().is_err()); // too long
408        assert!("U$D".parse::<CurrencyCode>().is_err()); // non-alpha
409    }
410
411    #[test]
412    fn currency_code_serde_roundtrip() {
413        let code = CurrencyCode::GBP;
414        let json = serde_json::to_string(&code).unwrap();
415        assert_eq!(json, "\"GBP\"");
416        let parsed: CurrencyCode = serde_json::from_str(&json).unwrap();
417        assert_eq!(parsed, code);
418    }
419
420    #[test]
421    fn money_zero() {
422        let z = Money::zero(CurrencyCode::JPY);
423        assert!(z.is_zero());
424        assert!(!z.is_positive());
425        assert!(!z.is_negative());
426    }
427
428    proptest! {
429        #[test]
430        fn checked_add_sub_are_inverses_for_same_currency(
431            a_raw in -1_000_000i64..1_000_000,
432            b_raw in -1_000_000i64..1_000_000,
433            currency in arb_currency(),
434        ) {
435            let a = Money::new(Decimal::new(a_raw, 2), currency);
436            let b = Money::new(Decimal::new(b_raw, 2), currency);
437            let sum = a.checked_add(b).unwrap();
438            let back = sum.checked_sub(b).unwrap();
439            prop_assert_eq!(back, a);
440        }
441    }
442
443    proptest! {
444        #[test]
445        fn checked_add_is_commutative_when_currency_matches(
446            a_raw in -1_000_000i64..1_000_000,
447            b_raw in -1_000_000i64..1_000_000,
448            currency in arb_currency(),
449        ) {
450            let a = Money::new(Decimal::new(a_raw, 3), currency);
451            let b = Money::new(Decimal::new(b_raw, 3), currency);
452            prop_assert_eq!(a.checked_add(b), b.checked_add(a));
453        }
454    }
455
456    proptest! {
457        #[test]
458        fn round_dp_is_idempotent(
459            raw in -100_000_000i64..100_000_000,
460            scale in 0u32..8,
461            dp in 0u32..8,
462            currency in arb_currency(),
463        ) {
464            let money = Money::new(Decimal::new(raw, scale), currency);
465            let once = money.round_dp(dp);
466            let twice = once.round_dp(dp);
467            prop_assert_eq!(once, twice);
468            prop_assert!(once.amount().scale() <= dp);
469        }
470    }
471
472    proptest! {
473        #[test]
474        fn checked_add_rejects_currency_mismatch(
475            a_raw in -1_000_000i64..1_000_000,
476            b_raw in -1_000_000i64..1_000_000,
477        ) {
478            let usd = Money::new(Decimal::new(a_raw, 2), CurrencyCode::USD);
479            let eur = Money::new(Decimal::new(b_raw, 2), CurrencyCode::EUR);
480            prop_assert!(usd.checked_add(eur).is_none());
481            prop_assert!(usd.checked_sub(eur).is_none());
482        }
483    }
484
485    #[test]
486    fn is_negative_returns_false_for_zero() {
487        let zero = Money::zero(CurrencyCode::USD);
488        assert!(!zero.is_negative());
489        assert!(!zero.is_positive());
490    }
491
492    #[test]
493    fn is_negative_returns_true_for_negative() {
494        let money = Money::new(dec!(-5.00), CurrencyCode::USD);
495        assert!(money.is_negative());
496        assert!(!money.is_positive());
497    }
498
499    #[test]
500    fn is_positive_returns_true_for_positive() {
501        let money = Money::new(dec!(5.00), CurrencyCode::USD);
502        assert!(money.is_positive());
503        assert!(!money.is_negative());
504    }
505
506    #[test]
507    fn checked_mul_scalar() {
508        let money = Money::new(dec!(10.00), CurrencyCode::USD);
509        let result = money.checked_mul_scalar(dec!(3)).unwrap();
510        assert_eq!(result.amount(), dec!(30.00));
511        assert_eq!(result.currency(), CurrencyCode::USD);
512    }
513
514    #[test]
515    fn checked_mul_scalar_fractional() {
516        let money = Money::new(dec!(100.00), CurrencyCode::USD);
517        let result = money.checked_mul_scalar(dec!(0.0825)).unwrap(); // 8.25% tax
518        assert_eq!(result.amount(), dec!(8.2500));
519    }
520
521    #[test]
522    fn checked_div_scalar() {
523        let money = Money::new(dec!(30.00), CurrencyCode::USD);
524        let result = money.checked_div_scalar(dec!(3)).unwrap();
525        assert_eq!(result.amount(), dec!(10.00));
526        assert_eq!(result.currency(), CurrencyCode::USD);
527    }
528
529    #[test]
530    fn checked_div_scalar_zero_returns_none() {
531        let money = Money::new(dec!(30.00), CurrencyCode::USD);
532        assert!(money.checked_div_scalar(dec!(0)).is_none());
533    }
534
535    #[test]
536    fn abs_negative_becomes_positive() {
537        let money = Money::new(dec!(-5.00), CurrencyCode::USD);
538        let result = money.abs();
539        assert_eq!(result.amount(), dec!(5.00));
540    }
541
542    #[test]
543    fn abs_positive_stays_positive() {
544        let money = Money::new(dec!(5.00), CurrencyCode::USD);
545        let result = money.abs();
546        assert_eq!(result.amount(), dec!(5.00));
547    }
548
549    #[test]
550    fn negate_round_trip() {
551        let money = Money::new(dec!(5.00), CurrencyCode::USD);
552        let negated = money.negate();
553        assert_eq!(negated.amount(), dec!(-5.00));
554        let restored = negated.negate();
555        assert_eq!(restored.amount(), dec!(5.00));
556    }
557
558    // -----------------------------------------------------------------------
559    // Arithmetic-overflow guards: `checked_*` must return `None`, never panic.
560    // -----------------------------------------------------------------------
561
562    #[test]
563    fn checked_add_returns_none_on_overflow() {
564        let max = Money::new(Decimal::MAX, CurrencyCode::USD);
565        let one = Money::new(Decimal::ONE, CurrencyCode::USD);
566        // Decimal::MAX + 1 overflows the Decimal representation.
567        assert!(max.checked_add(one).is_none());
568        assert!(max.checked_add(max).is_none());
569    }
570
571    #[test]
572    fn checked_sub_returns_none_on_overflow() {
573        let min = Money::new(Decimal::MIN, CurrencyCode::USD);
574        let one = Money::new(Decimal::ONE, CurrencyCode::USD);
575        // Decimal::MIN - 1 overflows the Decimal representation.
576        assert!(min.checked_sub(one).is_none());
577        let max = Money::new(Decimal::MAX, CurrencyCode::USD);
578        // Decimal::MIN - Decimal::MAX overflows.
579        assert!(min.checked_sub(max).is_none());
580    }
581
582    #[test]
583    fn checked_mul_scalar_returns_none_on_overflow() {
584        let max = Money::new(Decimal::MAX, CurrencyCode::USD);
585        // Decimal::MAX * 2 overflows the Decimal representation.
586        assert!(max.checked_mul_scalar(dec!(2)).is_none());
587        assert!(max.checked_mul_scalar(Decimal::MAX).is_none());
588    }
589
590    #[test]
591    fn checked_div_scalar_returns_none_on_overflow() {
592        let max = Money::new(Decimal::MAX, CurrencyCode::USD);
593        // Dividing the largest magnitude by a tiny fraction overflows.
594        assert!(max.checked_div_scalar(dec!(0.0000000000000000000000000001)).is_none());
595    }
596
597    proptest! {
598        /// `checked_*` arithmetic is total: it must never panic, even at the
599        /// extremes of the `Decimal` range. It either yields a value or `None`.
600        #[test]
601        fn checked_arithmetic_never_panics(
602            a_raw in any::<i64>(),
603            a_scale in 0u32..6,
604            b_raw in any::<i64>(),
605            b_scale in 0u32..6,
606            currency in arb_currency(),
607        ) {
608            let a = Money::new(Decimal::new(a_raw, a_scale), currency);
609            let b = Money::new(Decimal::new(b_raw, b_scale), currency);
610            // None of these may panic regardless of inputs.
611            let _ = a.checked_add(b);
612            let _ = a.checked_sub(b);
613            let _ = a.checked_mul_scalar(b.amount());
614            let _ = a.checked_div_scalar(b.amount());
615        }
616    }
617
618    proptest! {
619        /// Even when operands are pinned to the `Decimal` extremes, the
620        /// `checked_*` methods stay total (return `None`, never panic).
621        #[test]
622        fn checked_arithmetic_at_extremes_never_panics(
623            factor_raw in any::<i64>(),
624            factor_scale in 0u32..28,
625            currency in arb_currency(),
626        ) {
627            let max = Money::new(Decimal::MAX, currency);
628            let min = Money::new(Decimal::MIN, currency);
629            let factor = Decimal::new(factor_raw, factor_scale);
630            let _ = max.checked_add(max);
631            let _ = max.checked_add(min);
632            let _ = min.checked_sub(max);
633            let _ = max.checked_mul_scalar(factor);
634            let _ = min.checked_mul_scalar(factor);
635            let _ = max.checked_div_scalar(factor);
636            let _ = min.checked_div_scalar(factor);
637        }
638    }
639}