validation.uk-sort-code-account@3.0.0
impl/python.py
8,145 bytes · the Python 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.
import re
from typing import List, Optional
from .validation_uk_modulus_table import UkModulusRow, UkModulusTable ← from validation.uk-modulus-table ^1.0.0 · built alongside by fune
from .validation_uk_sort_code_account_types import UkSortCodeAccount
#: 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.
_EXCEPTION_2_WEIGHTS = (0, 0, 1, 2, 5, 3, 6, 4, 8, 7, 10, 9, 3, 1)
_EXCEPTION_2_WEIGHTS_G9 = (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.
_EXCEPTION_8_SORT_CODE = "090126"
_EXCEPTION_9_SORT_CODE = "309634"
_SORT_CODE = re.compile(r"[0-9]{6}")
def _is_sort_code(value: object) -> bool:
return isinstance(value, str) and _SORT_CODE.fullmatch(value) is not None
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _check_table(table: UkModulusTable) -> None:
"""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."""
for i, row in enumerate(table.rows):
where = "modulus table row %d" % (i + 1)
if not _is_sort_code(row.start) or not _is_sort_code(row.end):
raise ValueError("%s: start and end must be six-digit sort codes" % where)
if row.algorithm not in ("MOD10", "MOD11", "DBLAL"):
raise ValueError('%s: unknown algorithm "%s"; expected MOD10, MOD11 or DBLAL' % (where, row.algorithm))
if len(row.weights) != 14:
raise ValueError("%s: expected 14 weights, found %d" % (where, len(row.weights)))
if not all(_is_int(w) for w in row.weights):
raise ValueError("%s: weights must be whole numbers" % where)
if row.algorithm == "DBLAL" and any(w < 0 for w in row.weights):
raise ValueError("%s: a DBLAL row cannot have a negative weight" % where)
if row.exception is not None and (not _is_int(row.exception) or not 1 <= row.exception <= 14):
raise ValueError("%s: exception must be from 1 to 14, received %s" % (where, row.exception))
for i, s in enumerate(table.substitutions):
if not _is_sort_code(s.original) or not _is_sort_code(s.substitute):
raise ValueError(
"modulus table substitution %d: original and substitute must be six-digit sort codes" % (i + 1)
)
def _digits_only(value: str) -> Optional[str]:
"""Strip ASCII spaces and hyphens; None if anything else is not a digit."""
out: List[str] = []
for ch in value:
if ch == " " or ch == "-":
continue
if ch < "0" or ch > "9":
return None
out.append(ch)
return "".join(out)
def _invalid(reason: str, checked: bool) -> UkSortCodeAccount:
return UkSortCodeAccount(valid=False, sort_code=None, account_number=None, checked=checked, reason=reason)
def _run_check(row: UkModulusRow, table: UkModulusTable, sort_code: str, account: str) -> bool:
"""One modulus check from one row of the weight table, with its exception."""
weights = list(row.weights)
ex = row.exception
a = ord(account[0]) - 48
g = ord(account[6]) - 48
h = ord(account[7]) - 48
# Substitutions are "for check purposes only": they change the digits that
# are weighted, never which rows of the table apply.
if ex == 5:
for s in table.substitutions:
if s.original == sort_code:
sort_code = s.substitute
break
elif ex == 8:
sort_code = _EXCEPTION_8_SORT_CODE
elif ex == 9:
sort_code = _EXCEPTION_9_SORT_CODE
if ex == 2 and a != 0:
weights = list(_EXCEPTION_2_WEIGHTS_G9 if g == 9 else _EXCEPTION_2_WEIGHTS)
if ex == 7 and g == 9:
weights = [0] * 8 + weights[8:]
if ex == 10 and account[0:2] in ("09", "99") and g == 9:
weights = [0] * 8 + weights[8:]
number = sort_code + account
total = 0
for i in range(14):
product = (ord(number[i]) - 48) * weights[i]
# 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 += product // 10 + product % 10 if row.algorithm == "DBLAL" else product
if ex == 1:
total += 27
# A MOD11 row may have a negative weight, so the total may be negative;
# Python's % already gives 0 to 10, as the other languages are made to.
if ex == 4:
return total % 11 == g * 10 + h
if ex == 5:
if row.algorithm == "DBLAL":
remainder = total % 10
return h == 0 if remainder == 0 else 10 - remainder == h
remainder = total % 11
if remainder == 0:
return g == 0
if remainder == 1:
return False
return 11 - remainder == g
modulus = 11 if row.algorithm == "MOD11" else 10
return total % modulus == 0
def validate_uk_sort_code_account(sort_code: str, account_number: str, table: UkModulusTable) -> UkSortCodeAccount:
"""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.
"""
_check_table(table)
sc = _digits_only(sort_code) if isinstance(sort_code, str) else None
if sc is None or len(sc) != 6:
return _invalid("bad-sort-code", False)
account = _digits_only(account_number) if isinstance(account_number, str) else None
if account is None or len(account) < 6 or len(account) > 10:
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 len(account) > 8:
return _invalid("non-standard-account", False)
account = account.rjust(8, "0")
rules = [row for row in table.rows if row.start <= sc <= row.end]
if not rules:
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=False, reason=None)
a = ord(account[0]) - 48
g = ord(account[6]) - 48
h = ord(account[7]) - 48
# Exception 6: foreign currency accounts at these sorting codes follow no
# published rule, so they cannot be checked either way.
if rules[0].exception == 6 and 4 <= a <= 8 and g == h:
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=False, reason=None)
first = rules[0]
passed = _run_check(first, table, sc, account)
if first.exception == 14 and not 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 in (0, 1, 9):
passed = _run_check(first, table, sc, "0" + account[:7])
elif len(rules) > 1:
second = rules[1]
if first.exception in (2, 10, 12):
# Pairs 2 & 9, 10 & 11 and 12 & 13: either check passing is enough.
passed = passed or _run_check(second, table, sc, account)
elif passed and not (second.exception == 3 and account[2] in ("6", "9")):
# Every other pair needs both; exception 3 skips the second when c
# is 6 or 9.
passed = _run_check(second, table, sc, account)
if not passed:
return _invalid("bad-check-digit", True)
return UkSortCodeAccount(valid=True, sort_code=sc, account_number=account, checked=True, reason=None)