Functional Weave
Code in Rust

validation.uk-sort-code-account

Check a UK sort code and account number with the Vocalink/Pay.UK modulus rules, against a table you supply.

3.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 64 tests, run in TypeScript, Python and Rust.

What it does

Checks a UK sort code and account number against the modulus rules banks publish through Vocalink (for Pay.UK), the same check Bacs recommends before a Direct Credit or Direct Debit instruction is submitted.

A PASS IS NOT AN ACCOUNT. The specification is explicit: a valid result means the account number is a possible account at that sorting code, not that it is open, in use or belongs to the payee. Use Confirmation of Payee for that. Use this to catch a mistyped digit before it becomes a returned payment.

For example

  • validate_uk_sort_code_account(115000, 12345672, rows ×1, substitutions ) → valid true, sort code 115000, account number 12345672, checked true, reason — MOD10 passes: 7+2+9+28+5+18+49+2 = 120
  • validate_uk_sort_code_account(115000, 12345678, rows ×1, substitutions ) → valid false, sort code —, account number —, checked true, reason bad-check-digit MOD10 fails: 7+2+9+28+5+18+49+8 = 126
  • validate_uk_sort_code_account(120000, 12345679, rows ×1, substitutions ) → valid true, sort code 120000, account number 12345679, checked true, reason — MOD11 passes: 8+14+18+20+20+18+14+9 = 121 = 11 x 11

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn validate_uk_sort_code_account(sort_code: &str, account_number: &str, table: &UkModulusTable) -> UkSortCodeAccount
sort_codestringsix digits, optionally with spaces or hyphens: "08-99-99"
account_numberstringsix to eight digits, optionally spaced; six and seven are zero-padded
tableUkModulusTableVocalink's VALACDOS.txt and SCSUBTAB.txt, parsed by parseUkModulusTable
returnsUkSortCodeAccountthrows only for a malformed table, never for a bad sort code or account number

The type it declares, generated into your project

/// The two numbers are null when valid is false.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UkSortCodeAccount {
    pub valid: bool,
    /// six digits, no separators
    pub sort_code: Option<String>,
    /// eight digits, after zero-padding
    pub account_number: Option<String>,
    /// true when a modulus rule decided the answer; false when none applies
    pub checked: bool,
    /// null when valid; bad-sort-code, bad-account-number, non-standard-account or bad-check-digit
    pub reason: Option<String>,
}

Your code names it in one line, in the file that uses it

fune!(validation.uk-sort-code-account@^3);  // then call validate_uk_sort_code_account(…)
impl/rust.rs · 272 lines · open · 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]),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add validation.uk-sort-code-account
Download for Rust validation.uk-sort-code-account-3.0.0-rust.fune · 52,117 bytes sha256 95e73615b85ba29d5f5a6f69792f8f650c4e6b95ebf600d81d068a682a4d7059

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.uk-sort-code-account-3.0.0-rust.fune, or fetch it from a terminal with fune pull validation.uk-sort-code-account@3.0.0:rust.

The whole function, every language, is one file too: validation.uk-sort-code-account-3.0.0.fune, 68,924 bytes, sha256 636ab9cf79494aeb1d6f17d59ab4a5237bae07a8fbabfc3a1c29c5faf45e00e7. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before validation.uk-sort-code-account

after — your function gets the result and the arguments, and returns the final result.

// fune: after validation.uk-sort-code-account

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace validation.uk-modulus-table in validation.uk-sort-code-account

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.uk-sort-code-account --steps.

