Skip to main content

sofie_values/
currency.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlValue};
5
6use crate::parsed::deserialize_parsed;
7
8/// ISO 4217 list one as published on 2026-09-17: the codes in use and the digits of each
9/// one's minor unit, sorted for a binary search. Codes without a minor unit (metals, SDR,
10/// testing) are left out: no amount of money is written in them.
11#[rustfmt::skip]
12const CODES: [([u8; 3], u8); 165] = [
13    (*b"AED", 2), (*b"AFN", 2), (*b"ALL", 2), (*b"AMD", 2), (*b"AOA", 2), (*b"ARS", 2),
14    (*b"AUD", 2), (*b"AWG", 2), (*b"AZN", 2), (*b"BAM", 2), (*b"BBD", 2), (*b"BDT", 2),
15    (*b"BHD", 3), (*b"BIF", 0), (*b"BMD", 2), (*b"BND", 2), (*b"BOB", 2), (*b"BOV", 2),
16    (*b"BRL", 2), (*b"BSD", 2), (*b"BTN", 2), (*b"BWP", 2), (*b"BYN", 2), (*b"BZD", 2),
17    (*b"CAD", 2), (*b"CDF", 2), (*b"CHE", 2), (*b"CHF", 2), (*b"CHW", 2), (*b"CLF", 4),
18    (*b"CLP", 0), (*b"CNY", 2), (*b"COP", 2), (*b"COU", 2), (*b"CRC", 2), (*b"CUP", 2),
19    (*b"CVE", 2), (*b"CZK", 2), (*b"DJF", 0), (*b"DKK", 2), (*b"DOP", 2), (*b"DZD", 2),
20    (*b"EGP", 2), (*b"ERN", 2), (*b"ETB", 2), (*b"EUR", 2), (*b"FJD", 2), (*b"FKP", 2),
21    (*b"GBP", 2), (*b"GEL", 2), (*b"GHS", 2), (*b"GIP", 2), (*b"GMD", 2), (*b"GNF", 0),
22    (*b"GTQ", 2), (*b"GYD", 2), (*b"HKD", 2), (*b"HNL", 2), (*b"HTG", 2), (*b"HUF", 2),
23    (*b"IDR", 2), (*b"ILS", 2), (*b"INR", 2), (*b"IQD", 3), (*b"IRR", 2), (*b"ISK", 0),
24    (*b"JMD", 2), (*b"JOD", 3), (*b"JPY", 0), (*b"KES", 2), (*b"KGS", 2), (*b"KHR", 2),
25    (*b"KMF", 0), (*b"KPW", 2), (*b"KRW", 0), (*b"KWD", 3), (*b"KYD", 2), (*b"KZT", 2),
26    (*b"LAK", 2), (*b"LBP", 2), (*b"LKR", 2), (*b"LRD", 2), (*b"LSL", 2), (*b"LYD", 3),
27    (*b"MAD", 2), (*b"MDL", 2), (*b"MGA", 2), (*b"MKD", 2), (*b"MMK", 2), (*b"MNT", 2),
28    (*b"MOP", 2), (*b"MRU", 2), (*b"MUR", 2), (*b"MVR", 2), (*b"MWK", 2), (*b"MXN", 2),
29    (*b"MXV", 2), (*b"MYR", 2), (*b"MZN", 2), (*b"NAD", 2), (*b"NGN", 2), (*b"NIO", 2),
30    (*b"NOK", 2), (*b"NPR", 2), (*b"NZD", 2), (*b"OMR", 3), (*b"PAB", 2), (*b"PEN", 2),
31    (*b"PGK", 2), (*b"PHP", 2), (*b"PKR", 2), (*b"PLN", 2), (*b"PYG", 0), (*b"QAR", 2),
32    (*b"RON", 2), (*b"RSD", 2), (*b"RUB", 2), (*b"RWF", 0), (*b"SAR", 2), (*b"SBD", 2),
33    (*b"SCR", 2), (*b"SDG", 2), (*b"SEK", 2), (*b"SGD", 2), (*b"SHP", 2), (*b"SLE", 2),
34    (*b"SOS", 2), (*b"SRD", 2), (*b"SSP", 2), (*b"STN", 2), (*b"SVC", 2), (*b"SYP", 2),
35    (*b"SZL", 2), (*b"THB", 2), (*b"TJS", 2), (*b"TMT", 2), (*b"TND", 3), (*b"TOP", 2),
36    (*b"TRY", 2), (*b"TTD", 2), (*b"TWD", 2), (*b"TZS", 2), (*b"UAH", 2), (*b"UGX", 0),
37    (*b"USD", 2), (*b"USN", 2), (*b"UYI", 0), (*b"UYU", 2), (*b"UYW", 4), (*b"UZS", 2),
38    (*b"VED", 2), (*b"VES", 2), (*b"VND", 0), (*b"VUV", 0), (*b"WST", 2), (*b"XAD", 2),
39    (*b"XAF", 0), (*b"XCD", 2), (*b"XCG", 2), (*b"XOF", 0), (*b"XPF", 0), (*b"YER", 2),
40    (*b"ZAR", 2), (*b"ZMW", 2), (*b"ZWG", 2),
41];
42
43#[derive(Clone, Copy, PartialEq, Eq, Hash)]
44pub struct Currency {
45    code: [u8; 3],
46    minor_digits: u8,
47}
48
49impl Currency {
50    pub fn parse(input: &str) -> Result<Self> {
51        let index = CODES
52            .binary_search_by(|(code, _)| code.as_slice().cmp(input.as_bytes()))
53            .map_err(|_| invalid_currency())?;
54        let (code, minor_digits) = CODES[index];
55        Ok(Self { code, minor_digits })
56    }
57
58    #[must_use]
59    pub fn as_str(&self) -> &str {
60        std::str::from_utf8(&self.code).expect("a currency code is ASCII letters")
61    }
62
63    #[must_use]
64    pub fn minor_digits(&self) -> u8 {
65        self.minor_digits
66    }
67
68    /// Every code in use, as a client runtime formats amounts with them.
69    pub fn all() -> impl Iterator<Item = Self> {
70        CODES
71            .iter()
72            .map(|&(code, minor_digits)| Self { code, minor_digits })
73    }
74}
75
76fn invalid_currency() -> Error {
77    Error::invalid(
78        "currency",
79        "must be an ISO 4217 code in use, in uppercase, such as EUR",
80    )
81}
82
83impl fmt::Display for Currency {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter.write_str(self.as_str())
86    }
87}
88
89impl fmt::Debug for Currency {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(formatter, "Currency({})", self.as_str())
92    }
93}
94
95impl Show for Currency {
96    fn show(&self) -> Shown<'_> {
97        Shown::Text(self.as_str())
98    }
99}
100
101impl Cast for Currency {
102    const COLUMNS: &'static [ColumnSpec] = String::COLUMNS;
103
104    fn encode(&self, out: &mut Vec<SqlValue>) {
105        out.push(SqlValue::Text(self.as_str().to_owned()));
106    }
107
108    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
109        Self::parse(row.text()?)
110            .map_err(|error| Error::internal("stored currency is not an ISO 4217 code", error))
111    }
112}
113
114impl Serialize for Currency {
115    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
116        serializer.serialize_str(self.as_str())
117    }
118}
119
120impl<'de> Deserialize<'de> for Currency {
121    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
122        deserialize_parsed(deserializer, "an ISO 4217 currency code", Self::parse)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use serde::de::{self, Visitor};
129    use sofie_core::RowReader;
130
131    use super::*;
132    use crate::testing::{assert_refused, round_trip};
133
134    #[test]
135    fn accepts_codes_in_use() {
136        for code in ["EUR", "USD", "TRY", "JPY"] {
137            assert_eq!(Currency::parse(code).unwrap().as_str(), code);
138        }
139    }
140
141    #[test]
142    fn rejects_anything_else() {
143        assert_refused(
144            Currency::parse,
145            &[
146                "", "EU", "EURO", "eur", "Eur", "E1R", " EUR", "ÉUR", "€", "QQQ", "XXX", "XAU",
147                "DEM", "BGN",
148            ],
149        );
150    }
151
152    #[test]
153    fn knows_the_digits_of_its_minor_unit() {
154        for (code, digits) in [
155            ("EUR", 2),
156            ("USD", 2),
157            ("JPY", 0),
158            ("XPF", 0),
159            ("KWD", 3),
160            ("CLF", 4),
161            ("UYW", 4),
162            ("UYI", 0),
163        ] {
164            assert_eq!(
165                Currency::parse(code).unwrap().minor_digits(),
166                digits,
167                "{code}"
168            );
169        }
170    }
171
172    #[test]
173    fn displays_as_its_code() {
174        assert_eq!(Currency::parse("GBP").unwrap().to_string(), "GBP");
175    }
176
177    #[test]
178    fn round_trips_through_one_text_column() {
179        let currency = Currency::parse("EUR").unwrap();
180        assert_eq!(round_trip(&currency), vec![SqlValue::Text("EUR".into())]);
181    }
182
183    #[test]
184    fn an_invalid_stored_code_is_an_internal_error() {
185        let values = [SqlValue::Text("euro".into())];
186        let mut row = RowReader::new(&values);
187
188        assert!(matches!(
189            Currency::decode(&mut row),
190            Err(Error::Internal { .. })
191        ));
192    }
193
194    #[test]
195    fn round_trips_through_json_as_a_string() {
196        let currency = Currency::parse("USD").unwrap();
197        let json = serde_json::to_string(&currency).unwrap();
198
199        assert_eq!(json, "\"USD\"");
200        assert_eq!(serde_json::from_str::<Currency>(&json).unwrap(), currency);
201    }
202
203    #[test]
204    fn json_deserialization_validates_the_code() {
205        assert!(serde_json::from_str::<Currency>("\"usd\"").is_err());
206    }
207
208    /// Answers only a borrowed string, as a page's JSON does.
209    struct BorrowedOnly<'de>(&'de str);
210
211    impl<'de> Deserializer<'de> for BorrowedOnly<'de> {
212        type Error = serde::de::value::Error;
213
214        fn deserialize_any<V: Visitor<'de>>(self, _: V) -> Result<V::Value, Self::Error> {
215            Err(de::Error::custom("only a borrowed str is offered"))
216        }
217
218        fn deserialize_str<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, Self::Error> {
219            visitor.visit_borrowed_str(self.0)
220        }
221
222        serde::forward_to_deserialize_any! {
223            bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char string bytes byte_buf
224            option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum
225            identifier ignored_any
226        }
227    }
228
229    #[test]
230    fn deserializes_from_a_borrowed_str() {
231        let currency = Currency::deserialize(BorrowedOnly("JPY")).unwrap();
232
233        assert_eq!(currency, Currency::parse("JPY").unwrap());
234        assert!(Currency::deserialize(BorrowedOnly("jpy")).is_err());
235    }
236
237    #[test]
238    fn every_listed_code_reads_back_as_written() {
239        for (code, digits) in CODES {
240            let code = std::str::from_utf8(&code).unwrap();
241            let currency = Currency::parse(code).unwrap();
242
243            assert_eq!(currency.as_str(), code);
244            assert_eq!(currency.minor_digits(), digits, "{code}");
245        }
246    }
247
248    #[test]
249    fn the_listed_codes_are_sorted_uppercase_letters() {
250        assert!(CODES.is_sorted_by(|(first, _), (second, _)| first < second));
251        assert!(
252            CODES
253                .iter()
254                .all(|(code, _)| code.iter().all(u8::is_ascii_uppercase))
255        );
256    }
257}