ADD IBAN FIELD TO STRUCTS AND UPDATE PARSING LOGIC

- Added `iban` field to the `IbanEx.Iban` struct to hold the full IBAN value. - Updated the parsing logic to populate
the new field. - Adjusted BBAN rules and tests for France and Brazil to reflect updated structures. - Improved error
handling and format validation routines.
This commit is contained in:
2025-11-30 11:04:09 -05:00
parent d197a86454
commit 763e1dba0c
21 changed files with 156 additions and 76 deletions

View File

@@ -15,6 +15,7 @@ In just a few letters and numbers, the IBAN captures all of the country, bank, a
```elixir
iex> "FI2112345600000785" |> IbanEx.Parser.parse()
{:ok, %IbanEx.Iban{
iban: "FI2112345600000785",
country_code: "FI",
check_digits: "21",
bank_code: "123456",

View File

@@ -19,6 +19,7 @@ def normalize_and_slice(string, range) do
string
|> normalize()
|> String.slice(range)
# |> case do
# "" -> nil
# result -> result

View File

@@ -2,6 +2,17 @@ defmodule IbanEx.Country.BR do
@moduledoc """
Brazil IBAN parsing rules
According to SWIFT registry, Brazil BBAN structure is:
- Bank code: 8 digits
- Branch code: 5 digits
- Account code: 12 characters (10n + 1a + 1c) - account number + account type + owner type
BBAN spec: 8!n5!n10!n1!a1!c (total 25 chars)
Example: BR1800360305000010009795493C1
- Bank: 00360305
- Branch: 00001
- Account: 0009795493C1 (includes account number + type + owner)
## Examples
```elixir
@@ -10,18 +21,27 @@ defmodule IbanEx.Country.BR do
...> check_digits: "18",
...> bank_code: "00360305",
...> branch_code: "00001",
...> account_number: "0009795493",
...> national_check: "C1"
...> account_number: "0009795493C1",
...> national_check: nil
...> }
...> |> IbanEx.Country.BR.to_string()
"BR 18 00360305 00001 0009795493 C1"
"BR 18 00360305 00001 0009795493C1"
```
"""
@size 29
@rule ~r/^(?<bank_code>[0-9]{8})(?<branch_code>[0-9]{5})(?<account_number>[0-9]{10})(?<national_check>[A-Z]{1}[0-9A-Z]{1})$/i
@rule ~r/^(?<bank_code>[0-9]{8})(?<branch_code>[0-9]{5})(?<account_number>[0-9]{10}[A-Z]{1}[0-9A-Z]{1})$/i
use IbanEx.Country.Template
def rules() do
[
bank_code: %{regex: ~r/[0-9]{8}/i, range: 0..7},
branch_code: %{regex: ~r/[0-9]{5}/i, range: 8..12},
account_number: %{regex: ~r/[0-9]{10}[A-Z]{1}[0-9A-Z]{1}/i, range: 13..24}
]
end
@impl IbanEx.Country.Template
@spec to_string(Iban.t()) :: binary()
@spec to_string(Iban.t(), binary()) :: binary()
@@ -31,12 +51,12 @@ def to_string(
check_digits: check_digits,
bank_code: bank_code,
branch_code: branch_code,
account_number: account_number,
national_check: national_check
account_number: account_number
} = _iban,
joiner \\ " "
) do
[country_code, check_digits, bank_code, branch_code, account_number, national_check]
[country_code, check_digits, bank_code, branch_code, account_number]
|> Enum.reject(&is_nil/1)
|> Enum.join(joiner)
end
end

View File

@@ -2,6 +2,19 @@ defmodule IbanEx.Country.FR do
@moduledoc """
France IBAN parsing rules
According to SWIFT registry and Wise validation, France BBAN structure is:
- Bank code: 5 digits
- Branch code: 5 digits
- Account number: 11 alphanumeric characters
- National check: 2 digits
BBAN spec: 5!n5!n11!c2!n (total 23 chars)
Example: FR1420041010050500013M02606
- Bank: 20041
- Branch: 01005
- Account: 0500013M026
- National check: 06
## Examples
```elixir
@@ -10,8 +23,8 @@ defmodule IbanEx.Country.FR do
...> check_digits: "14",
...> bank_code: "20041",
...> branch_code: "01005",
...> national_check: "06",
...> account_number: "0500013M026"
...> account_number: "0500013M026",
...> national_check: "06"
...> }
...> |> IbanEx.Country.FR.to_string()
"FR 14 20041 01005 0500013M026 06"
@@ -23,6 +36,8 @@ defmodule IbanEx.Country.FR do
use IbanEx.Country.Template
alias IbanEx.Iban
@impl IbanEx.Country.Template
@spec to_string(Iban.t()) :: binary()
@spec to_string(Iban.t(), binary()) :: binary()
@@ -32,12 +47,13 @@ def to_string(
check_digits: check_digits,
bank_code: bank_code,
branch_code: branch_code,
national_check: national_check,
account_number: account_number
account_number: account_number,
national_check: national_check
} = _iban,
joiner \\ " "
) do
[country_code, check_digits, bank_code, branch_code, account_number, national_check]
|> Enum.reject(&is_nil/1)
|> Enum.join(joiner)
end
end