// fune: step validation.uk-sort-code-account after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
MOD10 passes: 7+2+9+28+5+18+49+2 = 120 115000, 12345672, rows ×1, substitutions → valid true, sort code 115000, account number 12345672, checked true, reason —
MOD10 fails: 7+2+9+28+5+18+49+8 = 126 115000, 12345678, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
MOD11 passes: 8+14+18+20+20+18+14+9 = 121 = 11 x 11 120000, 12345679, rows ×1, substitutions → valid true, sort code 120000, account number 12345679, checked true, reason —
MOD11 fails: the total is 120, remainder 10 120000, 12345678, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
DBLAL adds the digits of each product (18 counts 9, 14 counts 5, 10 counts 1): 50 passes, where adding the products whole gives 77 130000, 98765436, rows ×1, substitutions → valid true, sort code 130000, account number 98765436, checked true, reason —
DBLAL: one more in h makes 51, which fails 130000, 98765437, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
two rows: MOD11 (99) and DBLAL (40) both pass 141414, 12345601, rows ×2, substitutions → valid true, sort code 141414, account number 12345601, checked true, reason —
two rows: MOD11 passes (110) but DBLAL fails (51), so invalid 141414, 12345628, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
two rows: MOD11 fails but DBLAL passes, so invalid 141414, 12345619, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
the first sort code of a range is in it 110000, 12345672, rows ×2, substitutions → valid true, sort code 110000, account number 12345672, checked true, reason —
Show the other 54 tests
CaseArgumentsExpected
the last sort code of a range is in it 119999, 12345672, rows ×2, substitutions → valid true, sort code 119999, account number 12345672, checked true, reason —
a sort code between rows is in none: presumed valid, reported unchecked 109999, 12345678, rows ×2, substitutions → valid true, sort code 109999, account number 12345678, checked false, reason —
an empty table checks nothing: every well-formed pair is unchecked 089999, 66374958, rows , substitutions → valid true, sort code 089999, account number 66374958, checked false, reason —
a negative weight in a MOD11 row: -9+14+18+20+20+18+0+7 = 88 330000, 92345607, rows ×1, substitutions → valid true, sort code 330000, account number 92345607, checked true, reason —
exception 1: 27 is added to the DBLAL total, 33 + 27 = 60 210050, 12345606, rows ×1, substitutions → valid true, sort code 210050, account number 12345606, checked true, reason —
exception 1: 34 + 27 = 61 fails 210050, 12345607, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 2 & 9: a is 0, so the row's own weights are used and pass 220010, 02345604, rows ×2, substitutions → valid true, sort code 220010, account number 02345604, checked true, reason —
exception 2: a is not 0 and g is not 9, so the weights become 0 0 1 2 5 3 6 4 8 7 10 9 3 1 (176) 220010, 12345601, rows ×2, substitutions → valid true, sort code 220010, account number 12345601, checked true, reason —
exception 2: a is not 0 and g is 9, so the weights become 0 0 0 0 0 0 0 0 8 7 10 9 3 1 220010, 12345694, rows ×2, substitutions → valid true, sort code 220010, account number 12345694, checked true, reason —
exception 2 fails, and exception 9 passes only because sort code 309634 replaces 220010 220010, 12345615, rows ×2, substitutions → valid true, sort code 220010, account number 12345615, checked true, reason —
exception 2 and exception 9 both fail 220010, 12345600, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 3: c is 6, so the failing DBLAL check is skipped 230020, 12645602, rows ×2, substitutions → valid true, sort code 230020, account number 12645602, checked true, reason —
exception 3: c is 1, so the DBLAL check runs and fails 230020, 12145607, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 4: the remainder, 98 mod 11 = 10, equals gh 240030, 12345610, rows ×1, substitutions → valid true, sort code 240030, account number 12345610, checked true, reason —
exception 4: the remainder 10 does not equal gh = 11 240030, 12345611, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 5 with substitution: 250050 is weighted as 250010; MOD11 124 gives g = 11 - 3 = 8, DBLAL 39 gives h = 10 - 9 = 1 250050, 12345681, rows ×2, substitutions ×1 → valid true, sort code 250050, account number 12345681, checked true, reason —
exception 5 without the substitution the same account fails 250050, 12345681, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 5, a sort code with no substitute: both check digits right 250020, 12345655, rows ×2, substitutions ×1 → valid true, sort code 250020, account number 12345655, checked true, reason —
exception 5: a MOD11 remainder of 1 is always invalid 250020, 12345903, rows ×2, substitutions ×1 → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 5: a MOD11 remainder of 0 needs g = 0 250020, 12346703, rows ×2, substitutions ×1 → valid true, sort code 250020, account number 12346703, checked true, reason —
exception 5: g right, h wrong 250020, 12345650, rows ×2, substitutions ×1 → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 6: a is 5 and g equals h, a foreign currency account: valid but unchecked 260060, 52345600, rows ×2, substitutions → valid true, sort code 260060, account number 52345600, checked false, reason —
exception 6: a is 5 but g differs from h, so it is checked and fails 260060, 52345601, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 6: a is 3, so it is checked and fails 260060, 32345600, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 7: g is 9, so weights u to b are zeroed and it passes 270070, 12345690, rows ×1, substitutions → valid true, sort code 270070, account number 12345690, checked true, reason —
exception 7: g is not 9, the ordinary check passes 270070, 12345613, rows ×1, substitutions → valid true, sort code 270070, account number 12345613, checked true, reason —
exception 8: checked as sort code 090126, 58 + 107 = 165 280080, 12345603, rows ×1, substitutions → valid true, sort code 280080, account number 12345603, checked true, reason —
exception 8: two more in h makes 167, which fails 280080, 12345604, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 10 & 11: the first fails, the second passes, either is enough 300030, 12345601, rows ×2, substitutions → valid true, sort code 300030, account number 12345601, checked true, reason —
exception 10: ab is 99 and g is 9, so weights u to b are zeroed (121) and it passes 300030, 99345690, rows ×2, substitutions → valid true, sort code 300030, account number 99345690, checked true, reason —
exception 10: ab is 09 and g is 9, the same 300030, 09345690, rows ×2, substitutions → valid true, sort code 300030, account number 09345690, checked true, reason —
exception 12 & 13: MOD11 fails, MOD10 passes, either is enough 310010, 12345614, rows ×2, substitutions → valid true, sort code 310010, account number 12345614, checked true, reason —
exception 12 & 13: both fail 310010, 12345600, rows ×2, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
exception 14: fails (107), h is 9, so 01234560 is checked instead (77) and passes 320040, 12345609, rows ×1, substitutions → valid true, sort code 320040, account number 12345609, checked true, reason —
exception 14: h is 0, the same retry passes 320040, 12345600, rows ×1, substitutions → valid true, sort code 320040, account number 12345600, checked true, reason —
exception 14: h is 2, so there is no retry even though the shifted number would pass 320040, 12345602, rows ×1, substitutions → valid false, sort code —, account number —, checked true, reason bad-check-digit
a sort code with hyphens and an account number with a space 11-50-00, 1234 5672, rows ×1, substitutions → valid true, sort code 115000, account number 12345672, checked true, reason —
a seven-digit account number gets one leading zero: 0+7+12+15+16+15+12+0 = 77 120000, 1234560, rows ×1, substitutions → valid true, sort code 120000, account number 01234560, checked true, reason —
a six-digit account number gets two leading zeros: 0+0+18+20+20+18+14+9 = 99 120000, 345679, rows ×1, substitutions → valid true, sort code 120000, account number 00345679, checked true, reason —
nine digits needs the bank's own standardisation first 120000, 123456789, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason non-standard-account
ten digits too 120000, 0123456789, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason non-standard-account
five digits is too short to be any account number 120000, 12345, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-account-number
a letter in the account number 120000, 1234567O, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-account-number
non-ASCII digits in the account number 120000, 1234567٩, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-account-number
a five-digit sort code 12000, 12345679, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-sort-code
both empty reports the sort code first , , rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-sort-code
a dot is not a sort code separator 12.00.00, 12345679, rows ×1, substitutions → valid false, sort code —, account number —, checked false, reason bad-sort-code
a row with 13 weights 120000, 12345679, rows ×1, substitutions → error: modulus table row 1: expected 14 weights, found 13
an unknown algorithm 120000, 12345679, rows ×2, substitutions → error: modulus table row 2: unknown algorithm "MOD12"
a five-digit range start 120000, 12345679, rows ×1, substitutions → error: modulus table row 1: start and end must be six-digit sort codes
a negative weight in a DBLAL row 130000, 12345679, rows ×1, substitutions → error: modulus table row 1: a DBLAL row cannot have a negative weight
exception 15 does not exist 120000, 12345679, rows ×1, substitutions → error: modulus table row 1: exception must be from 1 to 14
a malformed table is an error even for a sort code it does not cover 990000, 12345679, rows ×1, substitutions → error: modulus table row 1: exception must be from 1 to 14
a substitution with a short sort code 250020, 12345655, rows ×2, substitutions ×1 → error: modulus table substitution 1: original and substitute must be six-digit sort codes

