Functional Weave
Code in Rust

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]),
    ))
}