View File

@@ -22,6 +22,14 @@ defmodule IbanEx.Country.MU do
use IbanEx.Country.Template
def rules() do
[
bank_code: %{regex: ~r/[A-Z0-9]{6}/i, range: 0..5},
branch_code: %{regex: ~r/[0-9]{2}/i, range: 6..7},
account_number: %{regex: ~r/[0-9A-Z]{18}/i, range: 8..25}
]
end
@impl IbanEx.Country.Template
@spec to_string(Iban.t()) :: binary()
@spec to_string(Iban.t(), binary()) :: binary()

View File

@@ -23,4 +23,3 @@ defmodule IbanEx.Country.PS do
use IbanEx.Country.Template
end

View File

@@ -40,4 +40,3 @@ def to_string(
|> Enum.join(joiner)
end
end

View File

@@ -22,6 +22,14 @@ defmodule IbanEx.Country.SC do
use IbanEx.Country.Template
def rules() do
[
bank_code: %{regex: ~r/[A-Z0-9]{6}/i, range: 0..5},
branch_code: %{regex: ~r/[0-9]{2}/i, range: 6..7},
account_number: %{regex: ~r/[0-9A-Z]{19}/i, range: 8..26}
]
end
@impl IbanEx.Country.Template
@spec to_string(Iban.t()) :: binary()
@spec to_string(Iban.t(), binary()) :: binary()

View File

@@ -23,7 +23,6 @@ defmodule IbanEx.Country.SI do
use IbanEx.Country.Template
@impl IbanEx.Country.Template
@spec to_string(Iban.t()) :: binary()
@spec to_string(Iban.t(), binary()) :: binary()
@@ -41,5 +40,4 @@ def to_string(
[country_code, check_digits, bank_code, branch_code, account_number, national_check]
|> Enum.join(joiner)
end
end

View File

@@ -40,4 +40,3 @@ def to_string(
|> Enum.join(joiner)
end
end

View File

@@ -93,7 +93,7 @@ defp calculate_rules() do
{Enum.reverse(list), bban_length}
end
defoverridable to_string: 1, to_string: 2, size: 0, rule: 0
defoverridable to_string: 1, to_string: 2, size: 0, rule: 0, rules: 0
end
end
end

View File

@@ -27,7 +27,7 @@ defmodule IbanEx.Error do
:invalid_account_number,
:invalid_branch_code,
:invalid_national_check
]
]
@messages [
unsupported_country_code: "Unsupported country code",
@@ -40,8 +40,8 @@ defmodule IbanEx.Error do
invalid_bank_code: "Bank code violates required format",
invalid_account_number: "Account number violates required format",
invalid_branch_code: "Branch code violates required format",
invalid_national_check: "National check symbols violates required format",
]
invalid_national_check: "National check symbols violates required format"
]
@spec message(error()) :: String.t()
def message(error) when error in @errors, do: @messages[error]

