Functional Weave
Code in Python

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)