More from the author

## You supply the weight table

This package is the algorithm only. The modulus weight table it checks against, Vocalink's **VALACDOS.txt**, and exception 5's substitution table, **SCSUBTAB.txt**, are Vocalink's copyright and are not redistributed here.

1. Download both files from Vocalink's modulus checking page, run for Pay.UK: <https://www.vocalink.com/tools/modulus-checking/>. 2. Parse them with `parseUkModulusTable` from `validation.uk-modulus-table`, once, when your application starts. 3. Pass the table to every call.

Vocalink updates VALACDOS.txt several times a year as banks add and retire sort codes; the page gives the date of each release. Fetch the new file when it changes. An out-of-date table answers "unchecked" for a new sort code rather than rejecting it.

TypeScript:

import { readFileSync } from "node:fs";
import { parseUkModulusTable } from "#fune/validation.uk-modulus-table@^1";
import { validateUkSortCodeAccount } from "#fune/validation.uk-sort-code-account@^3";

const table = parseUkModulusTable(
  readFileSync("data/valacdos.txt", "utf8"),
  readFileSync("data/scsubtab.txt", "utf8"),
);

const result = validateUkSortCodeAccount("08-99-99", "66374958", table);
if (!result.valid) console.log(result.reason); // "bad-check-digit", ...

