use super::funejson::Value; use super::validation_uk_modulus_table::{uk_modulus_table_from_value, UkModulusRow, UkModulusTable}; /// Exception 2's replacement weights when a is not 0: one set when g is not 9, /// another when it is. Both are part of the algorithm, in section 2 of /// Vocalink's "Validating account numbers", not of the weight table. const EXCEPTION_2_WEIGHTS: [i64; 14] = [0, 0, 1, 2, 5, 3, 6, 4, 8, 7, 10, 9, 3, 1]; const EXCEPTION_2_WEIGHTS_G9: [i64; 14] = [0, 0, 0, 0, 0, 0, 0, 0, 8, 7, 10, 9, 3, 1]; /// Exception 8 checks against this sorting code; exception 9 against Lloyds' /// euro sorting code. const EXCEPTION_8_SORT_CODE: &str = "090126"; const EXCEPTION_9_SORT_CODE: &str = "309634"; fn is_sort_code(value: &str) -> bool { value.len() == 6 && value.bytes().all(|b| b.is_ascii_digit()) } /// A table built by hand, or edited after parsing, can hold what the parser /// refuses. Refuse it here too, loudly: a bad row would otherwise index past /// the weights or quietly check nothing. fn check_table(table: &UkModulusTable) { for (i, row) in table.rows.iter().enumerate() { let wh = format!("modulus table row {}", i + 1); if !is_sort_code(&row.start) || !is_sort_code(&row.end) { panic!("{}: start and end must be six-digit sort codes", wh); } if row.algorithm != "MOD10" && row.algorithm != "MOD11" && row.algorithm != "DBLAL" { panic!("{}: unknown algorithm \"{}\"; expected MOD10, MOD11 or DBLAL", wh, row.algorithm); } if row.weights.len() != 14 { panic!("{}: expected 14 weights, found {}", wh, row.weights.len()); } if row.algorithm == "DBLAL" && row.weights.iter().any(|w| *w < 0) { panic!("{}: a DBLAL row cannot have a negative weight", wh); } if let Some(e) = row.exception { if !(1..=14).contains(&e) { panic!("{}: exception must be from 1 to 14, received {}", wh, e); } } } for (i, s) in table.substitutions.iter().enumerate() { if !is_sort_code(&s.original) || !is_sort_code(&s.substitute) { panic!( "modulus table substitution {}: original and substitute must be six-digit sort codes", i + 1 ); } } } /// Strip ASCII spaces and hyphens; None if anything else is not a digit. fn digits_only(value: &str) -> Option { let mut out = String::new(); for ch in value.chars() { if ch == ' ' || ch == '-' { continue; } if !ch.is_ascii_digit() { return None; } out.push(ch); } Some(out) } fn invalid(reason: &str, checked: bool) -> UkSortCodeAccount { UkSortCodeAccount { valid: false, sort_code: None, account_number: None, checked, reason: Some(reason.to_string()), } } fn digit_at(value: &str, index: usize) -> i64 { (value.as_bytes()[index] - b'0') as i64 } /// One modulus check from one row of the weight table, with its exception. fn run_check(row: &UkModulusRow, table: &UkModulusTable, sort_code: &str, account: &str) -> bool { let mut weights: [i64; 14] = [0; 14]; weights.copy_from_slice(&row.weights); let ex = row.exception; let a = digit_at(account, 0); let g = digit_at(account, 6); let h = digit_at(account, 7); // Substitutions are "for check purposes only": they change the digits that // are weighted, never which rows of the table apply. let mut sc: &str = sort_code; if ex == Some(5) { if let Some(s) = table.substitutions.iter().find(|s| s.original == sort_code) { sc = &s.substitute; } } else if ex == Some(8) { sc = EXCEPTION_8_SORT_CODE; } else if ex == Some(9) { sc = EXCEPTION_9_SORT_CODE; } if ex == Some(2) && a != 0 { weights = if g == 9 { EXCEPTION_2_WEIGHTS_G9 } else { EXCEPTION_2_WEIGHTS }; } if ex == Some(7) && g == 9 { for w in weights.iter_mut().take(8) { *w = 0; } } if ex == Some(10) && (&account[0..2] == "09" || &account[0..2] == "99") && g == 9 { for w in weights.iter_mut().take(8) { *w = 0; } } let number = format!("{}{}", sc, account); let double_alternate = row.algorithm == "DBLAL"; let mut total: i64 = 0; for (i, weight) in weights.iter().enumerate() { let product = digit_at(&number, i) * weight; // Double alternate adds the individual digits of each product, so 14 // counts as 1 + 4; the standard checks add the products themselves. // check_table keeps DBLAL weights non-negative, so the product is too. total += if double_alternate { product / 10 + product % 10 } else { product }; } if ex == Some(1) { total += 27; } // A MOD11 row may have a negative weight, so the total may be negative; the // remainder is taken as 0 to 10 (rem_euclid, Python's %) everywhere. if ex == Some(4) { return total.rem_euclid(11) == g * 10 + h; } if ex == Some(5) { if double_alternate { let remainder = total % 10; return if remainder == 0 { h == 0 } else { 10 - remainder == h }; } let remainder = total.rem_euclid(11); if remainder == 0 { return g == 0; } if remainder == 1 { return false; } return 11 - remainder == g; } let modulus = if row.algorithm == "MOD11" { 11 } else { 10 }; total % modulus == 0 } /// Modulus-check a UK sort code and account number against a weight table /// the caller supplies (Vocalink's VALACDOS.txt and SCSUBTAB.txt, parsed by /// `parse_uk_modulus_table`). /// /// A pass means the pair is a possible account at that sorting code, not that /// it exists or belongs to anyone in particular: that is Confirmation of /// Payee's job. A sort code no rule covers is presumed valid, as the /// specification says, and reported with `checked: false` so the caller can /// tell. /// /// # Panics /// Panics on a malformed table: never on a bad sort code or account number. pub fn validate_uk_sort_code_account(sort_code: &str, account_number: &str, table: &UkModulusTable) -> UkSortCodeAccount { check_table(table); let sc = match digits_only(sort_code) { Some(s) if s.len() == 6 => s, _ => return invalid("bad-sort-code", false), }; let mut account = match digits_only(account_number) { Some(s) if s.len() >= 6 && s.len() <= 10 => s, _ => return invalid("bad-account-number", false), }; // Nine and ten digit numbers are standardised differently bank by bank // (NatWest keeps the last eight, Co-operative the first eight, Santander // moves a digit into the sort code), and nothing here knows the bank. if account.len() > 8 { return invalid("non-standard-account", false); } while account.len() < 8 { account.insert(0, '0'); } let rules: Vec<&UkModulusRow> = table .rows .iter() .filter(|row| row.start.as_str() <= sc.as_str() && sc.as_str() <= row.end.as_str()) .collect(); let unchecked = |sc: String, account: String| UkSortCodeAccount { valid: true, sort_code: Some(sc), account_number: Some(account), checked: false, reason: None, }; if rules.is_empty() { return unchecked(sc, account); } let a = digit_at(&account, 0); let g = digit_at(&account, 6); let h = digit_at(&account, 7); // Exception 6: foreign currency accounts at these sorting codes follow no // published rule, so they cannot be checked either way. if rules[0].exception == Some(6) && (4..=8).contains(&a) && g == h { return unchecked(sc, account); } let first = rules[0]; let mut passed = run_check(first, table, &sc, &account); if first.exception == Some(14) && !passed { // Coutts: an eighth digit of 0, 1 or 9 is dropped and a zero put in // front, then the same modulus 11 check is run again. if h == 0 || h == 1 || h == 9 { passed = run_check(first, table, &sc, &format!("0{}", &account[..7])); } } else if rules.len() > 1 { let second = rules[1]; if matches!(first.exception, Some(2) | Some(10) | Some(12)) { // Pairs 2 & 9, 10 & 11 and 12 & 13: either check passing is enough. passed = passed || run_check(second, table, &sc, &account); } else if passed { let c = account.as_bytes()[2]; // Every other pair needs both; exception 3 skips the second when c // is 6 or 9. if !(second.exception == Some(3) && (c == b'6' || c == b'9')) { passed = run_check(second, table, &sc, &account); } } } if !passed { return invalid("bad-check-digit", true); } UkSortCodeAccount { valid: true, sort_code: Some(sc), account_number: Some(account), checked: true, reason: None, } } /// Object keys are camelCase to match the shared vectors, and so that a /// capability building on this one can reuse the same shape. pub fn uk_sort_code_account_to_value(result: &UkSortCodeAccount) -> Value { let text = |field: &Option| match field { Some(s) => Value::str(s), None => Value::Null, }; Value::obj(vec![ ("valid", Value::Bool(result.valid)), ("sortCode", text(&result.sort_code)), ("accountNumber", text(&result.account_number)), ("checked", Value::Bool(result.checked)), ("reason", text(&result.reason)), ]) } pub fn fune_vector(args: &[Value]) -> Value { uk_sort_code_account_to_value(&validate_uk_sort_code_account( args[0].as_str(), args[1].as_str(), &uk_modulus_table_from_value(&args[2]), )) }