validation.uk-sort-code-account@3.0.0
impl/rust.rs
10,007 bytes · the Rust implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::validation_uk_modulus_table::{uk_modulus_table_from_value, UkModulusRow, UkModulusTable}; ← from validation.uk-modulus-table ^1.0.0 · built alongside by fune
/// 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<String> {
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<String>| 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]),
))
}