Skip to main content

mx20022_validate/rules/
iban.rs

1//! IBAN (International Bank Account Number) validation rule.
2//!
3//! Validates per ISO 13616:
4//! - 2-letter country code
5//! - 2 decimal check digits
6//! - Up to 30 alphanumeric BBAN characters
7//! - Total length between 5 and 34 characters
8//! - Mod-97 check digit verification
9
10use crate::error::{Severity, ValidationError};
11use crate::rules::Rule;
12
13/// Validates a value as an IBAN using format and mod-97 check digit.
14///
15/// # Examples
16///
17/// ```
18/// use mx20022_validate::rules::iban::IbanRule;
19/// use mx20022_validate::rules::Rule;
20///
21/// let rule = IbanRule;
22/// let errors = rule.validate("GB82WEST12345698765432", "/path");
23/// assert!(errors.is_empty(), "Valid IBAN should produce no errors");
24///
25/// let errors = rule.validate("INVALID", "/path");
26/// assert!(!errors.is_empty(), "Invalid IBAN should produce errors");
27/// ```
28pub struct IbanRule;
29
30impl Rule for IbanRule {
31    fn id(&self) -> &'static str {
32        "IBAN_CHECK"
33    }
34
35    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
36        match validate_iban(value) {
37            Ok(()) => vec![],
38            Err(msg) => vec![ValidationError::new(
39                path,
40                Severity::Error,
41                "IBAN_CHECK",
42                msg,
43            )],
44        }
45    }
46}
47
48/// Core IBAN validation logic — returns `Ok(())` on success or an error message.
49fn validate_iban(iban: &str) -> Result<(), String> {
50    // Strip optional spaces (some representations include spaces every 4 chars)
51    let canonical: String = iban.chars().filter(|c| !c.is_whitespace()).collect();
52
53    let len = canonical.len();
54    if !(5..=34).contains(&len) {
55        return Err(format!(
56            "IBAN length {len} is out of range [5, 34]: `{iban}`"
57        ));
58    }
59
60    // First two characters must be uppercase ASCII letters (country code)
61    let country = &canonical[..2];
62    if !country.chars().all(|c| c.is_ascii_uppercase()) {
63        return Err(format!(
64            "IBAN country code must be 2 uppercase letters, got `{country}`"
65        ));
66    }
67
68    // Characters 3–4 must be decimal digits (check digits)
69    let check_str = &canonical[2..4];
70    if !check_str.chars().all(|c| c.is_ascii_digit()) {
71        return Err(format!(
72            "IBAN check digits must be 2 decimal digits, got `{check_str}`"
73        ));
74    }
75
76    // BBAN: remaining characters must be alphanumeric
77    let bban = &canonical[4..];
78    if !bban.chars().all(|c| c.is_ascii_alphanumeric()) {
79        return Err(format!("IBAN BBAN must be alphanumeric, got `{bban}`"));
80    }
81
82    // Mod-97 check: rearrange (move first 4 chars to end), expand letters to digits, compute mod 97
83    let rearranged = format!("{}{}", bban, &canonical[..4]);
84    let numeric = iban_to_numeric(&rearranged);
85    let remainder = mod97(&numeric);
86    if remainder != 1 {
87        return Err(format!(
88            "IBAN check digit verification failed (mod-97 = {remainder}): `{iban}`"
89        ));
90    }
91
92    Ok(())
93}
94
95/// Convert an IBAN string (letters → digits, A=10, B=11, …, Z=35) to a numeric string.
96fn iban_to_numeric(s: &str) -> String {
97    let mut out = String::with_capacity(s.len() * 2);
98    for c in s.chars() {
99        if c.is_ascii_digit() {
100            out.push(c);
101        } else {
102            // A=10, B=11, …, Z=35
103            let n = (c as u32) - ('A' as u32) + 10;
104            out.push_str(&n.to_string());
105        }
106    }
107    out
108}
109
110use super::checkdigit::mod97;
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::rules::Rule;
116
117    // Known-valid IBANs from Wikipedia / IBAN registry
118    const VALID_IBANS: &[&str] = &[
119        "GB82WEST12345698765432",
120        "DE89370400440532013000",
121        "FR7630006000011234567890189",
122        "NL91ABNA0417164300",
123        "BE71096123456769",
124        "CH9300762011623852957",
125        "SE4550000000058398257466",
126        "NO9386011117947",
127    ];
128
129    const INVALID_IBANS: &[&str] = &[
130        "GB82WEST1234569876543X",  // non-alphanumeric BBAN
131        "GB82WEST123456987654321", // wrong mod-97
132        "12WEST12345698765432",    // non-letter country code
133        "GBXWEST12345698765432",   // non-digit check digits
134        "GB",                      // too short
135        "",                        // empty
136        "INVALID",                 // too short and wrong format
137    ];
138
139    #[test]
140    fn valid_ibans_pass() {
141        let rule = IbanRule;
142        for iban in VALID_IBANS {
143            let errors = rule.validate(iban, "/test");
144            assert!(
145                errors.is_empty(),
146                "Expected no errors for valid IBAN `{iban}`, got: {errors:?}"
147            );
148        }
149    }
150
151    #[test]
152    fn invalid_ibans_fail() {
153        let rule = IbanRule;
154        for iban in INVALID_IBANS {
155            let errors = rule.validate(iban, "/test");
156            assert!(
157                !errors.is_empty(),
158                "Expected errors for invalid IBAN `{iban}`"
159            );
160        }
161    }
162
163    #[test]
164    fn error_has_correct_rule_id() {
165        let rule = IbanRule;
166        let errors = rule.validate("INVALID", "/some/path");
167        assert_eq!(errors[0].rule_id, "IBAN_CHECK");
168        assert_eq!(errors[0].path, "/some/path");
169    }
170
171    #[test]
172    fn rule_id_is_iban_check() {
173        assert_eq!(IbanRule.id(), "IBAN_CHECK");
174    }
175
176    #[test]
177    fn iban_with_spaces_is_normalised() {
178        // "GB82 WEST 1234 5698 7654 32" == "GB82WEST12345698765432"
179        let rule = IbanRule;
180        let errors = rule.validate("GB82 WEST 1234 5698 7654 32", "/test");
181        assert!(errors.is_empty(), "IBAN with spaces should be accepted");
182    }
183}