defmodule QRNBU do
@moduledoc """
NBU (National Bank of Ukraine) QR Code generator library.
This library provides a comprehensive API for generating NBU-compliant QR codes
for payment systems in Ukraine. It supports all three official NBU QR code versions:
- **V001**: Plain text format with CRLF line endings (EPC QR code compatible)
- **V002**: Base64URL encoded format with 13 fields
- **V003**: Extended Base64URL format with 17 fields (ISO 20022 category purpose)
## Quick Start
# Generate a simple V001 QR code
iex> {:ok, qr} = QRNBU.generate(:v001, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
iex> String.contains?(qr, "BCD")
true
# Generate a V002 QR code with amount
iex> {:ok, qr} = QRNBU.generate(:v002, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів",
...> amount: Decimal.new("100.50")
...> })
iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
true
# Generate a V003 QR code with extended fields
iex> {:ok, qr} = QRNBU.generate(:v003, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів",
...> amount: Decimal.new("500.00"),
...> category_purpose: "SUPP/REGU"
...> })
iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
true
## Version Selection
Choose the appropriate version based on your requirements:
- Use **V001** for compatibility with EPC QR codes and simple payments
- Use **V002** for modern NBU-compliant payments with Base64URL encoding
- Use **V003** for advanced features like ISO 20022 category purpose and field locking
## Field Reference
### Common Fields (All Versions)
- `:recipient` (required) - Recipient name (1-70 characters)
- `:iban` (required) - Bank account IBAN (UA + 27 digits)
- `:recipient_code` (required) - EDRPOU/IPN/Tax ID (6-35 characters)
- `:purpose` (required) - Payment purpose (1-140 characters)
- `:amount` (optional) - Payment amount as `Decimal.t()`
- `:function` (optional) - Function code: `:uct`, `:ict`, or `:xct` (default: `:uct`)
- `:encoding` (optional) - Character encoding: `:utf8` or `:cp1251` (default: `:utf8`)
### V002 Additional Fields
- `:reference` (optional) - Payment reference number (max 35 characters)
### V003 Additional Fields
- `:reference` (optional) - Payment reference number (max 35 characters)
- `:unique_recipient_id` (optional) - Unique recipient identifier (max 35 characters)
- `:category_purpose` (optional) - ISO 20022 category purpose (format: `CCCC/PPPP`)
- `:display` (optional) - Display text for QR scanner (max 140 characters)
- `:field_lock` (optional) - Field lock bitmap `0x0000-0xFFFF` (integer)
- `:invoice_validity` (optional) - Invoice expiration as `NaiveDateTime.t()`
- `:invoice_creation` (optional) - Invoice creation as `NaiveDateTime.t()`
- `:digital_signature` (optional) - Digital signature string (max 1000 characters)
## Error Handling
All functions return `{:ok, result}` or `{:error, reason}` tuples.
Common errors:
- Missing required fields
- Invalid field formats (IBAN, tax ID, etc.)
- Invalid character encoding
- Date/time validation failures
## References
- NBU Resolution No. 97, August 19, 2025
- EPC QR Code Guidelines v3.0
- ISO 20022 External Code Sets
"""
alias QRNBU.Versions.{V001, V002, V003}
@type version :: :v001 | :v002 | :v003
@type qr_data :: map()
@type qr_string :: String.t()
@doc """
Generates an NBU QR code string for the specified version.
## Parameters
- `version` - QR code version: `:v001`, `:v002`, or `:v003`
- `data` - Map containing QR code fields (see module documentation)
## Returns
- `{:ok, qr_string}` - Successfully generated QR code string
- `{:error, reason}` - Validation or encoding error
## Examples
iex> {:ok, qr} = QRNBU.generate(:v001, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
iex> is_binary(qr)
true
iex> {:ok, qr} = QRNBU.generate(:v002, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів",
...> amount: Decimal.new("100.50"),
...> reference: "INV-001"
...> })
iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
true
iex> QRNBU.generate(:invalid_version, %{})
{:error, "Invalid version: :invalid_version. Must be :v001, :v002, or :v003"}
iex> {:error, reason} = QRNBU.generate(:v001, %{})
iex> is_binary(reason)
true
"""
@spec generate(version(), qr_data()) :: {:ok, qr_string()} | {:error, String.t()}
def generate(version, data)
def generate(:v001, data) when is_map(data) do
with {:ok, v001} <- V001.new(data),
{:ok, qr_string} <- V001.encode(v001) do
{:ok, qr_string}
end
end
def generate(:v002, data) when is_map(data) do
with {:ok, v002} <- V002.new(data),
{:ok, qr_string} <- V002.encode(v002) do
{:ok, qr_string}
end
end
def generate(:v003, data) when is_map(data) do
with {:ok, v003} <- V003.new(data),
{:ok, qr_string} <- V003.encode(v003) do
{:ok, qr_string}
end
end
def generate(version, _data) when version not in [:v001, :v002, :v003] do
{:error, "Invalid version: #{inspect(version)}. Must be :v001, :v002, or :v003"}
end
def generate(_version, data) do
{:error, "Data must be a map, got: #{inspect(data)}"}
end
@doc """
Generates an NBU QR code string, raising an exception on error.
Same as `generate/2` but raises `RuntimeError` instead of returning an error tuple.
## Examples
iex> qr = QRNBU.generate!(:v001, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
iex> is_binary(qr) and String.contains?(qr, "BCD")
true
"""
@spec generate!(version(), qr_data()) :: qr_string()
def generate!(version, data) do
case generate(version, data) do
{:ok, qr_string} -> qr_string
{:error, reason} -> raise RuntimeError, reason
end
end
@doc """
Detects the version of an NBU QR code string.
Analyzes the QR code format to determine which version it represents.
## Returns
- `{:ok, :v001}` - Plain text format with BCD marker
- `{:ok, :v002}` - Base64URL with NBU URL prefix
- `{:ok, :v003}` - Base64URL with NBU URL prefix (distinguished by field count)
- `{:error, reason}` - Unable to detect version
## Examples
iex> v001_str = String.duplicate(" ", 23) <> "\\r\\nBCD\\r\\n001\\r\\n"
iex> QRNBU.detect_version(v001_str)
{:ok, :v001}
iex> QRNBU.detect_version("invalid")
{:error, "Unable to detect QR code version"}
"""
@spec detect_version(qr_string()) :: {:ok, version()} | {:error, String.t()}
def detect_version(qr_string) when is_binary(qr_string) do
cond do
# V001: Plain text with BCD marker
String.contains?(qr_string, "\r\nBCD\r\n") ->
{:ok, :v001}
# V002: Base64URL with new NBU URL prefix
String.starts_with?(qr_string, "https://qr.bank.gov.ua/") ->
detect_base64url_version(qr_string, "https://qr.bank.gov.ua/")
# V002/V003: Base64URL with legacy NBU URL prefix
String.starts_with?(qr_string, "https://qr.bank.gov.ua/") ->
detect_base64url_version(qr_string, "https://qr.bank.gov.ua/")
true ->
{:error, "Unable to detect QR code version"}
end
end
def detect_version(_), do: {:error, "QR string must be a binary"}
# Helper: Distinguish between V002 and V003 by field count
defp detect_base64url_version(qr_string, prefix) do
with base64_part <- String.replace_prefix(qr_string, prefix, ""),
{:ok, decoded} <- QRNBU.Encoders.Base64URL.decode(base64_part) do
fields = String.split(decoded, "\n")
field_count = length(fields)
case field_count do
13 -> {:ok, :v002}
17 -> {:ok, :v003}
_ -> {:error, "Unknown Base64URL format with #{field_count} fields"}
end
else
{:error, _} -> {:error, "Invalid Base64URL encoding"}
end
end
@doc """
Validates NBU QR code data without generating the QR string.
Useful for pre-validation before QR code generation.
## Parameters
- `version` - QR code version: `:v001`, `:v002`, or `:v003`
- `data` - Map containing QR code fields
## Returns
- `:ok` - Data is valid for the specified version
- `{:error, reason}` - Validation error
## Examples
iex> QRNBU.validate(:v001, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
:ok
iex> QRNBU.validate(:v001, %{})
{:error, "Missing required field: recipient"}
iex> QRNBU.validate(:v003, %{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів",
...> field_lock: -1
...> })
{:error, "Field lock must be between 0 and 65535"}
"""
@spec validate(version(), qr_data()) :: :ok | {:error, String.t()}
def validate(:v001, data) when is_map(data) do
case V001.new(data) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
def validate(:v002, data) when is_map(data) do
case V002.new(data) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
def validate(:v003, data) when is_map(data) do
case V003.new(data) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
def validate(version, _data) when version not in [:v001, :v002, :v003] do
{:error, "Invalid version: #{inspect(version)}. Must be :v001, :v002, or :v003"}
end
def validate(_version, data) do
{:error, "Data must be a map, got: #{inspect(data)}"}
end
end