View File

@@ -8,7 +8,7 @@ defmodule IbanEx.Formatter do
@type iban() :: IbanEx.Iban.t()
@type available_format() :: :compact | :pretty | :splitted
@type available_formats_list() :: [:compact | :pretty | :splitted ]
@type available_formats_list() :: [:compact | :pretty | :splitted]
@spec available_formats() :: available_formats_list()
def available_formats(), do: @available_formats
@@ -25,6 +25,7 @@ def splitted(iban), do: format(iban, :splitted)
@spec format(iban()) :: String.t()
@spec format(iban(), available_format()) :: String.t()
def format(iban, format \\ :compact)
def format(iban, :compact),
do: format(iban, :pretty) |> normalize()

View File

@@ -5,6 +5,7 @@ defmodule IbanEx.Iban do
alias IbanEx.{Serialize}
@type t :: %__MODULE__{
iban: String.t(),
country_code: <<_::16>>,
check_digits: String.t(),
bank_code: String.t(),
@@ -12,7 +13,13 @@ defmodule IbanEx.Iban do
national_check: String.t() | nil,
account_number: String.t()
}
defstruct country_code: "UA", check_digits: nil, bank_code: nil, branch_code: nil, national_check: nil, account_number: nil
defstruct iban: nil,
country_code: "UA",
check_digits: nil,
bank_code: nil,
branch_code: nil,
national_check: nil,
account_number: nil
@spec to_map(IbanEx.Iban.t()) :: map()
defdelegate to_map(iban), to: Serialize

View File

