Files
komp_ac/common/src/money.rs
2026-09-07 10:25:14 +02:00

146 lines
5.0 KiB
Rust

// 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<Self, Self::Err> {
match value {
"none" => Ok(Self::None),
"half_up" => Ok(Self::HalfUp),
_ => Err(serde::de::Error::custom(format!("Invalid money rounding: {value}"))),
}
}
}
impl TryFrom<String> for MoneyRounding {
type Error = serde::de::value::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
pub mod rounding_serde {
use super::MoneyRounding;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(value: &MoneyRounding, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(value.as_storage_str())
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<MoneyRounding, D::Error> {
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::<MoneyRounding>().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::<StoredPolicy>(json).unwrap(), stored);
}
for text in ["", "NONE", "HalfUp", "half-up", " half_up", "half_up ", "unknown"] {
assert!(text.parse::<MoneyRounding>().is_err());
assert!(serde_json::from_value::<StoredPolicy>(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::<StoredPolicy>(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:?}"
);
}
}
}