defmodule QRNBU.Validators.IBAN do
@moduledoc """
Validates Ukrainian IBAN (International Bank Account Number) using the iban_ex library.
This validator wraps the iban_ex library (developed by the same author) to provide
NBU-specific validation and extraction functions for Ukrainian IBANs.
## Specification
Ukrainian IBAN format (29 characters):
- Positions 1-2: Country code "UA"
- Positions 3-4: Check digits (calculated using MOD-97 algorithm)
- Positions 5-10: Bank code (MFO - 6 digits)
- Positions 11-29: Account number (19 characters)
## Validation Rules
1. Must be exactly 29 characters
2. Must start with "UA"
3. Must contain only alphanumeric characters
4. Check digits must be valid (MOD-97 algorithm per ISO 13616)
## Examples
iex> QRNBU.Validators.IBAN.validate("UA303348510000026206114040874")
{:ok, "UA303348510000026206114040874"}
iex> QRNBU.Validators.IBAN.validate("ua303348510000026206114040874")
{:ok, "UA303348510000026206114040874"} # Normalized to uppercase
iex> QRNBU.Validators.IBAN.validate("PL60102010260000042270201111")
{:error, "IBAN must be for Ukraine (country code UA)"}
iex> QRNBU.Validators.IBAN.validate("UA30334851")
{:error, "IBAN must be exactly 29 characters for Ukraine"}
## References
- NBU Resolution No. 97, August 19, 2025
- ISO 13616:2020 IBAN standard
- Uses iban_ex library: https://hex.pm/packages/iban_ex
"""
alias IbanEx.Parser
@country_code "UA"
@required_length 29
@doc """
Validates Ukrainian IBAN format and checksum using iban_ex library.
The IBAN is automatically normalized (trimmed and uppercased) before validation.
Returns `{:ok, normalized_iban}` if valid, `{:error, reason}` otherwise.
"""
@spec validate(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def validate(iban) when is_binary(iban) do
normalized = iban |> String.trim() |> String.upcase()
case Parser.parse(normalized) do
{:ok, %{country_code: @country_code, iban: validated_iban}} ->
{:ok, validated_iban}
{:ok, %{country_code: other_code}} ->
{:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}
{:error, :invalid_checksum} ->
{:error, "Invalid IBAN checksum"}
{:error, :invalid_length} ->
{:error, "IBAN must be exactly #{@required_length} characters for Ukraine"}
{:error, :invalid_format} ->
{:error, "IBAN contains invalid characters"}
{:error, :unsupported_country_code} ->
{:error, "Unsupported country code"}
{:error, reason} when is_atom(reason) ->
{:error, "IBAN validation failed: #{reason}"}
{:error, reason} ->
{:error, to_string(reason)}
end
end
def validate(_), do: {:error, "IBAN must be a string"}
@doc """
Validates and extracts bank code (MFO) from Ukrainian IBAN.
Uses iban_ex parsing to extract the 6-digit bank identifier code.
## Examples
iex> QRNBU.Validators.IBAN.extract_bank_code("UA303348510000026206114040874")
{:ok, "334851"}
"""
@spec extract_bank_code(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def extract_bank_code(iban) when is_binary(iban) do
normalized = iban |> String.trim() |> String.upcase()
case Parser.parse(normalized) do
{:ok, %{country_code: @country_code, bank_code: bank_code}} ->
{:ok, bank_code}
{:ok, %{country_code: other_code}} ->
{:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
@doc """
Validates and extracts account number from Ukrainian IBAN.
Uses iban_ex parsing to extract the 19-character account number.
## Examples
iex> QRNBU.Validators.IBAN.extract_account_number("UA303348510000026206114040874")
{:ok, "0000026206114040874"}
"""
@spec extract_account_number(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def extract_account_number(iban) when is_binary(iban) do
normalized = iban |> String.trim() |> String.upcase()
case Parser.parse(normalized) do
{:ok, %{country_code: @country_code, account_number: account_number}} ->
{:ok, account_number}
{:ok, %{country_code: other_code}} ->
{:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
@doc """
Parses a Ukrainian IBAN into its components using iban_ex.
Returns a map with all IBAN components: iban, country_code, check_digits,
bank_code, and account_number.
## Examples
iex> QRNBU.Validators.IBAN.parse("UA303348510000026206114040874")
{:ok, %{
iban: "UA303348510000026206114040874",
country_code: "UA",
check_digits: "30",
bank_code: "334851",
account_number: "0000026206114040874"
}}
"""
@spec parse(String.t()) :: {:ok, map()} | {:error, String.t()}
def parse(iban) when is_binary(iban) do
normalized = iban |> String.trim() |> String.upcase()
case Parser.parse(normalized) do
{:ok, %{country_code: @country_code} = parsed} ->
result = %{
iban: parsed.iban,
country_code: parsed.country_code,
check_digits: parsed.check_digits,
bank_code: parsed.bank_code,
account_number: parsed.account_number
}
{:ok, result}
{:ok, %{country_code: other_code}} ->
{:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}
{:error, reason} ->
{:error, format_error(reason)}
end
end
# Format error messages
defp format_error(:invalid_checksum), do: "Invalid IBAN checksum"
defp format_error(:invalid_length),
do: "IBAN must be exactly #{@required_length} characters for Ukraine"
defp format_error(:invalid_format), do: "IBAN contains invalid characters"
defp format_error(:unsupported_country_code), do: "Unsupported country code"
defp format_error(reason) when is_atom(reason),
do: "IBAN validation failed: #{reason}"
defp format_error(reason), do: to_string(reason)
end