Skip to main content

librotenado/
money.rs

1// SPDX-License-Identifier: MIT
2//! Currencies, how they divide, and amounts of money.
3
4use std::{fmt, ops::Neg, str::FromStr};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::{Date, Object, ParseError};
9
10/// A currency by its code: three upper-case letters for an ISO 4217 currency (`EUR`, `CHF`), `x-` and 1–62 characters
11/// of `a-z`, `0-9` and `-` for a currency defined outside ISO 4217 (`x-btc`, `x-galleon`).
12///
13/// The two forms cannot collide. Only the form is checked; whether a code denotes an existing currency, and how it
14/// divides, is reference data ([`CurrencyDefinition`]), not protocol.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct Currency(String);
17
18impl Currency {
19    /// The code as text.
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23
24    /// Whether this is an ISO 4217 code (three upper-case letters) rather than one beginning with `x-`.
25    pub fn is_iso(&self) -> bool {
26        !self.0.starts_with("x-")
27    }
28}
29
30impl FromStr for Currency {
31    type Err = ParseError;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        let iso = s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase());
35        let custom = s.strip_prefix("x-").is_some_and(|name| {
36            (1..=62).contains(&name.len())
37                && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
38        });
39        if iso || custom { Ok(Currency(s.to_owned())) } else { Err(ParseError::Currency(s.to_owned())) }
40    }
41}
42
43impl fmt::Display for Currency {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(&self.0)
46    }
47}
48
49impl Serialize for Currency {
50    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
51        serializer.serialize_str(&self.0)
52    }
53}
54
55impl<'de> Deserialize<'de> for Currency {
56    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
57        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
58    }
59}
60
61/// An amount in a currency, counted in the currency's smallest unit: `{"currency": "EUR", "base_units": "-1240"}`
62/// is −12.40 euros.
63///
64/// The count is exact and has one written form only – an integer as a string (`"-1240"`), never a JSON number –
65/// so the same amount always has the same bytes and the same hash. Its magnitude is at most
66/// [`Money::MAX_BASE_UNITS`]. Reading, checking and summing amounts never needs the currency's definition.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68pub struct Money {
69    currency: Currency,
70    #[serde(with = "base_units")]
71    base_units: i128,
72}
73
74impl Money {
75    /// The largest magnitude of an amount in base units: 10^38 − 1. Every implementation can represent it.
76    pub const MAX_BASE_UNITS: i128 = 10i128.pow(38) - 1;
77
78    /// `base_units` of the currency's smallest unit; `None` if the magnitude is above [`Self::MAX_BASE_UNITS`].
79    pub fn new(base_units: i128, currency: Currency) -> Option<Self> {
80        (base_units.unsigned_abs() <= Self::MAX_BASE_UNITS.unsigned_abs()).then_some(Money { currency, base_units })
81    }
82
83    /// The currency.
84    pub fn currency(&self) -> &Currency {
85        &self.currency
86    }
87
88    /// The amount in the currency's smallest unit; its sign is given by its use (see [`crate::Posting`]).
89    pub fn base_units(&self) -> i128 {
90        self.base_units
91    }
92}
93
94impl Object for Money {
95    const TYPE: &'static str = "money";
96}
97
98impl Neg for Money {
99    type Output = Money;
100
101    /// The same amount with the opposite sign; the range is symmetric, so this cannot overflow.
102    fn neg(self) -> Money {
103        Money { currency: self.currency, base_units: -self.base_units }
104    }
105}
106
107impl fmt::Display for Money {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "{} base units of {}", self.base_units, self.currency)
110    }
111}
112
113/// The written form of an amount in base units: an integer as a string, in its one canonical form.
114mod base_units {
115    use serde::{Deserialize, Deserializer, Serializer, de};
116
117    use super::Money;
118
119    pub fn serialize<S: Serializer>(base_units: &i128, serializer: S) -> Result<S::Ok, S::Error> {
120        serializer.collect_str(base_units)
121    }
122
123    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i128, D::Error> {
124        let s = String::deserialize(deserializer)?;
125        parse(&s).ok_or_else(|| de::Error::custom(format!("not an amount in base units: {s:?}")))
126    }
127
128    /// An optional `-`, then `0` or a digit 1–9 followed by digits; not `-0`; at most [`Money::MAX_BASE_UNITS`].
129    pub(super) fn parse(s: &str) -> Option<i128> {
130        let digits = s.strip_prefix('-').unwrap_or(s);
131        let canonical = !digits.is_empty()
132            && digits.bytes().all(|b| b.is_ascii_digit())
133            && (digits == "0" || !digits.starts_with('0'))
134            && s != "-0";
135        let base_units: i128 = if canonical { s.parse().ok()? } else { return None };
136        (base_units.unsigned_abs() <= Money::MAX_BASE_UNITS.unsigned_abs()).then_some(base_units)
137    }
138}
139
140/// How a currency divides into its base unit – reference data, published in versions with the protocol.
141///
142/// `subdivisions` lists, from the unit down, how many of the next smaller stage one stage contains: `[100]` for EUR
143/// (euro, cent), `[]` for JPY, `[17, 29]` for Galleons (Galleon, Sickle, Knut), `[1000000000, 1000000000]` for
144/// `x-eth` (ether, gwei, wei). Stage `n` is the unit after `n` subdivisions; stage 0 is the unit itself, the last
145/// stage the base unit. Names are not part of a definition: translation data names stage `n` by the key
146/// `<code>/<n>` (`EUR/0` euro, `EUR/1` cent).
147#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(try_from = "RawDefinition", into = "RawDefinition")]
149pub struct CurrencyDefinition {
150    code: Currency,
151    subdivisions: Vec<u64>,
152}
153
154impl Object for CurrencyDefinition {
155    const TYPE: &'static str = "currency";
156}
157
158/// Why a currency definition is not valid.
159#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
160pub enum DefinitionError {
161    /// A stage containing fewer than 2 of the next, or more than 2^53 − 1.
162    #[error("every subdivision is between 2 and 2^53 - 1")]
163    Stage,
164    /// More base units per unit than 10^38, the range of amounts.
165    #[error("more than 10^38 base units per unit")]
166    TooLarge,
167    /// An ISO 4217 currency not divided as ISO 4217 does: once by a power of ten, or not at all.
168    #[error("an ISO 4217 currency is divided once by a power of ten, or not at all")]
169    Iso,
170}
171
172impl CurrencyDefinition {
173    /// A definition, checked: every subdivision between 2 and 2^53 − 1, at most 10^38 base units per unit, and an
174    /// ISO 4217 currency divided at most once, by a power of ten.
175    pub fn new(code: Currency, subdivisions: Vec<u64>) -> Result<Self, DefinitionError> {
176        let definition = CurrencyDefinition { code, subdivisions };
177        if definition.subdivisions.iter().any(|&s| !(2..=crate::MAX_SAFE_INTEGER).contains(&s)) {
178            return Err(DefinitionError::Stage);
179        }
180        definition.base_units_per_unit().ok_or(DefinitionError::TooLarge)?;
181        if definition.code.is_iso() && (definition.subdivisions.len() > 1 || definition.decimals().is_none()) {
182            return Err(DefinitionError::Iso);
183        }
184        Ok(definition)
185    }
186
187    /// The code.
188    pub fn code(&self) -> &Currency {
189        &self.code
190    }
191
192    /// How many of the next smaller stage each stage contains, from the unit down.
193    pub fn subdivisions(&self) -> &[u64] {
194        &self.subdivisions
195    }
196
197    /// How many base units make one unit: 100 for EUR, 17 · 29 = 493 for Galleons in Knuts; `None` above 10^38.
198    pub fn base_units_per_unit(&self) -> Option<u128> {
199        let limit = Money::MAX_BASE_UNITS.unsigned_abs() + 1;
200        self.subdivisions
201            .iter()
202            .try_fold(1u128, |product, &s| product.checked_mul(u128::from(s)).filter(|&p| p <= limit))
203    }
204
205    /// The number of decimal places if one unit is a power of ten base units – `Some(2)` for EUR, `Some(0)` for JPY,
206    /// `None` for Galleons. Only then does money convert to and from decimal numbers.
207    pub fn decimals(&self) -> Option<u32> {
208        let mut per_unit = self.base_units_per_unit()?;
209        let mut decimals = 0;
210        while per_unit % 10 == 0 {
211            per_unit /= 10;
212            decimals += 1;
213        }
214        (per_unit == 1).then_some(decimals)
215    }
216}
217
218/// A published table of currency definitions – reference data, versioned with the protocol (spec part 0001).
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct CurrencyTable {
221    /// The version of the table.
222    pub version: String,
223    /// The date of the ISO 4217 list the table follows.
224    pub iso_4217: Date,
225    /// The definitions, each code once.
226    pub currencies: Vec<CurrencyDefinition>,
227}
228
229impl Object for CurrencyTable {
230    const TYPE: &'static str = "currency_table";
231}
232
233impl CurrencyTable {
234    /// The table of version 0.1: every currency of ISO 4217 with a minor unit, and `x-btc` and `x-eth`
235    /// (`spec/data/currencies.json`).
236    pub fn v0_1() -> Self {
237        crate::from_root(include_bytes!("../../../spec/data/currencies.json")).expect("the published table is valid")
238    }
239
240    /// The definition of this currency, if the table has it.
241    pub fn get(&self, code: &Currency) -> Option<&CurrencyDefinition> {
242        self.currencies.iter().find(|definition| definition.code() == code)
243    }
244
245    /// Whether every code occurs once.
246    pub fn is_valid(&self) -> bool {
247        let mut codes: Vec<&Currency> = self.currencies.iter().map(CurrencyDefinition::code).collect();
248        codes.sort();
249        codes.windows(2).all(|pair| pair[0] != pair[1])
250    }
251}
252
253/// The written form of a definition: `{"code": …, "subdivisions": […]}`.
254#[derive(Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256struct RawDefinition {
257    code: Currency,
258    subdivisions: Vec<u64>,
259}
260
261impl TryFrom<RawDefinition> for CurrencyDefinition {
262    type Error = DefinitionError;
263
264    fn try_from(raw: RawDefinition) -> Result<Self, Self::Error> {
265        CurrencyDefinition::new(raw.code, raw.subdivisions)
266    }
267}
268
269impl From<CurrencyDefinition> for RawDefinition {
270    fn from(definition: CurrencyDefinition) -> Self {
271        RawDefinition { code: definition.code, subdivisions: definition.subdivisions }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn eur() -> Currency {
280        "EUR".parse().unwrap()
281    }
282
283    #[test]
284    fn currency_codes_are_iso_or_begin_with_x() {
285        for s in ["EUR", "XAU", "x-btc", "x-galleon", "x-bonus-points-2026"] {
286            assert_eq!(s.parse::<Currency>().unwrap().to_string(), s);
287        }
288        assert!(eur().is_iso());
289        assert!(!"x-btc".parse::<Currency>().unwrap().is_iso());
290        for s in ["eur", "EU", "EURO", "E1R", "", "x-", "X-btc", "x-BTC", "x-b c", "btc"] {
291            assert!(s.parse::<Currency>().is_err(), "{s:?}");
292        }
293    }
294
295    #[test]
296    fn money_is_base_units_as_a_string_in_json() {
297        let money = Money::new(-1240, eur()).unwrap();
298        let json = serde_json::to_string(&money).unwrap();
299        assert_eq!(json, r#"{"currency":"EUR","base_units":"-1240"}"#);
300        assert_eq!(serde_json::from_str::<Money>(&json).unwrap(), money);
301    }
302
303    #[test]
304    fn base_units_have_one_written_form() {
305        for s in ["0", "7", "-1240", "99999999999999999999999999999999999999"] {
306            assert_eq!(base_units::parse(s).unwrap().to_string(), s);
307        }
308        for s in
309            ["", "-", "-0", "+1", "01", "-01", "1.0", "12.40", "1e3", " 1", "100000000000000000000000000000000000000"]
310        {
311            assert!(base_units::parse(s).is_none(), "{s:?}");
312        }
313        assert!(serde_json::from_str::<Money>(r#"{"currency":"EUR","base_units":1240}"#).is_err());
314    }
315
316    #[test]
317    fn the_range_is_symmetric() {
318        assert!(Money::new(Money::MAX_BASE_UNITS + 1, eur()).is_none());
319        assert_eq!((-Money::new(-Money::MAX_BASE_UNITS, eur()).unwrap()).base_units(), Money::MAX_BASE_UNITS);
320    }
321
322    fn definition(json: &str) -> Result<CurrencyDefinition, serde_json::Error> {
323        serde_json::from_str(json)
324    }
325
326    #[test]
327    fn decimal_currencies() {
328        let json = r#"{"code":"EUR","subdivisions":[100]}"#;
329        let eur = definition(json).unwrap();
330        assert_eq!((eur.base_units_per_unit(), eur.decimals()), (Some(100), Some(2)));
331        assert_eq!(serde_json::to_string(&eur).unwrap(), json);
332        let jpy = definition(r#"{"code":"JPY","subdivisions":[]}"#).unwrap();
333        assert_eq!((jpy.base_units_per_unit(), jpy.decimals()), (Some(1), Some(0)));
334        let eth = definition(r#"{"code":"x-eth","subdivisions":[1000000000,1000000000]}"#).unwrap();
335        assert_eq!(eth.decimals(), Some(18));
336    }
337
338    #[test]
339    fn other_currencies() {
340        let galleon = definition(r#"{"code":"x-galleon","subdivisions":[17,29]}"#).unwrap();
341        assert_eq!((galleon.base_units_per_unit(), galleon.decimals()), (Some(493), None));
342        assert_eq!(galleon.subdivisions(), [17, 29]);
343    }
344
345    #[test]
346    fn the_table_of_version_0_1() {
347        let table = CurrencyTable::v0_1();
348        assert!(table.is_valid());
349        assert_eq!(table.currencies.len(), 167);
350        let decimals = |code: &str| table.get(&code.parse().unwrap()).and_then(CurrencyDefinition::decimals);
351        assert_eq!(
352            [decimals("EUR"), decimals("JPY"), decimals("KWD"), decimals("CLF"), decimals("MGA")],
353            [Some(2), Some(0), Some(3), Some(4), Some(2)]
354        );
355        assert_eq!((decimals("x-btc"), decimals("x-eth")), (Some(8), Some(18)));
356        assert!(table.get(&"XAU".parse().unwrap()).is_none(), "units without a minor unit are not in the table");
357    }
358
359    #[test]
360    fn invalid_definitions() {
361        let x = || "x-test".parse::<Currency>().unwrap();
362        for stages in [vec![1], vec![17, 0], vec![20, 1, 12], vec![1 << 53]] {
363            assert_eq!(CurrencyDefinition::new(x(), stages), Err(DefinitionError::Stage));
364        }
365        let too_large = vec![1_000_000_000_000_000; 3];
366        assert_eq!(CurrencyDefinition::new(x(), too_large), Err(DefinitionError::TooLarge));
367        for stages in [vec![20, 12], vec![10, 10], vec![5]] {
368            assert_eq!(CurrencyDefinition::new(eur(), stages), Err(DefinitionError::Iso));
369        }
370        for json in [
371            r#"{"code":"EUR"}"#,
372            r#"{"code":"EUR","decimals":2}"#,
373            r#"{"code":"EUR","subdivisions":[100],"number":"978"}"#,
374            r#"{"code":"EUR","subdivisions":[100.0]}"#,
375        ] {
376            assert!(definition(json).is_err(), "{json}");
377        }
378    }
379}