1use core::fmt;
13use core::hash::{Hash, Hasher};
14
15use crate::numeric::{Decimal, Rational, RoundingMode};
16
17#[derive(Clone, Copy, Debug)]
20pub struct Currency {
21 pub code: &'static str,
22 pub scale: u32,
23}
24
25impl PartialEq for Currency {
26 fn eq(&self, other: &Self) -> bool {
27 self.code == other.code
28 }
29}
30impl Eq for Currency {}
31impl Hash for Currency {
32 fn hash<H: Hasher>(&self, state: &mut H) {
33 self.code.hash(state);
34 }
35}
36
37pub mod currency {
40 use super::Currency;
41
42 pub fn by_code(code: &str) -> Option<Currency> {
44 let c = code.trim().to_ascii_uppercase();
45 let cur = |code, scale| Currency { code, scale };
46 Some(match c.as_str() {
47 "USD" => cur("USD", 2),
48 "EUR" => cur("EUR", 2),
49 "GBP" => cur("GBP", 2),
50 "CHF" => cur("CHF", 2),
51 "CAD" => cur("CAD", 2),
52 "AUD" => cur("AUD", 2),
53 "NZD" => cur("NZD", 2),
54 "SGD" => cur("SGD", 2),
55 "HKD" => cur("HKD", 2),
56 "CNY" => cur("CNY", 2),
57 "INR" => cur("INR", 2),
58 "BRL" => cur("BRL", 2),
59 "MXN" => cur("MXN", 2),
60 "ZAR" => cur("ZAR", 2),
61 "SEK" => cur("SEK", 2),
62 "NOK" => cur("NOK", 2),
63 "DKK" => cur("DKK", 2),
64 "RUB" => cur("RUB", 2),
65 "TRY" => cur("TRY", 2),
66 "PLN" => cur("PLN", 2),
67 "JPY" => cur("JPY", 0),
69 "KRW" => cur("KRW", 0),
70 "ISK" => cur("ISK", 0),
71 "VND" => cur("VND", 0),
72 "CLP" => cur("CLP", 0),
73 "BHD" => cur("BHD", 3),
75 "KWD" => cur("KWD", 3),
76 "OMR" => cur("OMR", 3),
77 "JOD" => cur("JOD", 3),
78 "TND" => cur("TND", 3),
79 _ => return None,
80 })
81 }
82}
83
84#[derive(Clone, Debug)]
86pub struct Money {
87 pub amount: Decimal,
89 pub currency: Currency,
90}
91
92impl Money {
93 pub fn of(amount: Decimal, currency: Currency) -> Money {
96 Money { amount: amount.rescale(currency.scale, RoundingMode::HalfEven), currency }
97 }
98
99 pub fn add(&self, other: &Money) -> Option<Money> {
101 if self.currency != other.currency {
102 return None;
103 }
104 Some(Money { amount: self.amount.add(&other.amount), currency: self.currency })
105 }
106
107 pub fn sub(&self, other: &Money) -> Option<Money> {
109 if self.currency != other.currency {
110 return None;
111 }
112 Some(Money { amount: self.amount.sub(&other.amount), currency: self.currency })
113 }
114
115 pub fn scale_int(&self, k: i64) -> Money {
117 Money::of(self.amount.mul(&Decimal::from_i64(k)), self.currency)
118 }
119
120 pub fn ratio(&self, other: &Money) -> Option<Rational> {
123 if self.currency != other.currency {
124 return None;
125 }
126 self.amount.to_rational().div(&other.amount.to_rational())
127 }
128}
129
130impl PartialEq for Money {
131 fn eq(&self, other: &Self) -> bool {
133 self.currency == other.currency && self.amount.to_rational() == other.amount.to_rational()
134 }
135}
136impl Eq for Money {}
137
138impl Hash for Money {
139 fn hash<H: Hasher>(&self, state: &mut H) {
140 self.currency.hash(state);
141 self.amount.to_rational().hash(state);
142 }
143}
144
145impl fmt::Display for Money {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 write!(f, "{} {}", self.amount, self.currency.code)
149 }
150}
151
152#[derive(Clone, Debug, Default)]
159pub struct RateTable {
160 rates: std::collections::HashMap<String, Rational>,
161}
162
163impl RateTable {
164 pub fn new() -> Self {
165 RateTable { rates: std::collections::HashMap::new() }
166 }
167
168 pub fn set(&mut self, code: &str, rate: Rational) {
170 self.rates.insert(code.trim().to_ascii_uppercase(), rate);
171 }
172
173 pub fn rate(&self, code: &str) -> Option<&Rational> {
175 self.rates.get(&code.trim().to_ascii_uppercase())
176 }
177
178 pub fn convert(&self, m: &Money, to: Currency) -> Option<Money> {
181 let from_rate = self.rate(m.currency.code)?;
182 let to_rate = self.rate(to.code)?;
183 let value_in_reference = m.amount.to_rational().mul(from_rate);
184 let amount_in_to = value_in_reference.div(to_rate)?;
185 Some(Money::of(Decimal::from_rational(&amount_in_to, to.scale, RoundingMode::HalfEven), to))
186 }
187}
188
189thread_local! {
190 static AMBIENT_RATES: std::cell::RefCell<Option<RateTable>> =
194 const { std::cell::RefCell::new(None) };
195}
196
197pub fn set_ambient_rates(table: RateTable) {
199 AMBIENT_RATES.with(|r| *r.borrow_mut() = Some(table));
200}
201
202pub fn set_ambient_rate(code: &str, rate: Rational) {
205 AMBIENT_RATES.with(|r| r.borrow_mut().get_or_insert_with(RateTable::new).set(code, rate));
206}
207
208pub fn clear_ambient_rates() {
210 AMBIENT_RATES.with(|r| *r.borrow_mut() = None);
211}
212
213pub fn has_ambient_rates() -> bool {
215 AMBIENT_RATES.with(|r| r.borrow().is_some())
216}
217
218pub fn ambient_convert(m: &Money, to: Currency) -> Option<Money> {
221 AMBIENT_RATES.with(|r| r.borrow().as_ref().and_then(|t| t.convert(m, to)))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn money(s: &str, code: &str) -> Money {
229 Money::of(Decimal::parse(s).unwrap(), currency::by_code(code).unwrap())
230 }
231
232 #[test]
233 fn currency_catalog_resolves_codes_with_their_iso_scales() {
234 assert_eq!(currency::by_code("USD").unwrap().scale, 2);
235 assert_eq!(currency::by_code("usd").unwrap().scale, 2); assert_eq!(currency::by_code("JPY").unwrap().scale, 0); assert_eq!(currency::by_code("BHD").unwrap().scale, 3); assert_eq!(currency::by_code("KWD").unwrap().scale, 3);
239 assert_eq!(currency::by_code("XYZ"), None);
240 }
241
242 #[test]
243 fn money_quantises_to_the_currency_minor_unit() {
244 assert_eq!(money("19.99", "USD").to_string(), "19.99 USD");
245 assert_eq!(money("5", "USD").to_string(), "5.00 USD"); assert_eq!(money("100", "JPY").to_string(), "100 JPY"); assert_eq!(money("1.5", "BHD").to_string(), "1.500 BHD"); assert_eq!(money("19.999", "USD").to_string(), "20.00 USD");
250 assert_eq!(money("2.345", "USD").to_string(), "2.34 USD"); }
252
253 #[test]
254 fn same_currency_add_sub_are_exact_and_keep_the_currency() {
255 assert_eq!(money("0.10", "USD").add(&money("0.20", "USD")).unwrap().to_string(), "0.30 USD");
257 assert_eq!(money("19.99", "USD").add(&money("5.00", "USD")).unwrap().to_string(), "24.99 USD");
258 assert_eq!(money("24.99", "USD").sub(&money("5.00", "USD")).unwrap().to_string(), "19.99 USD");
259 assert_eq!(money("100", "JPY").add(&money("50", "JPY")).unwrap().to_string(), "150 JPY");
260 }
261
262 #[test]
263 fn cross_currency_arithmetic_is_forbidden() {
264 assert_eq!(money("5.00", "USD").add(&money("1.00", "EUR")), None);
266 assert_eq!(money("5.00", "USD").sub(&money("1.00", "EUR")), None);
267 assert_eq!(money("5.00", "USD").ratio(&money("1.00", "EUR")), None);
268 }
269
270 #[test]
271 fn scaling_and_ratio() {
272 assert_eq!(money("19.99", "USD").scale_int(3).to_string(), "59.97 USD");
273 assert_eq!(money("30.00", "USD").ratio(&money("10.00", "USD")).unwrap(), Rational::from_i64(3));
274 assert_eq!(money("1.00", "USD").ratio(&money("0.00", "USD")), None); }
276
277 #[test]
278 fn universal_money_amount_converts_exactly_via_a_rate_table() {
279 let mut rates = RateTable::new();
281 rates.set("USD", Rational::from_i64(1));
282 rates.set("EUR", Rational::from_ratio_i64(11, 10).unwrap());
283 rates.set("GBP", Rational::from_ratio_i64(5, 4).unwrap());
284 let usd = currency::by_code("USD").unwrap();
285 let eur = currency::by_code("EUR").unwrap();
286 let gbp = currency::by_code("GBP").unwrap();
287
288 assert_eq!(rates.convert(&money("10.00", "EUR"), usd).unwrap().to_string(), "11.00 USD");
290 assert_eq!(rates.convert(&money("11.00", "USD"), eur).unwrap().to_string(), "10.00 EUR");
291 assert_eq!(rates.convert(&money("42.00", "USD"), usd).unwrap().to_string(), "42.00 USD");
293 assert_eq!(rates.convert(&money("10.00", "GBP"), eur).unwrap().to_string(), "11.36 EUR");
295 assert_eq!(rates.convert(&money("1.00", "USD"), currency::by_code("JPY").unwrap()), None);
297 }
298
299 #[test]
300 fn value_equality() {
301 assert_eq!(money("5", "USD"), money("5.00", "USD")); assert_ne!(money("5.00", "USD"), money("5.00", "EUR")); assert_ne!(money("5.00", "USD"), money("6.00", "USD"));
304 }
305}