// common/src/money.rs //! //! The one canonical currency spelling shared by the client and the server. //! //! A currency code is stored, sent and compared as its canonical uppercase //! ISO-4217 alphabetic code. Both ends resolve it with //! [`require_iso_currency_code`], so the client rejects exactly what the server //! would reject, and each side renders the shared error as its own error type. pub use rusty_money::iso; pub use crate::proto::komp_ac::table_definition::MoneyRounding; impl MoneyRounding { pub fn as_storage_str(self) -> &'static str { match self { Self::None => "none", Self::HalfUp => "half_up", } } } impl std::str::FromStr for MoneyRounding { type Err = serde::de::value::Error; fn from_str(value: &str) -> Result { match value { "none" => Ok(Self::None), "half_up" => Ok(Self::HalfUp), _ => Err(serde::de::Error::custom(format!("Invalid money rounding: {value}"))), } } } impl TryFrom for MoneyRounding { type Error = serde::de::value::Error; fn try_from(value: String) -> Result { value.parse() } } pub mod rounding_serde { use super::MoneyRounding; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize(value: &MoneyRounding, serializer: S) -> Result { serializer.serialize_str(value.as_storage_str()) } pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { String::deserialize(deserializer)?.parse().map_err(serde::de::Error::custom) } } /// Resolves a canonical uppercase ISO-4217 alphabetic code to its currency. /// /// Rejects anything not already canonical: lowercase (`eur`), surrounding /// whitespace, and any length but three. Normalising here instead would let a /// value be written in one spelling and compared in another. pub fn require_iso_currency_code(currency_code: &str) -> Result<&'static iso::Currency, String> { iso::find(currency_code).ok_or_else(|| format!("Unknown ISO-4217 currency: {currency_code}")) } /// Returns every currency accepted by [`require_iso_currency_code`], ordered by /// its canonical alphabetic code. pub fn iso_currency_codes() -> Vec<&'static str> { let mut currencies = Vec::new(); for first in b'A'..=b'Z' { for second in b'A'..=b'Z' { for third in b'A'..=b'Z' { let bytes = [first, second, third]; let code = std::str::from_utf8(&bytes).expect("uppercase ASCII currency code"); if let Some(currency) = iso::find(code) { currencies.push(currency.iso_alpha_code); } } } } currencies } #[cfg(test)] mod tests { use super::*; #[test] fn rounding_storage_and_json_boundaries_are_strict() { #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] struct StoredPolicy { #[serde(with = "super::rounding_serde")] rounding: MoneyRounding, } for (text, policy) in [("none", MoneyRounding::None), ("half_up", MoneyRounding::HalfUp)] { assert_eq!(text.parse::().unwrap(), policy); assert_eq!(MoneyRounding::try_from(text.to_string()).unwrap(), policy); assert_eq!(policy.as_storage_str(), text); let stored = StoredPolicy { rounding: policy }; let json = serde_json::json!({ "rounding": text }); assert_eq!(serde_json::to_value(&stored).unwrap(), json); assert_eq!(serde_json::from_value::(json).unwrap(), stored); } for text in ["", "NONE", "HalfUp", "half-up", " half_up", "half_up ", "unknown"] { assert!(text.parse::().is_err()); assert!(serde_json::from_value::(serde_json::json!({ "rounding": text })).is_err()); } for value in [serde_json::Value::Null, serde_json::json!(0), serde_json::json!(1)] { assert!(serde_json::from_value::(serde_json::json!({ "rounding": value })).is_err()); } assert!(MoneyRounding::try_from(-1).is_err()); assert!(MoneyRounding::try_from(2).is_err()); } #[test] fn canonical_codes_resolve_to_their_currency() { assert_eq!(require_iso_currency_code("EUR").unwrap(), iso::EUR); assert_eq!( require_iso_currency_code("USD").unwrap().iso_alpha_code, "USD" ); } #[test] fn non_canonical_or_unknown_codes_are_rejected() { for currency_code in [ "", "E", "EU", "EURO", "eur", "Eur", " EUR", "EUR ", "E R", "E\u{20AC}R", "AAA", "123", ] { assert!( require_iso_currency_code(currency_code).is_err(), "unexpectedly accepted {currency_code:?}" ); } } }