logicaffeine_base/money.rs
1//! Money — an exact monetary amount in a specific currency (UNIVERSAL_TYPES Part V).
2//!
3//! Money rides the exact [`Decimal`] tower, so it **never float-drifts**: `0.10 + 0.20` is exactly
4//! `0.30`, not `0.30000000000000004`. The amount is quantised to the currency's minor unit (USD has
5//! 2 fractional digits, JPY has 0, BHD has 3), with banker's rounding.
6//!
7//! Currency is part of the value, and same-currency arithmetic is the only kind that means anything:
8//! `5 USD + 1 EUR` has no answer, so `add`/`sub` across currencies return `None` — the exact analogue
9//! of the dimensional rule that forbids `meter + gram`. `× ÷` by a plain number scales the amount; a
10//! same-currency `Money ÷ Money` is an exact dimensionless ratio.
11
12use core::fmt;
13use core::hash::{Hash, Hasher};
14
15use crate::numeric::{Decimal, Rational, RoundingMode};
16
17/// An ISO-4217 currency: its three-letter code and its minor-unit `scale` (the number of fractional
18/// digits — `USD`→2, `JPY`→0, `BHD`→3). The code is the identity.
19#[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
37/// The currency catalog — resolve an ISO-4217 code (case-insensitive) to its [`Currency`]. A growable
38/// data table, mirroring [`crate::quantity::units::by_name`]; the minor-unit scales follow ISO-4217.
39pub mod currency {
40 use super::Currency;
41
42 /// Look up a currency by its ISO-4217 code (case-insensitive); `None` for an unknown code.
43 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 // Zero-decimal currencies (no minor unit).
68 "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 // Three-decimal currencies.
74 "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/// An exact monetary amount, quantised to its currency's minor unit.
85#[derive(Clone, Debug)]
86pub struct Money {
87 /// The amount, exact, at `currency.scale` fractional digits.
88 pub amount: Decimal,
89 pub currency: Currency,
90}
91
92impl Money {
93 /// Build money, quantising the amount to the currency's minor unit with banker's rounding —
94 /// `5 USD` is stored as `5.00`, `19.999 USD` rounds to `20.00`, `100 JPY` stays `100`.
95 pub fn of(amount: Decimal, currency: Currency) -> Money {
96 Money {
97 amount: amount.rescale(currency.scale, RoundingMode::HalfEven),
98 currency,
99 }
100 }
101
102 /// Sum of two amounts in the **same** currency; `None` across currencies (no common meaning).
103 pub fn add(&self, other: &Money) -> Option<Money> {
104 if self.currency != other.currency {
105 return None;
106 }
107 Some(Money {
108 amount: self.amount.add(&other.amount),
109 currency: self.currency,
110 })
111 }
112
113 /// Difference of two amounts in the **same** currency; `None` across currencies.
114 pub fn sub(&self, other: &Money) -> Option<Money> {
115 if self.currency != other.currency {
116 return None;
117 }
118 Some(Money {
119 amount: self.amount.sub(&other.amount),
120 currency: self.currency,
121 })
122 }
123
124 /// Scale an amount by a plain integer (`19.99 USD × 3 = 59.97 USD`), re-quantised to the currency.
125 pub fn scale_int(&self, k: i64) -> Money {
126 Money::of(self.amount.mul(&Decimal::from_i64(k)), self.currency)
127 }
128
129 /// The exact dimensionless ratio of two same-currency amounts (`30 USD ÷ 10 USD = 3`); `None`
130 /// across currencies or when the divisor is zero.
131 pub fn ratio(&self, other: &Money) -> Option<Rational> {
132 if self.currency != other.currency {
133 return None;
134 }
135 self.amount.to_rational().div(&other.amount.to_rational())
136 }
137}
138
139impl PartialEq for Money {
140 /// Value equality: same currency and equal amount (`5 USD == 5.00 USD`, `5 USD ≠ 5 EUR`).
141 fn eq(&self, other: &Self) -> bool {
142 self.currency == other.currency && self.amount.to_rational() == other.amount.to_rational()
143 }
144}
145impl Eq for Money {}
146
147impl Hash for Money {
148 fn hash<H: Hasher>(&self, state: &mut H) {
149 self.currency.hash(state);
150 self.amount.to_rational().hash(state);
151 }
152}
153
154impl fmt::Display for Money {
155 /// `"19.99 USD"` — the amount at the currency's minor-unit scale, then the code.
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 write!(f, "{} {}", self.amount, self.currency.code)
158 }
159}
160
161/// A pluggable exchange-rate table — the substrate of the **Universal Money Amount** (money's UTC).
162/// Each currency code maps to its rate **versus a chosen reference currency**: how many reference
163/// units one unit of the currency is worth, exact on the Rational tower (the reference itself is 1).
164/// The *source* is interchangeable — a literal table, a CRDT-synced table, an API fetch — they all
165/// produce this same value, so with rates in scope money of different currencies shares one
166/// coordinate and cross-currency math becomes meaningful (and exact).
167#[derive(Clone, Debug, Default)]
168pub struct RateTable {
169 rates: std::collections::HashMap<String, Rational>,
170}
171
172impl RateTable {
173 pub fn new() -> Self {
174 RateTable {
175 rates: std::collections::HashMap::new(),
176 }
177 }
178
179 /// Record that `1 <code> = rate` reference units (case-insensitive code).
180 pub fn set(&mut self, code: &str, rate: Rational) {
181 self.rates.insert(code.trim().to_ascii_uppercase(), rate);
182 }
183
184 /// The rate of `code` versus the reference, if known.
185 pub fn rate(&self, code: &str) -> Option<&Rational> {
186 self.rates.get(&code.trim().to_ascii_uppercase())
187 }
188
189 /// Convert `m` into `to`, exact via the Rational tower (normalise to the reference, then to the
190 /// target), quantised to `to`'s minor unit. `None` if either currency's rate is missing.
191 pub fn convert(&self, m: &Money, to: Currency) -> Option<Money> {
192 let from_rate = self.rate(m.currency.code)?;
193 let to_rate = self.rate(to.code)?;
194 let value_in_reference = m.amount.to_rational().mul(from_rate);
195 let amount_in_to = value_in_reference.div(to_rate)?;
196 Some(Money::of(
197 Decimal::from_rational(&amount_in_to, to.scale, RoundingMode::HalfEven),
198 to,
199 ))
200 }
201}
202
203thread_local! {
204 /// The in-scope exchange-rate context for `<money> in <currency>` conversion. Pluggable: a
205 /// program installs it from a literal table, a CRDT-synced table, or an API fetch — they all land
206 /// here. `None` until rates are provided (conversion then errors, rather than guessing).
207 static AMBIENT_RATES: std::cell::RefCell<Option<RateTable>> =
208 const { std::cell::RefCell::new(None) };
209}
210
211/// Install (replace) the ambient rate table.
212pub fn set_ambient_rates(table: RateTable) {
213 AMBIENT_RATES.with(|r| *r.borrow_mut() = Some(table));
214}
215
216/// Add or replace one rate in the ambient table (`1 <code> = rate` reference units), creating the
217/// table if none is in scope yet.
218pub fn set_ambient_rate(code: &str, rate: Rational) {
219 AMBIENT_RATES.with(|r| {
220 r.borrow_mut()
221 .get_or_insert_with(RateTable::new)
222 .set(code, rate)
223 });
224}
225
226/// Drop the ambient rate table (back to "no rates in scope").
227pub fn clear_ambient_rates() {
228 AMBIENT_RATES.with(|r| *r.borrow_mut() = None);
229}
230
231/// True if any rate context is in scope.
232pub fn has_ambient_rates() -> bool {
233 AMBIENT_RATES.with(|r| r.borrow().is_some())
234}
235
236/// Convert `m` to `to` using the ambient rate table. `None` if no rates are in scope or a currency's
237/// rate is missing — the caller surfaces a clean error rather than a wrong number.
238pub fn ambient_convert(m: &Money, to: Currency) -> Option<Money> {
239 AMBIENT_RATES.with(|r| r.borrow().as_ref().and_then(|t| t.convert(m, to)))
240}
241
242#[cfg(test)]
243#[path = "../../logicaffeine_tests/unit/logicaffeine_base/money/tests.rs"]
244mod tests;