Skip to main content

iso8583_codec_rs/
validation.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use serde_json::Value;
5
6use crate::field_spec::{FieldType, LoadedSpec, PrefixType};
7
8#[derive(Debug, Clone)]
9pub struct ValidationError {
10    pub de: u32,
11    pub field_name: String,
12    pub rule: String,
13    pub message: String,
14}
15
16#[derive(Debug)]
17pub struct ValidationResult {
18    pub errors: Vec<ValidationError>,
19}
20
21impl ValidationResult {
22    pub fn is_valid(&self) -> bool {
23        self.errors.is_empty()
24    }
25}
26
27fn response_codes() -> &'static HashMap<String, String> {
28    static CODES: OnceLock<HashMap<String, String>> = OnceLock::new();
29    CODES.get_or_init(|| {
30        let json = include_str!("../specifications/response_codes.json");
31        serde_json::from_str(json).expect("invalid response_codes.json")
32    })
33}
34
35pub fn lookup_response_code(code: &str) -> Option<&'static str> {
36    response_codes().get(code).map(|s| s.as_str())
37}
38
39pub fn validate(message: &Value, spec: &LoadedSpec) -> ValidationResult {
40    let mut errors = Vec::new();
41
42    // 1. MTI validation
43    let mti = match message.get("mti").and_then(|v| v.as_str()) {
44        Some(m) => m,
45        None => {
46            errors.push(ValidationError {
47                de: 0,
48                field_name: "mti".to_string(),
49                rule: "mti".to_string(),
50                message: "MTI is missing".to_string(),
51            });
52            return ValidationResult { errors };
53        }
54    };
55
56    if mti.len() != 4 || !mti.chars().all(|c| c.is_ascii_digit()) {
57        errors.push(ValidationError {
58            de: 0,
59            field_name: "mti".to_string(),
60            rule: "mti".to_string(),
61            message: format!("MTI '{}' must be exactly 4 digits", mti),
62        });
63    } else {
64        let chars: Vec<char> = mti.chars().collect();
65        let version = chars[0];
66        let class = chars[1];
67        let function = chars[2];
68
69        if !['0', '1', '2', '9'].contains(&version) {
70            errors.push(ValidationError {
71                de: 0,
72                field_name: "mti".to_string(),
73                rule: "mti".to_string(),
74                message: format!("MTI version digit '{}' is invalid", version),
75            });
76        }
77        if !['1', '2', '3', '4', '5', '6', '7', '8'].contains(&class) {
78            errors.push(ValidationError {
79                de: 0,
80                field_name: "mti".to_string(),
81                rule: "mti".to_string(),
82                message: format!("MTI message class digit '{}' is invalid", class),
83            });
84        }
85        if !['0', '1', '2', '3', '4', '5'].contains(&function) {
86            errors.push(ValidationError {
87                de: 0,
88                field_name: "mti".to_string(),
89                rule: "mti".to_string(),
90                message: format!("MTI function digit '{}' is invalid", function),
91            });
92        }
93    }
94
95    // 2. Mandatory field check
96    if let Some(mandatory) = spec.mti_rules.get(mti)
97        && let Some(fields_obj) = message.get("fields").and_then(|v| v.as_object())
98    {
99        for &de_num in mandatory {
100            let prefix = format!("de{:03}_", de_num);
101            let found = fields_obj.keys().any(|k| k.starts_with(&prefix));
102            if !found {
103                let field_name = spec
104                    .get_field(de_num)
105                    .map(|f| f.name.clone())
106                    .unwrap_or_else(|| format!("de{:03}", de_num));
107                errors.push(ValidationError {
108                    de: de_num,
109                    field_name,
110                    rule: "mandatory".to_string(),
111                    message: format!("DE {} is mandatory for MTI {}", de_num, mti),
112                });
113            }
114        }
115    }
116
117    // 3. Per-field validation
118    if let Some(fields_obj) = message.get("fields").and_then(|v| v.as_object()) {
119        for (key, value) in fields_obj {
120            let de_num = match parse_de_number(key) {
121                Some(n) => n,
122                None => continue,
123            };
124
125            let field_spec = match spec.get_field(de_num) {
126                Some(f) => f,
127                None => continue,
128            };
129
130            // Skip composite fields (objects)
131            if value.is_object() {
132                continue;
133            }
134
135            let val_str = match value.as_str() {
136                Some(s) => s,
137                None => continue,
138            };
139
140            // Format check
141            match field_spec.field_type {
142                FieldType::Numeric => {
143                    if !val_str.chars().all(|c| c.is_ascii_digit()) {
144                        errors.push(ValidationError {
145                            de: de_num,
146                            field_name: field_spec.name.clone(),
147                            rule: "format".to_string(),
148                            message: format!(
149                                "DE {} value '{}' must contain only digits",
150                                de_num, val_str
151                            ),
152                        });
153                    }
154                }
155                FieldType::Binary => {
156                    if !val_str.chars().all(|c| c.is_ascii_hexdigit()) {
157                        errors.push(ValidationError {
158                            de: de_num,
159                            field_name: field_spec.name.clone(),
160                            rule: "format".to_string(),
161                            message: format!(
162                                "DE {} value '{}' must contain only hex characters",
163                                de_num, val_str
164                            ),
165                        });
166                    }
167                }
168                FieldType::XNumeric => {
169                    if val_str.is_empty() {
170                        errors.push(ValidationError {
171                            de: de_num,
172                            field_name: field_spec.name.clone(),
173                            rule: "format".to_string(),
174                            message: format!("DE {} x+n value must not be empty", de_num),
175                        });
176                    } else {
177                        let first = val_str.chars().next().unwrap();
178                        if first != 'C' && first != 'D' {
179                            errors.push(ValidationError {
180                                de: de_num,
181                                field_name: field_spec.name.clone(),
182                                rule: "format".to_string(),
183                                message: format!(
184                                    "DE {} x+n value '{}' must start with C or D",
185                                    de_num, val_str
186                                ),
187                            });
188                        }
189                        if !val_str[1..].chars().all(|c| c.is_ascii_digit()) {
190                            errors.push(ValidationError {
191                                de: de_num,
192                                field_name: field_spec.name.clone(),
193                                rule: "format".to_string(),
194                                message: format!(
195                                    "DE {} x+n value '{}' digits portion must be all digits",
196                                    de_num, val_str
197                                ),
198                            });
199                        }
200                    }
201                }
202                _ => {}
203            }
204
205            // Length check
206            match field_spec.prefix {
207                PrefixType::Fixed => {
208                    if val_str.len() != field_spec.length {
209                        errors.push(ValidationError {
210                            de: de_num,
211                            field_name: field_spec.name.clone(),
212                            rule: "length".to_string(),
213                            message: format!(
214                                "DE {} length {} does not match fixed length {}",
215                                de_num,
216                                val_str.len(),
217                                field_spec.length
218                            ),
219                        });
220                    }
221                }
222                _ => {
223                    if val_str.len() > field_spec.length {
224                        errors.push(ValidationError {
225                            de: de_num,
226                            field_name: field_spec.name.clone(),
227                            rule: "length".to_string(),
228                            message: format!(
229                                "DE {} length {} exceeds maximum length {}",
230                                de_num,
231                                val_str.len(),
232                                field_spec.length
233                            ),
234                        });
235                    }
236                }
237            }
238
239            // Pattern check
240            if let Some(ref validation) = field_spec.validation
241                && let Some(ref pattern) = validation.pattern
242                && let Some(err) = validate_pattern(de_num, &field_spec.name, val_str, pattern)
243            {
244                errors.push(err);
245            }
246
247            // Response code check (DE 39)
248            if de_num == 39 && lookup_response_code(val_str).is_none() {
249                errors.push(ValidationError {
250                    de: 39,
251                    field_name: field_spec.name.clone(),
252                    rule: "response_code".to_string(),
253                    message: format!("DE 39 response code '{}' is not recognized", val_str),
254                });
255            }
256        }
257    }
258
259    ValidationResult { errors }
260}
261
262fn parse_de_number(key: &str) -> Option<u32> {
263    if let Some(num_part) = key.strip_prefix("de") {
264        let end = num_part.find('_').unwrap_or(num_part.len());
265        num_part[..end].parse().ok()
266    } else {
267        None
268    }
269}
270
271fn validate_pattern(
272    de: u32,
273    field_name: &str,
274    value: &str,
275    pattern: &str,
276) -> Option<ValidationError> {
277    match pattern {
278        "MMDDhhmmss" => {
279            if value.len() != 10 {
280                return Some(ValidationError {
281                    de,
282                    field_name: field_name.to_string(),
283                    rule: "pattern".to_string(),
284                    message: format!(
285                        "DE {} value '{}' must be 10 digits for MMDDhhmmss",
286                        de, value
287                    ),
288                });
289            }
290            let mm: u32 = value[0..2].parse().unwrap_or(0);
291            let dd: u32 = value[2..4].parse().unwrap_or(0);
292            let hh: u32 = value[4..6].parse().unwrap_or(0);
293            let mi: u32 = value[6..8].parse().unwrap_or(0);
294            let ss: u32 = value[8..10].parse().unwrap_or(0);
295            if !(1..=12).contains(&mm) || !(1..=31).contains(&dd) || hh > 23 || mi > 59 || ss > 59 {
296                return Some(ValidationError {
297                    de,
298                    field_name: field_name.to_string(),
299                    rule: "pattern".to_string(),
300                    message: format!(
301                        "DE {} value '{}' has invalid date/time components for MMDDhhmmss",
302                        de, value
303                    ),
304                });
305            }
306        }
307        "hhmmss" => {
308            if value.len() != 6 {
309                return Some(ValidationError {
310                    de,
311                    field_name: field_name.to_string(),
312                    rule: "pattern".to_string(),
313                    message: format!("DE {} value '{}' must be 6 digits for hhmmss", de, value),
314                });
315            }
316            let hh: u32 = value[0..2].parse().unwrap_or(99);
317            let mi: u32 = value[2..4].parse().unwrap_or(99);
318            let ss: u32 = value[4..6].parse().unwrap_or(99);
319            if hh > 23 || mi > 59 || ss > 59 {
320                return Some(ValidationError {
321                    de,
322                    field_name: field_name.to_string(),
323                    rule: "pattern".to_string(),
324                    message: format!(
325                        "DE {} value '{}' has invalid time components for hhmmss",
326                        de, value
327                    ),
328                });
329            }
330        }
331        "MMDD" => {
332            if value.len() != 4 {
333                return Some(ValidationError {
334                    de,
335                    field_name: field_name.to_string(),
336                    rule: "pattern".to_string(),
337                    message: format!("DE {} value '{}' must be 4 digits for MMDD", de, value),
338                });
339            }
340            let mm: u32 = value[0..2].parse().unwrap_or(0);
341            let dd: u32 = value[2..4].parse().unwrap_or(0);
342            if !(1..=12).contains(&mm) || !(1..=31).contains(&dd) {
343                return Some(ValidationError {
344                    de,
345                    field_name: field_name.to_string(),
346                    rule: "pattern".to_string(),
347                    message: format!(
348                        "DE {} value '{}' has invalid date components for MMDD",
349                        de, value
350                    ),
351                });
352            }
353        }
354        "YYMM" => {
355            if value.len() != 4 {
356                return Some(ValidationError {
357                    de,
358                    field_name: field_name.to_string(),
359                    rule: "pattern".to_string(),
360                    message: format!("DE {} value '{}' must be 4 digits for YYMM", de, value),
361                });
362            }
363            let mm: u32 = value[2..4].parse().unwrap_or(0);
364            if !(1..=12).contains(&mm) {
365                return Some(ValidationError {
366                    de,
367                    field_name: field_name.to_string(),
368                    rule: "pattern".to_string(),
369                    message: format!(
370                        "DE {} value '{}' has invalid month component for YYMM",
371                        de, value
372                    ),
373                });
374            }
375        }
376        _ => {}
377    }
378    None
379}