fraiseql_core/validation/checksum.rs
1//! Checksum validation algorithms for credit cards, IBANs, and other structured identifiers.
2//!
3//! This module provides validators for common checksum algorithms used in banking and
4//! payment systems.
5
6/// Luhn algorithm validator for credit cards and similar identifiers.
7///
8/// The Luhn algorithm (also called mod-10) is used to validate credit card numbers
9/// and other identification numbers.
10///
11/// # Algorithm Steps
12/// 1. From right to left, double every second digit
13/// 2. If doubling results in a two-digit number, subtract 9
14/// 3. Sum all digits
15/// 4. The sum modulo 10 should equal 0
16pub struct LuhnValidator;
17
18impl LuhnValidator {
19 /// Validate a string using the Luhn algorithm.
20 ///
21 /// # Arguments
22 ///
23 /// * `value` - The string to validate (must contain only digits)
24 ///
25 /// # Returns
26 ///
27 /// `true` if the value passes Luhn validation, `false` otherwise
28 ///
29 /// # Example
30 ///
31 /// ```
32 /// use fraiseql_core::validation::checksum::LuhnValidator;
33 ///
34 /// assert!(LuhnValidator::validate("4532015112830366")); // Valid Visa
35 /// assert!(!LuhnValidator::validate("4532015112830367")); // Invalid
36 /// ```
37 pub fn validate(value: &str) -> bool {
38 // Must contain only digits
39 if !value.chars().all(|c| c.is_ascii_digit()) {
40 return false;
41 }
42
43 // Must have at least 1 digit
44 if value.is_empty() {
45 return false;
46 }
47
48 let mut sum = 0;
49 let mut is_second = false;
50
51 // Process digits from right to left
52 for ch in value.chars().rev() {
53 let digit = ch.to_digit(10).expect("pre-filtered to numeric chars only") as usize;
54
55 let processed = if is_second {
56 let doubled = digit * 2;
57 if doubled > 9 { doubled - 9 } else { doubled }
58 } else {
59 digit
60 };
61
62 sum += processed;
63 is_second = !is_second;
64 }
65
66 sum % 10 == 0
67 }
68
69 /// Get a human-readable description of why validation failed.
70 pub fn error_message() -> &'static str {
71 "Invalid checksum (Luhn algorithm)"
72 }
73}
74
75/// MOD-97 algorithm validator for IBANs and similar identifiers.
76///
77/// The MOD-97 algorithm is used to validate International Bank Account Numbers (IBANs)
78/// and other financial identifiers.
79///
80/// # Algorithm Steps
81/// 1. Move the first 4 characters to the end
82/// 2. Replace letters with numbers (A=10, B=11, ..., Z=35)
83/// 3. Calculate the remainder of the number modulo 97
84/// 4. The remainder should be 1 for valid IBANs
85pub struct Mod97Validator;
86
87impl Mod97Validator {
88 /// Validate a string using the MOD-97 algorithm.
89 ///
90 /// # Arguments
91 ///
92 /// * `value` - The string to validate (typically an IBAN or similar identifier)
93 ///
94 /// # Returns
95 ///
96 /// `true` if the value passes MOD-97 validation, `false` otherwise
97 ///
98 /// # Example
99 ///
100 /// ```
101 /// use fraiseql_core::validation::checksum::Mod97Validator;
102 ///
103 /// assert!(Mod97Validator::validate("GB82WEST12345698765432")); // Valid IBAN
104 /// assert!(!Mod97Validator::validate("GB82WEST12345698765433")); // Invalid
105 /// ```
106 pub fn validate(value: &str) -> bool {
107 let value_upper = value.to_uppercase();
108
109 // Must have at least 4 characters
110 if value_upper.len() < 4 {
111 return false;
112 }
113
114 // Must contain only alphanumeric characters
115 if !value_upper.chars().all(|c| c.is_ascii_alphanumeric()) {
116 return false;
117 }
118
119 // Rearrange: move first 4 characters to the end
120 let rearranged = format!("{}{}", &value_upper[4..], &value_upper[..4]);
121
122 // Convert to numeric string: A=10, B=11, ..., Z=35
123 let mut numeric = String::new();
124 for ch in rearranged.chars() {
125 if ch.is_ascii_digit() {
126 numeric.push(ch);
127 } else if ch.is_ascii_uppercase() {
128 // A=10, B=11, ..., Z=35
129 numeric.push_str(&(10 + (ch as usize - 'A' as usize)).to_string());
130 } else {
131 return false;
132 }
133 }
134
135 // Calculate mod 97
136 let remainder = Self::mod97(&numeric);
137 remainder == 1
138 }
139
140 /// Calculate mod 97 of a numeric string.
141 ///
142 /// Uses the standard modulo operator by processing the number in chunks
143 /// to avoid overflow on very large numbers.
144 fn mod97(numeric: &str) -> u32 {
145 let mut remainder = 0u32;
146
147 for digit_char in numeric.chars() {
148 if let Some(digit) = digit_char.to_digit(10) {
149 remainder = (remainder * 10 + digit) % 97;
150 }
151 }
152
153 remainder
154 }
155
156 /// Get a human-readable description of why validation failed.
157 pub fn error_message() -> &'static str {
158 "Invalid checksum (MOD-97 algorithm)"
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 // Luhn tests
167 #[test]
168 fn test_luhn_valid_visa() {
169 assert!(LuhnValidator::validate("4532015112830366"));
170 }
171
172 #[test]
173 fn test_luhn_valid_another_visa() {
174 assert!(LuhnValidator::validate("4111111111111111"));
175 }
176
177 #[test]
178 fn test_luhn_invalid_checksum() {
179 assert!(!LuhnValidator::validate("4532015112830367"));
180 }
181
182 #[test]
183 fn test_luhn_invalid_non_digits() {
184 assert!(!LuhnValidator::validate("4532-0151-1283-0366"));
185 }
186
187 #[test]
188 fn test_luhn_empty_string() {
189 assert!(!LuhnValidator::validate(""));
190 }
191
192 #[test]
193 fn test_luhn_single_digit() {
194 assert!(LuhnValidator::validate("0"));
195 }
196
197 #[test]
198 fn test_luhn_all_zeros() {
199 assert!(LuhnValidator::validate("0000000000000000"));
200 }
201
202 // MOD-97 tests
203 #[test]
204 fn test_mod97_valid_iban_gb() {
205 assert!(Mod97Validator::validate("GB82WEST12345698765432"));
206 }
207
208 #[test]
209 fn test_mod97_valid_iban_de() {
210 assert!(Mod97Validator::validate("DE89370400440532013000"));
211 }
212
213 #[test]
214 fn test_mod97_invalid_checksum() {
215 assert!(!Mod97Validator::validate("GB82WEST12345698765433"));
216 }
217
218 #[test]
219 fn test_mod97_invalid_too_short() {
220 assert!(!Mod97Validator::validate("GB8"));
221 }
222
223 #[test]
224 fn test_mod97_invalid_special_chars() {
225 assert!(!Mod97Validator::validate("GB82-WEST-1234"));
226 }
227
228 #[test]
229 fn test_mod97_lowercase_conversion() {
230 // Should handle lowercase by converting to uppercase
231 assert!(Mod97Validator::validate("gb82west12345698765432"));
232 }
233
234 #[test]
235 fn test_mod97_error_message() {
236 assert_eq!(Mod97Validator::error_message(), "Invalid checksum (MOD-97 algorithm)");
237 }
238
239 #[test]
240 fn test_luhn_error_message() {
241 assert_eq!(LuhnValidator::error_message(), "Invalid checksum (Luhn algorithm)");
242 }
243}