mx20022_validate/rules/
iban.rs1use crate::error::{Severity, ValidationError};
11use crate::rules::Rule;
12
13pub 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
48fn validate_iban(iban: &str) -> Result<(), String> {
50 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 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 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 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 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
95fn 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 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 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", "GB82WEST123456987654321", "12WEST12345698765432", "GBXWEST12345698765432", "GB", "", "INVALID", ];
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 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}