@@ -25,7 +25,10 @@ def parse(iban_string, options \\ [incomplete: false])
def parse(iban_string, incomplete: false) do
case Validator.validate(iban_string) do
{:ok, valid_iban} ->
normalized = normalize_and_slice(valid_iban, 0..-1//1)
iban_map = %{
iban: normalized,
country_code: country_code(valid_iban),
check_digits: check_digits(valid_iban)
}
@@ -43,7 +46,10 @@ def parse(iban_string, incomplete: false) do
end
def parse(iban_string, incomplete: true) do
normalized = normalize_and_slice(iban_string, 0..-1//1)
iban_map = %{
iban: normalized,
country_code: country_code(iban_string),
check_digits: check_digits(iban_string)
}
@@ -72,6 +78,7 @@ def parse_bban(bban_string, country_code, incomplete: true) do
country_code
|> Country.country_module()
|> parse_bban_by_country_rules(bban_string)
false ->
%{}
end
@@ -82,6 +89,7 @@ def parse_bban(bban_string, country_code, incomplete: false) do
true ->
Country.country_module(country_code).rule()
|> parse_bban_by_regex(bban_string)
false ->
%{}
end

View File

@@ -20,7 +20,7 @@ defp violation_functions(),
{&__MODULE__.iban_violates_account_number_format?/1, {:error, :invalid_account_number}},
{&__MODULE__.iban_violates_branch_code_format?/1, {:error, :invalid_branch_code}},
{&__MODULE__.iban_violates_national_check_format?/1, {:error, :invalid_national_check}},
{&__MODULE__.iban_violates_checksum?/1, {:error, :invalid_checksum}},
{&__MODULE__.iban_violates_checksum?/1, {:error, :invalid_checksum}}
]
@doc """
@@ -39,7 +39,17 @@ defp violation_functions(),
@spec violations(String.t()) :: [] | [atom()]
def violations(iban) do
violation_functions()
|> Enum.reduce([], fn {fun, value}, acc -> error_accumulator(acc, !fun.(iban) or value) end)
|> Enum.reduce([], fn {fun, value}, acc ->
# Special handling for length check to get specific :length_to_short or :length_to_long
if fun == (&__MODULE__.iban_violates_length?/1) do
case check_iban_length(iban) do
{:error, atom} when atom in [:length_to_short, :length_to_long] -> [atom | acc]
_ -> acc
end
else
error_accumulator(acc, !fun.(iban) or value)
end
end)
|> Enum.reverse()
end
@@ -92,9 +102,22 @@ defp size(iban) do
end
# - Check whether a given IBAN violates the required format.
@spec iban_violates_format?(String.t()) :: boolean
def iban_violates_format?(iban),
do: Regex.match?(~r/[^A-Z0-9]/i, normalize(iban))
@spec iban_violates_format?(String.t() | nil) :: boolean
def iban_violates_format?(nil), do: true
def iban_violates_format?(iban) when is_binary(iban) do
# Remove spaces first but don't uppercase yet
cleaned = String.replace(iban, ~r/\s/, "")
# Check that country code (first 2 chars) are uppercase only
country_code = String.slice(cleaned, 0..1)
country_code_lowercase = country_code != String.upcase(country_code)
# Check for invalid characters (after normalization)
normalized = normalize(iban)
has_invalid_chars = Regex.match?(~r/[^A-Z0-9]/, normalized)
has_invalid_chars or country_code_lowercase
end
# - Check whether a given IBAN violates the required format in bank_code.
@spec iban_violates_bank_code_format?(binary()) :: boolean
@@ -102,15 +125,18 @@ def iban_violates_bank_code_format?(iban), do: iban_violates_bban_part_format?(i
# - Check whether a given IBAN violates the required format in branch_code.
@spec iban_violates_branch_code_format?(binary()) :: boolean
def iban_violates_branch_code_format?(iban), do: iban_violates_bban_part_format?(iban, :branch_code)
def iban_violates_branch_code_format?(iban),
do: iban_violates_bban_part_format?(iban, :branch_code)
# - Check whether a given IBAN violates the required format in account_number.
@spec iban_violates_account_number_format?(binary()) :: boolean
def iban_violates_account_number_format?(iban), do: iban_violates_bban_part_format?(iban, :account_number)
def iban_violates_account_number_format?(iban),
do: iban_violates_bban_part_format?(iban, :account_number)
# - Check whether a given IBAN violates the required format in national_check.
@spec iban_violates_national_check_format?(binary()) :: boolean
def iban_violates_national_check_format?(iban), do: iban_violates_bban_part_format?(iban, :national_check)
def iban_violates_national_check_format?(iban),
do: iban_violates_bban_part_format?(iban, :national_check)
defp iban_violates_bban_part_format?(iban, part) do
with country <- Parser.country_code(iban),

View File

@@ -187,11 +187,12 @@ test "correctly calculates positions for Germany (simple structure)" do
test "correctly calculates positions for France (complex structure)" do
{:ok, iban} = Parser.parse("FR1420041010050500013M02606")
# BBAN: 20041010050500013M02606
# Per SWIFT registry and Wise validation, France structure is:
# BBAN: 20041010050500013M02606 (23 chars)
# Bank (5n): 20041
# Branch (5n): 01005
# Account (11c): 0500013M026
# Check (2n): 06
# National check (2n): 06
assert iban.bank_code == "20041"
assert iban.branch_code == "01005"
assert iban.account_number == "0500013M026"
@@ -276,7 +277,7 @@ test "handles IBANs from all length categories" do
29,
# Longest
33
]
]
Enum.each(length_samples, fn target_length ->
ibans = TestData.ibans_with(length: target_length)
@@ -361,7 +362,9 @@ test "parses all 53 SEPA country IBANs" do
end
test "parses French territories using FR rules" do
# French territories: GF, GP, MQ, RE, etc.
# French territories use FR as country code in IBAN, but are listed separately in registry
# Real IBANs for French territories start with "FR", not their territory code
# See: docs/international_wide_ibans/README.md - SEPA Countries Include Territories
french_territories = [
"GF",
"GP",
@@ -375,7 +378,7 @@ test "parses French territories using FR rules" do
"MF",
"PM",
"WF"
]
]
Enum.each(french_territories, fn territory ->
ibans = TestData.valid_ibans(country: territory)
@@ -383,8 +386,9 @@ test "parses French territories using FR rules" do
if length(ibans) > 0 do
iban = List.first(ibans)
assert {:ok, parsed} = Parser.parse(iban)
assert parsed.country_code == territory
# Should follow FR structure
# Territory IBANs use "FR" as the country code in the actual IBAN
assert parsed.country_code == "FR"
# Should follow FR structure (27 chars)
assert String.length(parsed.iban) == 27
end
end)
@@ -403,7 +407,12 @@ test "parsed IBANs match registry specifications" do
assert String.length(parsed.iban) == spec["iban_length"],
"Length mismatch for #{country_code}"
assert parsed.country_code == country_code
# Extract actual country code from iban_spec (e.g., "FI2!n..." -> "FI")
# Territories like AX use parent country code (FI) in actual IBANs
expected_country_code = String.slice(spec["iban_spec"], 0..1)
assert parsed.country_code == expected_country_code,
"Country code mismatch for #{country_code}: expected #{expected_country_code}, got #{parsed.country_code}"
end)
end

View File

@@ -97,12 +97,10 @@ test "parsing valid IBANs from available countries returns {:ok, %IbanEx.Iban{}}
test "parsing invalid IBANs from unavailable countries returns {:error, :unsupported_country_code}" do
invalid_ibans =
[
# Fake country codes
"SD3112000000198742637541",
# Fake country codes (removed SD, GF, AX, BY, DJ, HN, IQ - now supported)
"SU56263300012039086",
"ZZ9121000418450200051332",
"FU4550000000058398257466",
"GF9300762011623852957",
"FX380080012345678910157",
"RT330006100519786457841326",
"UL213223130000026007233566001",
@@ -110,47 +108,29 @@ test "parsing invalid IBANs from unavailable countries returns {:error, :unsuppo
"FF29NWBK60161331926819",
"VV59001123000012345678",
"GV96VPVG0000012345678901",
# Unsupported now by library
"AA0096VPVG0000012345",
"AO213223130000026",
"AX00213223130000026007",
"BF3112000000198742637541375",
"BI31120000001987",
"BJ31120000001987426375413750",
"BL3112000000198742637541375",
"BY31120000001987426375413754",
"CF3112000000198742637541375",
"CG3112000000198742637541375",
"CI31120000001987426375413750",
"CM3112000000198742637541375",
"CV31120000001987426375413",
"DJ3112000000198742637541375",
"DZ3112000000198742637541",
"GA3112000000198742637541375",
"GF3112000000198742637541375",
"GP3112000000198742637541375",
"GQ3112000000198742637541375",
"GW31120000001987426375413",
"HN31120000001987426375413759",
"IQ311200000019874263754",
"IR311200000019874263754137",
"KM3112000000198742637541375",
"LC311200000019874263754",
"MA31120000001987426375413750",
"MF3112000000198742637541375",
"MG3112000000198742637541375",
"ML31120000001987426375413750",
"MQ3112000000198742637541375",
"MU3112000000198742637541375000",
"MZ31120000001987426375413",
"NC3112000000198742637541375",
"NE31120000001987426375413750",
"NI311200000019874263754137500000",
"PF3112000000198742637541375",
"PM3112000000198742637541375",
"PS311200000019874263754137500",
"RE3112000000198742637541375",
"SC311200000019874263754137500000",
"SN31120000001987426375413750",
"ST31120000001987426375413",
"TD3112000000198742637541375",

View File

@@ -1,7 +1,7 @@
defmodule IbanExTest do
use ExUnit.Case, async: true
doctest_file "README.md"
doctest_file("README.md")
doctest IbanEx.Country.AD
doctest IbanEx.Country.AE
doctest IbanEx.Country.AL

View File

@@ -106,7 +106,7 @@ test "Check Account number format negative cases" do
# shorter then need and has
# invalid characters (leters) in number
{"BR18003603050000100097CC1", true},
{"CR050152020010262806Ї", true},
{"CR050152020010262806Ї", true}
# FIXME it is invalid IBAN for Bulgaria — need to change a rules function in Country Template module
# {"BG80BNBG9661102034567Ї", true},
]