Skip to main content

iso8583_core/
validation.rs

1//! Validation utilities for ISO 8583 messages and fields
2
3use crate::error::{ISO8583Error, Result};
4use crate::field::{Field, FieldLength, FieldType, FieldValue};
5use crate::message::ISO8583Message;
6
7/// Validator for ISO 8583 messages and fields
8pub struct Validator;
9
10impl Validator {
11    /// Validate Primary Account Number (PAN) using Luhn algorithm
12    ///
13    /// The Luhn algorithm (mod-10 algorithm) is used to validate credit card numbers.
14    ///
15    /// # Algorithm
16    /// 1. Starting from the rightmost digit (check digit), double every second digit
17    /// 2. If doubling results in a two-digit number, add the digits
18    /// 3. Sum all digits
19    /// 4. If sum is divisible by 10, the number is valid
20    ///
21    /// # Example
22    /// ```
23    /// use iso8583_core::validation::Validator;
24    ///
25    /// assert!(Validator::validate_pan("4111111111111111"));  // Valid test card
26    /// assert!(!Validator::validate_pan("4111111111111112")); // Invalid
27    /// ```
28    pub fn validate_pan(pan: &str) -> bool {
29        // Remove any spaces or dashes
30        let pan: String = pan.chars().filter(|c| c.is_ascii_digit()).collect();
31
32        // PAN should be 13-19 digits
33        if pan.len() < 13 || pan.len() > 19 {
34            return false;
35        }
36
37        Self::luhn_check(&pan)
38    }
39
40    /// Luhn algorithm check
41    fn luhn_check(number: &str) -> bool {
42        let mut sum = 0;
43        let mut double = false;
44
45        // Process digits from right to left
46        for ch in number.chars().rev() {
47            if let Some(digit) = ch.to_digit(10) {
48                let mut digit = digit;
49
50                if double {
51                    digit *= 2;
52                    if digit > 9 {
53                        digit -= 9; // Same as adding the digits
54                    }
55                }
56
57                sum += digit;
58                double = !double;
59            } else {
60                return false; // Non-digit character
61            }
62        }
63
64        sum % 10 == 0
65    }
66
67    /// Validate field format based on field type
68    pub fn validate_field_format(field: Field, value: &FieldValue) -> Result<()> {
69        let def = field.definition();
70
71        match value {
72            FieldValue::String(s) => {
73                // Check field type constraints
74                match def.field_type {
75                    FieldType::Numeric => {
76                        if !s.chars().all(|c: char| c.is_ascii_digit()) {
77                            return Err(ISO8583Error::invalid_field_value(
78                                field.number(),
79                                "Field must be numeric",
80                            ));
81                        }
82                    }
83                    FieldType::Alpha => {
84                        if !s.chars().all(|c: char| c.is_ascii_alphabetic() || c == ' ') {
85                            return Err(ISO8583Error::invalid_field_value(
86                                field.number(),
87                                "Field must be alphabetic",
88                            ));
89                        }
90                    }
91                    _ => {} // Other types allow more characters
92                }
93
94                // Check length
95                match def.length {
96                    FieldLength::Fixed(len) => {
97                        if s.len() != len {
98                            return Err(ISO8583Error::field_length_mismatch(
99                                field.number(),
100                                len,
101                                s.len(),
102                            ));
103                        }
104                    }
105                    FieldLength::LLVar(max_len) | FieldLength::LLLVar(max_len) => {
106                        if s.len() > max_len {
107                            return Err(ISO8583Error::invalid_field_value(
108                                field.number(),
109                                format!("Field exceeds maximum length of {}", max_len),
110                            ));
111                        }
112                    }
113                }
114            }
115            FieldValue::Binary(_) => {
116                // Binary fields have their own validation rules
117            }
118        }
119
120        Ok(())
121    }
122
123    /// Validate specific field values
124    pub fn validate_field_value(field: Field, value: &FieldValue) -> Result<()> {
125        match field {
126            Field::PrimaryAccountNumber => {
127                if let Some(pan) = value.as_string() {
128                    if !Self::validate_pan(pan) {
129                        return Err(ISO8583Error::LuhnCheckFailed);
130                    }
131                }
132            }
133            Field::ResponseCode => {
134                if let Some(code) = value.as_string() {
135                    if code.len() != 2 {
136                        return Err(ISO8583Error::invalid_field_value(
137                            39,
138                            "Response code must be 2 characters",
139                        ));
140                    }
141                }
142            }
143            Field::TransactionAmount | Field::SettlementAmount => {
144                if let Some(amount) = value.as_string() {
145                    if !amount.chars().all(|c: char| c.is_ascii_digit()) {
146                        return Err(ISO8583Error::invalid_field_value(
147                            field.number(),
148                            "Amount must be numeric",
149                        ));
150                    }
151                    // Amount must not be zero (in most cases)
152                    if amount.chars().all(|c: char| c == '0') {
153                        return Err(ISO8583Error::invalid_field_value(
154                            field.number(),
155                            "Amount cannot be zero",
156                        ));
157                    }
158                }
159            }
160            _ => {}
161        }
162
163        Ok(())
164    }
165
166    /// Validate required fields for a message type
167    pub fn validate_required_fields(msg: &ISO8583Message) -> Result<()> {
168        // Common required fields for most transactions
169        let common_required = vec![
170            Field::ProcessingCode,
171            Field::SystemTraceAuditNumber,
172            Field::LocalTransactionTime,
173            Field::LocalTransactionDate,
174        ];
175
176        for field in common_required {
177            if msg.get_field(field).is_none() {
178                return Err(ISO8583Error::MissingRequiredField(field.number()));
179            }
180        }
181
182        // Message type specific requirements
183        if msg.mti.is_request() {
184            // Requests typically need PAN and amount
185            if msg.mti.class == crate::mti::MessageClass::Financial
186                || msg.mti.class == crate::mti::MessageClass::Authorization
187            {
188                if msg.get_field(Field::PrimaryAccountNumber).is_none() {
189                    return Err(ISO8583Error::MissingRequiredField(2));
190                }
191                if msg.get_field(Field::TransactionAmount).is_none() {
192                    return Err(ISO8583Error::MissingRequiredField(4));
193                }
194            }
195        }
196
197        if msg.mti.is_response() {
198            // Responses need a response code
199            if msg.get_field(Field::ResponseCode).is_none() {
200                return Err(ISO8583Error::MissingRequiredField(39));
201            }
202        }
203
204        Ok(())
205    }
206
207    /// Validate date format (MMDD)
208    pub fn validate_date_mmdd(date: &str) -> bool {
209        if date.len() != 4 {
210            return false;
211        }
212
213        if let Ok(month) = date[0..2].parse::<u32>() {
214            if let Ok(day) = date[2..4].parse::<u32>() {
215                return (1..=12).contains(&month) && (1..=31).contains(&day);
216            }
217        }
218
219        false
220    }
221
222    /// Validate time format (hhmmss)
223    pub fn validate_time_hhmmss(time: &str) -> bool {
224        if time.len() != 6 {
225            return false;
226        }
227
228        if let Ok(hour) = time[0..2].parse::<u32>() {
229            if let Ok(minute) = time[2..4].parse::<u32>() {
230                if let Ok(second) = time[4..6].parse::<u32>() {
231                    return hour < 24 && minute < 60 && second < 60;
232                }
233            }
234        }
235
236        false
237    }
238
239    /// Validate currency code (ISO 4217)
240    pub fn validate_currency_code(code: &str) -> bool {
241        code.len() == 3 && code.chars().all(|c| c.is_ascii_digit())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_luhn_valid() {
251        // Valid test card numbers
252        assert!(Validator::validate_pan("4111111111111111")); // Visa
253        assert!(Validator::validate_pan("5500000000000004")); // Mastercard
254        assert!(Validator::validate_pan("340000000000009")); // Amex
255    }
256
257    #[test]
258    fn test_luhn_invalid() {
259        assert!(!Validator::validate_pan("4111111111111112")); // Wrong check digit
260        assert!(!Validator::validate_pan("1234567890123456")); // Invalid
261        assert!(!Validator::validate_pan("0000000000000001")); // Should fail Luhn
262    }
263
264    #[test]
265    fn test_luhn_with_spaces() {
266        assert!(Validator::validate_pan("4111 1111 1111 1111")); // With spaces
267    }
268
269    #[test]
270    fn test_pan_length() {
271        assert!(!Validator::validate_pan("123")); // Too short
272        assert!(!Validator::validate_pan("12345678901234567890")); // Too long
273    }
274
275    #[test]
276    fn test_validate_date_mmdd() {
277        assert!(Validator::validate_date_mmdd("0101")); // Jan 1
278        assert!(Validator::validate_date_mmdd("1231")); // Dec 31
279        assert!(!Validator::validate_date_mmdd("1301")); // Invalid month
280        assert!(!Validator::validate_date_mmdd("0132")); // Invalid day
281        assert!(!Validator::validate_date_mmdd("123")); // Wrong length
282    }
283
284    #[test]
285    fn test_validate_time_hhmmss() {
286        assert!(Validator::validate_time_hhmmss("000000")); // Midnight
287        assert!(Validator::validate_time_hhmmss("235959")); // 23:59:59
288        assert!(Validator::validate_time_hhmmss("120000")); // Noon
289        assert!(!Validator::validate_time_hhmmss("240000")); // Invalid hour
290        assert!(!Validator::validate_time_hhmmss("126000")); // Invalid minute
291        assert!(!Validator::validate_time_hhmmss("120060")); // Invalid second
292    }
293
294    #[test]
295    fn test_validate_currency_code() {
296        assert!(Validator::validate_currency_code("840")); // USD
297        assert!(Validator::validate_currency_code("566")); // NGN
298        assert!(Validator::validate_currency_code("978")); // EUR
299        assert!(!Validator::validate_currency_code("USD")); // Not numeric
300        assert!(!Validator::validate_currency_code("84")); // Too short
301    }
302}