# validation.uk-sort-code-account 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. ## 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: . 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: ```ts 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: ```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: ```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: The specification also says to confirm the sort code exists in the EISCD first. That directory is licensed and not shipped here.