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 = alpha_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
95use super::checkdigit::{alpha_to_numeric, mod97};
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::rules::Rule;
101
102 const VALID_IBANS: &[&str] = &[
104 "GB82WEST12345698765432",
105 "DE89370400440532013000",
106 "FR7630006000011234567890189",
107 "NL91ABNA0417164300",
108 "BE71096123456769",
109 "CH9300762011623852957",
110 "SE4550000000058398257466",
111 "NO9386011117947",
112 ];
113
114 const INVALID_IBANS: &[&str] = &[
115 "GB82WEST1234569876543X", "GB82WEST123456987654321", "12WEST12345698765432", "GBXWEST12345698765432", "GB", "", "INVALID", ];
123
124 #[test]
125 fn valid_ibans_pass() {
126 let rule = IbanRule;
127 for iban in VALID_IBANS {
128 let errors = rule.validate(iban, "/test");
129 assert!(
130 errors.is_empty(),
131 "Expected no errors for valid IBAN `{iban}`, got: {errors:?}"
132 );
133 }
134 }
135
136 #[test]
137 fn invalid_ibans_fail() {
138 let rule = IbanRule;
139 for iban in INVALID_IBANS {
140 let errors = rule.validate(iban, "/test");
141 assert!(
142 !errors.is_empty(),
143 "Expected errors for invalid IBAN `{iban}`"
144 );
145 }
146 }
147
148 #[test]
149 fn error_has_correct_rule_id() {
150 let rule = IbanRule;
151 let errors = rule.validate("INVALID", "/some/path");
152 assert_eq!(errors[0].rule_id, "IBAN_CHECK");
153 assert_eq!(errors[0].path, "/some/path");
154 }
155
156 #[test]
157 fn rule_id_is_iban_check() {
158 assert_eq!(IbanRule.id(), "IBAN_CHECK");
159 }
160
161 #[test]
162 fn iban_with_spaces_is_normalised() {
163 let rule = IbanRule;
165 let errors = rule.validate("GB82 WEST 1234 5698 7654 32", "/test");
166 assert!(errors.is_empty(), "IBAN with spaces should be accepted");
167 }
168}