valence_core/currency/
mod.rs1mod code;
10
11pub use code::{CurrencyCode, ParseCurrencyCodeError};
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum CurrencyError {
18 Mismatch {
20 left: CurrencyCode,
21 right: CurrencyCode,
22 },
23 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct Currency {
43 code: CurrencyCode,
44 amount_minor: i64,
45}
46
47impl Currency {
48 #[must_use]
50 pub const fn new(code: CurrencyCode, amount_minor: i64) -> Self {
51 Self { code, amount_minor }
52 }
53
54 #[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 #[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 #[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 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 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 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 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}