Packages

UK bank account modulus checking using the Vocalink algorithm. Validates UK sort code and account number combinations.

Current section

Files

Jump to
uk_modulus lib uk_modulus.ex
Raw

lib/uk_modulus.ex

defmodule UkModulus do
@moduledoc """
UK Bank Account Modulus Checking.
Validates UK sort code and account number combinations using the
Vocalink modulus checking algorithm.
The algorithm uses weights applied to each digit of the combined
sort code + account number, then checks if the result is divisible
by 10 or 11 (depending on the algorithm type).
Data source: Vocalink (https://www.vocalink.com/tools/modulus-checking/)
## Usage
iex> UkModulus.valid?("200000", "58177632")
true
iex> UkModulus.valid?("200000", "58177633")
false
iex> UkModulus.validate("200000", "58177632")
{:ok, true}
iex> UkModulus.validate("123", "12345678")
{:error, :invalid_sort_code_format}
## How it works
The library downloads the full Vocalink weight table (~1,800 entries) on
application startup and caches it locally. Data is refreshed automatically
every 7 days.
Until the download completes, fallback data covering major UK banks is used.
"""
alias UkModulus.VocalinkData
# Sort code substitution table (for exception 5)
@substitutions %{
"938173" => "938017",
"938289" => "938068",
"938297" => "938076",
"938600" => "938017",
"938602" => "938017",
"938604" => "938017",
"938608" => "938017",
"938609" => "938017",
"938611" => "938017",
"938613" => "938017",
"938616" => "938068",
"938618" => "938068",
"938620" => "938068",
"938622" => "938068",
"938628" => "938068",
"938643" => "938068",
"938647" => "938076",
"938648" => "938076",
"938649" => "938076",
"938651" => "938076",
"938657" => "938076"
}
# Embedded weight table for fallback
@weight_table [
# Barclays
{"200000", "209999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# HSBC
{"400000", "409999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Lloyds
{"300000", "309999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
{"770000", "779999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# NatWest / RBS
{"500000", "509999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
{"600000", "609999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
{"830000", "839999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Santander
{"090000", "099999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Halifax / Bank of Scotland
{"110000", "119999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Nationwide
{"070000", "079999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# TSB
{"870000", "879999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Coutts
{"180000", "189999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Metro Bank
{"230000", "239999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Starling
{"608000", "608999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Monzo
{"040000", "049999", :dblal, [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1], nil},
# Co-operative Bank
{"089000", "089999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Virgin Money
{"050000", "059999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Building societies
{"010004", "016715", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
{"010016", "019999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], nil},
# Exception 5 sort codes (Coutts special handling)
{"938000", "938999", :mod11, [0, 0, 0, 0, 0, 0, 8, 7, 6, 5, 4, 3, 2, 1], 5}
]
@doc """
Validates a UK sort code and account number combination.
Returns `{:ok, true}` if valid, `{:ok, false}` if invalid,
or `{:error, reason}` if the input format is wrong.
## Examples
iex> UkModulus.validate("200000", "58177632")
{:ok, true}
iex> UkModulus.validate("200000", "58177633")
{:ok, false}
iex> UkModulus.validate("123", "12345678")
{:error, :invalid_sort_code_format}
iex> UkModulus.validate("20-00-00", "58177632")
{:ok, true}
"""
@spec validate(String.t(), String.t()) ::
{:ok, boolean()} | {:error, :invalid_sort_code_format | :invalid_account_number_format}
def validate(sort_code, account_number) do
with {:ok, sort_code} <- normalize_sort_code(sort_code),
{:ok, account_number} <- normalize_account_number(account_number) do
case find_weight_rules(sort_code) do
[] ->
# No rules found for this sort code range - can't validate
# Return true to allow (payment processor will validate)
{:ok, true}
rules ->
result = validate_with_rules(sort_code, account_number, rules)
{:ok, result}
end
end
end
@doc """
Same as `validate/2` but returns a simple boolean.
Returns `true` if valid or if validation cannot be performed.
Returns `false` if invalid or if the format is wrong.
## Examples
iex> UkModulus.valid?("200000", "58177632")
true
iex> UkModulus.valid?("200000", "58177633")
false
iex> UkModulus.valid?("123", "12345678")
false
"""
@spec valid?(String.t(), String.t()) :: boolean()
def valid?(sort_code, account_number) do
case validate(sort_code, account_number) do
{:ok, result} -> result
{:error, _} -> false
end
end
@doc """
Check if the Vocalink data is loaded and ready.
Returns `true` if data has been loaded into ETS (either fallback or downloaded).
"""
@spec ready?() :: boolean()
def ready? do
VocalinkData.ready?()
end
@doc """
Force a refresh of the Vocalink data.
This will trigger an async download of the latest data.
"""
@spec refresh() :: :ok
def refresh do
VocalinkData.refresh()
end
# --- Private Functions ---
# Normalize sort code to 6 digits (remove dashes/spaces)
defp normalize_sort_code(sort_code) when is_binary(sort_code) do
normalized = sort_code |> String.replace(~r/[\s\-]/, "")
if String.match?(normalized, ~r/^\d{6}$/) do
{:ok, normalized}
else
{:error, :invalid_sort_code_format}
end
end
defp normalize_sort_code(_), do: {:error, :invalid_sort_code_format}
# Normalize account number to 8 digits (pad with zeros if needed)
defp normalize_account_number(account_number) when is_binary(account_number) do
normalized = account_number |> String.replace(~r/[\s\-]/, "")
cond do
String.match?(normalized, ~r/^\d{8}$/) ->
{:ok, normalized}
String.match?(normalized, ~r/^\d{6}$/) ->
# Some old accounts have 6 digits - pad with zeros
{:ok, "00" <> normalized}
String.match?(normalized, ~r/^\d{7}$/) ->
# 7 digit accounts - pad with one zero
{:ok, "0" <> normalized}
true ->
{:error, :invalid_account_number_format}
end
end
defp normalize_account_number(_), do: {:error, :invalid_account_number_format}
# Find all weight rules that apply to this sort code
# Tries VocalinkData GenServer first, falls back to embedded table
defp find_weight_rules(sort_code) do
case VocalinkData.get_weight_rules(sort_code) do
rules when is_list(rules) and rules != [] ->
# Convert from VocalinkData format {start_int, end_int, algo, weights, exception}
Enum.map(rules, fn {start_int, end_int, algo, weights, exception} ->
{Integer.to_string(start_int), Integer.to_string(end_int), algo, weights, exception}
end)
_ ->
# Fallback to embedded weight table
find_weight_rules_embedded(sort_code)
end
end
defp find_weight_rules_embedded(sort_code) do
sort_int = String.to_integer(sort_code)
@weight_table
|> Enum.filter(fn {start_sort, end_sort, _, _, _} ->
start_int = String.to_integer(start_sort)
end_int = String.to_integer(end_sort)
sort_int >= start_int and sort_int <= end_int
end)
end
# Validate using the found rules
defp validate_with_rules(sort_code, account_number, rules) do
case rules do
[single_rule] ->
check_single_rule(sort_code, account_number, single_rule)
[first_rule, second_rule] ->
check_double_rules(sort_code, account_number, first_rule, second_rule)
_ ->
# Multiple rules - check if any pass
Enum.any?(rules, &check_single_rule(sort_code, account_number, &1))
end
end
defp check_single_rule(sort_code, account_number, {_, _, algorithm, weights, exception}) do
effective_sort = apply_substitution(sort_code, exception)
digits = parse_digits(effective_sort, account_number)
run_check(algorithm, digits, weights, exception)
end
defp check_double_rules(sort_code, account_number, first_rule, second_rule) do
{_, _, _, _, ex1} = first_rule
{_, _, _, _, ex2} = second_rule
first_result = check_single_rule(sort_code, account_number, first_rule)
cond do
# Exception 2 & 9: If first check passes, account is valid
ex1 in [2, 9] and first_result ->
true
# Exception 10 & 11: Special handling for certain banks
ex1 in [10, 11] ->
first_result or check_single_rule(sort_code, account_number, second_rule)
# Exception 12 & 13: Special Natwest handling
ex1 in [12, 13] ->
first_result and check_single_rule(sort_code, account_number, second_rule)
# Exception 14: Special handling
ex2 == 14 and not first_result ->
check_single_rule(sort_code, account_number, second_rule)
# Standard: both checks must pass, or first check passes
true ->
first_result or check_single_rule(sort_code, account_number, second_rule)
end
end
# Apply sort code substitution for exception 5
defp apply_substitution(sort_code, 5) do
# Try VocalinkData first, then fall back to embedded substitutions
case VocalinkData.get_substitution(sort_code) do
^sort_code ->
Map.get(@substitutions, sort_code, sort_code)
substitute ->
substitute
end
end
defp apply_substitution(sort_code, _), do: sort_code
# Parse sort code + account number into 14 digits
defp parse_digits(sort_code, account_number) do
(sort_code <> account_number)
|> String.graphemes()
|> Enum.map(&String.to_integer/1)
end
# Run the appropriate modulus check
defp run_check(:mod10, digits, weights, exception) do
total = weighted_sum(digits, weights, exception)
rem(total, 10) == 0
end
defp run_check(:mod11, digits, weights, exception) do
total = weighted_sum(digits, weights, exception)
case exception do
4 ->
# Exception 4: remainder must equal g*10 + h
g = Enum.at(digits, 12)
h = Enum.at(digits, 13)
rem(total, 11) == g * 10 + h
5 ->
# Exception 5: check digit is g (position 12)
remainder = rem(total, 11)
g = Enum.at(digits, 12)
cond do
remainder == 0 and g == 0 -> true
remainder == 1 -> false
11 - remainder == g -> true
true -> false
end
_ ->
rem(total, 11) == 0
end
end
defp run_check(:dblal, digits, weights, _exception) do
# Double Alternate (Luhn-like algorithm)
total =
Enum.zip(digits, weights)
|> Enum.map(fn {d, w} ->
product = d * w
sum_digits(product)
end)
|> Enum.sum()
rem(total, 10) == 0
end
# Calculate weighted sum
defp weighted_sum(digits, weights, exception) do
case exception do
1 ->
# Exception 1: Add 27 to the total
base_sum =
Enum.zip(digits, weights)
|> Enum.map(fn {d, w} -> d * w end)
|> Enum.sum()
base_sum + 27
_ ->
Enum.zip(digits, weights)
|> Enum.map(fn {d, w} -> d * w end)
|> Enum.sum()
end
end
# Sum the digits of a number (for DBLAL)
defp sum_digits(n) when n < 10, do: n
defp sum_digits(n), do: rem(n, 10) + div(n, 10)
end