Skip to main content

valence_core/currency/
mod.rs

1//! Monetary amount with an ISO-4217 [`CurrencyCode`].
2//!
3//! # Storage
4//!
5//! Persisted as a single JSON object:
6//! `{ "code": "USD", "amount_minor": 12345 }`
7//! where `code` is the alphabetic ISO string and `amount_minor` is signed minor units.
8
9mod code;
10
11pub use code::{CurrencyCode, ParseCurrencyCodeError};
12
13use serde::{Deserialize, Serialize};
14
15/// Same-currency arithmetic / conversion failure.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum CurrencyError {
18    /// Operands used different [`CurrencyCode`] values.
19    Mismatch {
20        left: CurrencyCode,
21        right: CurrencyCode,
22    },
23    /// Checked arithmetic overflowed.
24    Overflow,
25}
26
27impl std::fmt::Display for CurrencyError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            CurrencyError::Mismatch { left, right } => {
31                write!(f, "currency mismatch: {left} vs {right}")
32            }
33            CurrencyError::Overflow => f.write_str("currency arithmetic overflow"),
34        }
35    }
36}
37
38impl std::error::Error for CurrencyError {}
39
40/// Composite money value: ISO code + signed minor units.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct Currency {
43    code: CurrencyCode,
44    amount_minor: i64,
45}
46
47impl Currency {
48    /// Construct a monetary value.
49    #[must_use]
50    pub const fn new(code: CurrencyCode, amount_minor: i64) -> Self {
51        Self { code, amount_minor }
52    }
53
54    /// Zero amount in `code`.
55    #[must_use]
56    pub const fn zero(code: CurrencyCode) -> Self {
57        Self::new(code, 0)
58    }
59
60    #[must_use]
61    pub const fn code(self) -> CurrencyCode {
62        self.code
63    }
64
65    #[must_use]
66    pub const fn amount_minor(self) -> i64 {
67        self.amount_minor
68    }
69
70    #[must_use]
71    pub const fn is_zero(self) -> bool {
72        self.amount_minor == 0
73    }
74
75    /// Absolute minor amount (overflow on `i64::MIN` → `None`).
76    #[must_use]
77    pub const fn checked_abs(self) -> Option<Self> {
78        match self.amount_minor.checked_abs() {
79            Some(v) => Some(Self::new(self.code, v)),
80            None => None,
81        }
82    }
83
84    /// Negate minor amount (overflow on `i64::MIN` → `None`).
85    #[must_use]
86    pub const fn checked_negate(self) -> Option<Self> {
87        match self.amount_minor.checked_neg() {
88            Some(v) => Some(Self::new(self.code, v)),
89            None => None,
90        }
91    }
92
93    /// Same-currency checked addition.
94    pub fn checked_add(self, other: Self) -> Result<Self, CurrencyError> {
95        if self.code != other.code {
96            return Err(CurrencyError::Mismatch {
97                left: self.code,
98                right: other.code,
99            });
100        }
101        self.amount_minor
102            .checked_add(other.amount_minor)
103            .map(|v| Self::new(self.code, v))
104            .ok_or(CurrencyError::Overflow)
105    }
106
107    /// Compare minor amounts when currencies match.
108    pub fn partial_cmp_amount(self, other: Self) -> Result<std::cmp::Ordering, CurrencyError> {
109        if self.code != other.code {
110            return Err(CurrencyError::Mismatch {
111                left: self.code,
112                right: other.code,
113            });
114        }
115        Ok(self.amount_minor.cmp(&other.amount_minor))
116    }
117
118    /// Build from major units using the ISO exponent for `code`.
119    ///
120    /// `major` is scaled by `10^exponent` into minor units.
121    pub fn from_major_units(code: CurrencyCode, major: i64) -> Result<Self, CurrencyError> {
122        let factor = 10_i64
123            .checked_pow(code.exponent())
124            .ok_or(CurrencyError::Overflow)?;
125        major
126            .checked_mul(factor)
127            .map(|minor| Self::new(code, minor))
128            .ok_or(CurrencyError::Overflow)
129    }
130
131    /// Convert minor units to truncated major units using the ISO exponent.
132    pub fn to_major_units(self) -> Result<i64, CurrencyError> {
133        let factor = 10_i64
134            .checked_pow(self.code.exponent())
135            .ok_or(CurrencyError::Overflow)?;
136        if factor == 0 {
137            return Err(CurrencyError::Overflow);
138        }
139        Ok(self.amount_minor / factor)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn serde_shape() {
149        let c = Currency::new(CurrencyCode::Usd, 12345);
150        let v = serde_json::to_value(c).unwrap();
151        assert_eq!(
152            v,
153            serde_json::json!({ "code": "USD", "amount_minor": 12345 })
154        );
155        let back: Currency = serde_json::from_value(v).unwrap();
156        assert_eq!(back, c);
157    }
158
159    #[test]
160    fn rejects_unknown_code_on_deserialize() {
161        let v = serde_json::json!({ "code": "ZZZ", "amount_minor": 1 });
162        assert!(serde_json::from_value::<Currency>(v).is_err());
163    }
164
165    #[test]
166    fn add_mismatch_and_major() {
167        let a = Currency::new(CurrencyCode::Usd, 100);
168        let b = Currency::new(CurrencyCode::Eur, 100);
169        assert!(matches!(
170            a.checked_add(b),
171            Err(CurrencyError::Mismatch { .. })
172        ));
173        let from_major = Currency::from_major_units(CurrencyCode::Usd, 12).unwrap();
174        assert_eq!(from_major.amount_minor(), 1200);
175        assert_eq!(from_major.to_major_units().unwrap(), 12);
176        let jpy = Currency::from_major_units(CurrencyCode::Jpy, 100).unwrap();
177        assert_eq!(jpy.amount_minor(), 100);
178    }
179}