Python:

from pathlib import Path

from fune.validation.uk_modulus_table import parse_uk_modulus_table  # validation.uk-modulus-table@^1
from fune.validation.uk_sort_code_account import validate_uk_sort_code_account  # validation.uk-sort-code-account@^3

table = parse_uk_modulus_table(
    Path("data/valacdos.txt").read_text(encoding="utf-8"),
    Path("data/scsubtab.txt").read_text(encoding="utf-8"),
)

result = validate_uk_sort_code_account("08-99-99", "66374958", table)
if not result.valid:
    print(result.reason)

Rust:

fune!(validation.uk-modulus-table@^1);
fune!(validation.uk-sort-code-account@^3);

fn main() -> std::io::Result<()> {
    let table = parse_uk_modulus_table(
        &std::fs::read_to_string("data/valacdos.txt")?,
        &std::fs::read_to_string("data/scsubtab.txt")?,
    );
    let result = validate_uk_sort_code_account("08-99-99", "66374958", &table);
    if !result.valid {
        println!("{}", result.reason.unwrap_or_default());
    }
    Ok(())
}

## The result

`valid`, the standardised `sortCode` (six digits) and `accountNumber` (eight digits), `checked`, and a `reason` when invalid. The two numbers are null when `valid` is false.

`checked` is false in two cases where the specification says to presume the account valid because nothing can be checked: the sort code is in no range of the weight table, or exception 6 applies (a foreign currency account at a sorting code that holds them). Callers who want to treat "unchecked" more cautiously can. An empty table leaves everything unchecked, which is one reason the parser refuses an empty VALACDOS.txt.

