Skip to main content

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}