Skip to main content

mx20022_validate/rules/
currency.rs

1//! ISO 4217 currency code validation rule.
2//!
3//! Validates that a 3-letter alphabetic code is a recognised ISO 4217
4//! currency code.  The set covers all actively-traded currencies plus the
5//! major precious-metal and testing codes (XAU, XAG, XXX, …).
6
7use crate::error::{Severity, ValidationError};
8use crate::rules::Rule;
9
10/// Static set of recognised ISO 4217 currency codes.
11///
12/// Sources: ISO 4217 Maintenance Agency list (2024 edition).
13/// Codes are 3-letter uppercase ASCII.
14static VALID_CURRENCIES: &[&str] = &[
15    // Major traded currencies
16    "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT",
17    "BGN", "BHD", "BMD", "BND", "BOB", "BOV", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD",
18    "CDF", "CHE", "CHF", "CHW", "CLF", "CLP", "CNY", "COP", "COU", "CRC", "CUP", "CVE", "CZK",
19    "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS",
20    "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD",
21    "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD",
22    "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT",
23    "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MXV", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK",
24    "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD",
25    "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP",
26    "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS",
27    "UAH", "UGX", "USD", "USN", "UYI", "UYU", "UYW", "UZS", "VED", "VES", "VND", "VUV", "WST",
28    "XAF", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XCD", "XDR", "XOF", "XPD", "XPF", "XPT",
29    "XSU", "XTS", "XUA", "XXX", "YER", "ZAR", "ZMW", "ZWG",
30    // Historical codes still appearing in legacy ISO 20022 messages
31    "HRK", "SLL", "STD", "VEF", "MRO", "BYR",
32];
33
34/// Validates a value as an ISO 4217 currency code.
35///
36/// # Examples
37///
38/// ```
39/// use mx20022_validate::rules::currency::CurrencyRule;
40/// use mx20022_validate::rules::Rule;
41///
42/// let rule = CurrencyRule;
43///
44/// let errors = rule.validate("USD", "/path");
45/// assert!(errors.is_empty(), "USD is a valid currency code");
46///
47/// let errors = rule.validate("XYZ", "/path");
48/// assert!(!errors.is_empty(), "XYZ is not a valid ISO 4217 code");
49///
50/// let errors = rule.validate("usd", "/path");
51/// assert!(!errors.is_empty(), "Lowercase codes are rejected");
52/// ```
53pub struct CurrencyRule;
54
55impl Rule for CurrencyRule {
56    fn id(&self) -> &'static str {
57        "CURRENCY_CHECK"
58    }
59
60    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
61        if VALID_CURRENCIES.contains(&value) {
62            return vec![];
63        }
64
65        let msg = if value.is_empty() {
66            "Currency code must not be empty".to_owned()
67        } else if value.len() != 3 {
68            format!(
69                "Currency code must be exactly 3 characters, got {}: `{value}`",
70                value.len()
71            )
72        } else if !value.chars().all(|c| c.is_ascii_alphabetic()) {
73            format!("Currency code must be 3 alphabetic characters, got: `{value}`")
74        } else if !value.chars().all(|c| c.is_ascii_uppercase()) {
75            format!("Currency code must be uppercase, got: `{value}`")
76        } else {
77            format!("Unrecognised ISO 4217 currency code: `{value}`")
78        };
79
80        vec![ValidationError::new(
81            path,
82            Severity::Error,
83            "CURRENCY_CHECK",
84            msg,
85        )]
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::rules::Rule;
93
94    // A representative sample of valid ISO 4217 codes
95    const VALID_CODES: &[&str] = &[
96        "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "INR", "BRL", "MXN", "SGD",
97        "HKD", "KRW", "ZAR", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF", "TRY", "THB", "MYR", "IDR",
98        "PHP", "AED", "SAR", "QAR", "KWD", "BHD", "OMR", "EGP", "ILS", "TWD", "ARS", "CLP", "COP",
99        "PEN", "NGN", "KES", "GHS", "TZS", "RUB", "UAH", "RON", "BGN", "ISK",
100        // ISO special codes
101        "XAU", "XAG", "XDR", "XXX", "XAF", "XOF", // Historical legacy codes
102        "HRK",
103    ];
104
105    const INVALID_CODES: &[&str] = &[
106        "usd",  // lowercase
107        "Usd",  // mixed case
108        "US",   // too short (2 chars)
109        "USDX", // too long (4 chars)
110        "123",  // numeric
111        "U1D",  // contains digit
112        "",     // empty
113        "ZZZ",  // fictional (not in set — note XTS/XXX are valid testing codes but ZZZ is not)
114        "ABC",  // fictional
115    ];
116
117    #[test]
118    fn valid_currency_codes_pass() {
119        let rule = CurrencyRule;
120        for code in VALID_CODES {
121            let errors = rule.validate(code, "/test");
122            assert!(
123                errors.is_empty(),
124                "Expected no errors for valid code `{code}`, got: {errors:?}"
125            );
126        }
127    }
128
129    #[test]
130    fn invalid_currency_codes_fail() {
131        let rule = CurrencyRule;
132        for code in INVALID_CODES {
133            let errors = rule.validate(code, "/test");
134            assert!(
135                !errors.is_empty(),
136                "Expected errors for invalid code `{code}`"
137            );
138        }
139    }
140
141    #[test]
142    fn error_has_correct_rule_id_and_path() {
143        let rule = CurrencyRule;
144        let errors = rule.validate("ABC", "/Document/Ccy");
145        assert_eq!(errors.len(), 1);
146        assert_eq!(errors[0].rule_id, "CURRENCY_CHECK");
147        assert_eq!(errors[0].path, "/Document/Ccy");
148        assert_eq!(errors[0].severity, Severity::Error);
149    }
150
151    #[test]
152    fn rule_id_is_currency_check() {
153        assert_eq!(CurrencyRule.id(), "CURRENCY_CHECK");
154    }
155
156    #[test]
157    fn lowercase_code_rejected_with_descriptive_message() {
158        let rule = CurrencyRule;
159        let errors = rule.validate("usd", "/test");
160        assert!(!errors.is_empty());
161        assert!(
162            errors[0].message.contains("uppercase") || errors[0].message.contains("Unrecognised"),
163            "Expected message about case, got: {}",
164            errors[0].message
165        );
166    }
167
168    #[test]
169    fn two_char_code_rejected_with_length_message() {
170        let rule = CurrencyRule;
171        let errors = rule.validate("US", "/test");
172        assert!(!errors.is_empty());
173        assert!(
174            errors[0].message.contains("3 characters") || errors[0].message.contains("exactly"),
175            "Expected length message, got: {}",
176            errors[0].message
177        );
178    }
179
180    #[test]
181    fn empty_code_rejected() {
182        let rule = CurrencyRule;
183        let errors = rule.validate("", "/test");
184        assert!(!errors.is_empty());
185        assert!(errors[0].message.contains("empty"));
186    }
187
188    #[test]
189    fn numeric_code_rejected() {
190        let rule = CurrencyRule;
191        let errors = rule.validate("123", "/test");
192        assert!(!errors.is_empty());
193    }
194}