| reason | meaning | |---|---| | `bad-sort-code` | not six digits once spaces and hyphens are removed (checked first) | | `bad-account-number` | not 6 to 10 digits once spaces and hyphens are removed | | `non-standard-account` | 9 or 10 digits: must be standardised per bank first (below) | | `bad-check-digit` | the modulus check failed |

A bad sort code or account number is an answer, never an error. The function throws only for a malformed table (one built or edited by hand: a row without 14 weights, an unknown algorithm, a range that is not two six-digit sort codes, a negative DBLAL weight, an exception outside 1 to 14, or a substitution that is not two six-digit sort codes), naming the row.

## Account number length

Six and seven digit account numbers are left-padded with zeros, which the specification applies to every bank. Nine and ten digit numbers are refused with `non-standard-account`, because their standardisation depends on the bank (NatWest keeps the last eight digits, Co-operative and Leeds the first eight, Santander moves the first digit into the sort code) and nothing here knows which bank a sort code belongs to without the EISCD. Standardise them and call again.

## The algorithm

Find the row(s) of the weight table whose range contains the sort code. Each names MOD10, MOD11 or DBLAL (double alternate), fourteen weights for the digits u-z (sort code) and a-h (account), and an optional exception. Multiply digit by weight; MOD10 and MOD11 add the products, DBLAL adds the digits of the products; the total must divide by the modulus. With two rows, both checks must pass, except:

- 1: add 27 to the DBLAL total. - 2 and 9: if a is not 0 replace the weights (two sets, by whether g is 9); if that fails, check again against sort code 309634 (Lloyds euro accounts). Either passing is enough. - 3: skip the second check when c is 6 or 9. - 4: the MOD11 remainder must equal the two-digit number gh. - 5: substitute the sort code from the table's substitutions (SCSUBTAB.txt) for both checks; the MOD11 check digit is g and the DBLAL check digit is h: a MOD11 remainder of 0 needs g = 0, of 1 is always invalid, otherwise g = 11 - remainder; a DBLAL remainder of 0 needs h = 0, otherwise h = 10 - remainder. - 6: a in 4-8 with g equal to h is a foreign currency account: unchecked. - 7: if g is 9, zero the weights u to b. - 8: check against sort code 090126. - 10 and 11: either passing is enough; for 10, if ab is 09 or 99 and g is 9, zero the weights u to b. - 12 and 13: either passing is enough. - 14 (Coutts): if MOD11 fails and h is 0, 1 or 9, drop h, put a 0 in front and check again.

A MOD11 row may carry a negative weight; its remainder is always taken as 0 to 10, so the three languages agree.

## Tests

The vectors use small invented tables, not Vocalink's: made-up sort code ranges (110000-119999, 220000-220099 and so on) with weights chosen so each algorithm and each exception is exercised, passing and failing, and every expected answer worked by hand from the specification's description of the algorithm. They are not the test cases in chapter 3 of the specification, which depend on the real table. To check your own copy of VALACDOS.txt, run those cases against it in your application's tests.

## Changes

3.0.0 takes the weight table as a third argument instead of bundling Vocalink's VALACDOS.txt and SCSUBTAB.txt, which may not be redistributed. Parse the files with `validation.uk-modulus-table`. The result and its reasons are unchanged; the function now throws for a malformed table.

## Sources

- Vocalink, "Validating account numbers: UK Modulus Checking", section 2 (the algorithms and exceptions), via the modulus checking page: <https://www.vocalink.com/tools/modulus-checking/>

The specification also says to confirm the sort code exists in the EISCD first. That directory is licensed and not shipped here.

Files

PathBytes
README.md7,375
impl/python.py8,145
impl/rust.rs10,007
impl/typescript.ts8,108
vectors.json28,076