import re from typing import List, Optional from .validation_uk_modulus_table import UkModulusRow, UkModulusTable 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)