defmodule QRNBU.Encoders.Formatter do
@moduledoc """
Formats NBU QR code data strings for different versions.
Handles the specific format requirements for each version:
- **V001**: Plain text format with CRLF line endings
- **V002**: Base64URL format with prefix
- **V003**: Base64URL format with prefix (extended fields)
## NBU QR Code Prefixes
- V002: `https://qr.bank.gov.ua/`
- V003: `https://qr.bank.gov.ua/`
"""
alias QRNBU.Encoders.{Charset, Base64URL}
@v001_prefix String.duplicate(" ", 23)
@v002_prefix "https://qr.bank.gov.ua/"
@v003_prefix "https://qr.bank.gov.ua/"
@crlf "\r\n"
@lf "\n"
@type version :: 1 | 2 | 3
@type encoding :: :utf8 | :cp1251
@type function_code :: :uct | :ict | :xct
@doc """
Formats QR data for V001 (plain text with CRLF).
## Format Structure
```
[23 spaces]\\r\\n
BCD\\r\\n
001\\r\\n
1\\r\\n
UCT\\r\\n
\\r\\n
{recipient}\\r\\n
{iban}\\r\\n
{amount with UAH prefix}\\r\\n
{recipient_code}\\r\\n
\\r\\n
\\r\\n
{purpose}\\r\\n
\\r\\n
```
## Parameters
- `data` - Map with fields: recipient, iban, amount, recipient_code, purpose, function
- `opts` - Options (encoding)
## Returns
`{:ok, formatted_string}` or `{:error, reason}`.
"""
@spec format_v001(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def format_v001(data, opts \\ []) do
# V001 MUST use UTF-8 encoding (code "1") per NBU spec
# V001 MUST use UCT function code per NBU spec
_encoding = Keyword.get(opts, :encoding, :utf8)
with {:ok, recipient, _} <- Charset.encode(data.recipient, :utf8),
{:ok, purpose, _} <- Charset.encode(data.purpose, :utf8) do
lines = [
@v001_prefix,
"BCD",
"001",
"1",
"UCT",
"",
recipient,
data.iban,
format_amount(data[:amount]),
data.recipient_code,
"",
"",
purpose,
""
]
result = Enum.join(lines, @crlf)
{:ok, result}
end
end
@doc """
Formats QR data for V002 (Base64URL with prefix).
## Format Structure
```
https://qr.bank.gov.ua/{base64url_encoded_data}
```
## Data String Structure
Fields separated by LF (0x0A):
1. Service label (BCD)
2. Version format (002)
3. Encoding code (1 or 2)
4. Function code (UCT only)
5. Empty (reserved)
6. Recipient name
7. IBAN
8. Amount (optional, with UAH prefix)
9. Recipient code (EDRPOU/Tax ID)
10. Empty (reserved)
11. Empty (reserved)
12. Purpose
13. Display (optional)
## Parameters
- `data` - Map with fields: recipient, iban, amount, recipient_code, purpose, function
- `opts` - Options (encoding)
## Returns
`{:ok, qr_string}` or `{:error, reason}`.
"""
@spec format_v002(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def format_v002(data, opts \\ []) do
# V002 MUST use UCT function code per NBU spec
encoding = Keyword.get(opts, :encoding, :utf8)
with {:ok, recipient, enc_code} <- Charset.encode(data.recipient, encoding),
{:ok, purpose, _} <- Charset.encode(data.purpose, encoding) do
# Optional text fields
display = maybe_encode(data[:display], encoding)
fields = [
"BCD",
"002",
"#{enc_code}",
"UCT",
"",
recipient,
data.iban,
format_amount(data[:amount]),
data.recipient_code,
"",
"",
purpose,
display || ""
]
data_string = Enum.join(fields, @lf)
encoded = Base64URL.encode(data_string)
{:ok, @v002_prefix <> encoded}
end
end
@doc """
Formats QR data for V003 (Base64URL with prefix and extended fields).
## Format Structure
```
https://qr.bank.gov.ua/{base64url_encoded_data}
```
## Data String Structure (17 fields)
Fields separated by LF (0x0A):
1. Service label (BCD)
2. Version format (003)
3. Encoding code (1 or 2)
4. Function code (UCT/ICT/XCT)
5. Empty (reserved)
6. Recipient name
7. IBAN
8. Amount (optional, with UAH prefix)
9. Recipient code (EDRPOU/Tax ID)
10. Category/Purpose (ISO 20022)
11. Reference (optional)
12. Purpose
13. Display text (optional)
14. Field lock (optional, hex)
15. Invoice validity datetime (optional)
16. Invoice creation datetime (optional)
17. Digital signature (reserved for future)
## Parameters
- `data` - Map with all V003 fields
- `opts` - Options (encoding)
## Returns
`{:ok, qr_string}` or `{:error, reason}`.
"""
@spec format_v003(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def format_v003(data, opts \\ []) do
encoding = Keyword.get(opts, :encoding, :utf8)
function_code = data[:function] || :uct
with {:ok, recipient, enc_code} <- Charset.encode(data.recipient, encoding),
{:ok, purpose, _} <- Charset.encode(data.purpose, encoding) do
# Optional text fields
category_purpose = maybe_encode(data[:category_purpose], encoding)
display = maybe_encode(data[:display], encoding)
reference = maybe_encode(data[:reference], encoding)
fields = [
"BCD",
"003",
"#{enc_code}",
function_code_string(function_code),
"",
recipient,
data.iban,
format_amount(data[:amount]),
data.recipient_code,
category_purpose || "",
reference || "",
purpose,
display || "",
format_field_lock(data[:field_lock]),
format_datetime(data[:invoice_validity]),
format_datetime(data[:invoice_creation]),
data[:digital_signature] || ""
]
data_string = Enum.join(fields, @lf)
encoded = Base64URL.encode(data_string)
{:ok, @v003_prefix <> encoded}
end
end
# Helper: Convert function code atom to string
@spec function_code_string(function_code()) :: String.t()
defp function_code_string(:uct), do: "UCT"
defp function_code_string(:ict), do: "ICT"
defp function_code_string(:xct), do: "XCT"
# Helper: Format amount as string with UAH currency prefix (removes trailing zeros)
@spec format_amount(Decimal.t() | nil) :: String.t()
defp format_amount(nil), do: ""
defp format_amount(%Decimal{} = amount) do
# NBU spec: "Перед сумою повинні розміщуватися три великі літери коду валюти"
# NBU spec: "Сума має бути якомога коротшою відповідно до результуючого коду"
# - If no kopiykas: no decimal point (e.g., "UAH100" not "UAH100.00")
# - If kopiykas exist: exactly 2 digits (e.g., "UAH100.50" not "UAH100.5")
str = Decimal.to_string(amount, :normal)
formatted_number =
case String.split(str, ".") do
[integer_part] ->
# No decimal point, return as-is
integer_part
[integer_part, "00"] ->
# No kopiykas, remove decimal point
integer_part
[integer_part, "0"] ->
# No kopiykas, remove decimal point
integer_part
[integer_part, decimal_part] when byte_size(decimal_part) == 1 ->
# Single digit kopiykas, pad to 2 digits (e.g., "100.5" → "100.50")
"#{integer_part}.#{decimal_part}0"
[integer_part, decimal_part] ->
# Two digit kopiykas, keep as-is
"#{integer_part}.#{decimal_part}"
end
"UAH#{formatted_number}"
end
# Helper: Format field lock as 4-digit hex
# Accepts both integer values and QRNBU.FieldLock structs
@spec format_field_lock(non_neg_integer() | QRNBU.FieldLock.t() | nil) :: String.t()
defp format_field_lock(nil), do: ""
defp format_field_lock(%QRNBU.FieldLock{} = field_lock) do
field_lock
|> QRNBU.FieldLock.to_integer()
|> format_field_lock()
end
defp format_field_lock(value) when is_integer(value) and value >= 0 and value <= 0xFFFF do
value
|> Integer.to_string(16)
|> String.pad_leading(4, "0")
|> String.upcase()
end
# Helper: Format datetime as YYMMDDHHMMSS
@spec format_datetime(NaiveDateTime.t() | nil) :: String.t()
defp format_datetime(nil), do: ""
defp format_datetime(%NaiveDateTime{} = dt) do
year = rem(dt.year, 100) |> Integer.to_string() |> String.pad_leading(2, "0")
month = dt.month |> Integer.to_string() |> String.pad_leading(2, "0")
day = dt.day |> Integer.to_string() |> String.pad_leading(2, "0")
hour = dt.hour |> Integer.to_string() |> String.pad_leading(2, "0")
minute = dt.minute |> Integer.to_string() |> String.pad_leading(2, "0")
second = dt.second |> Integer.to_string() |> String.pad_leading(2, "0")
"#{year}#{month}#{day}#{hour}#{minute}#{second}"
end
# Helper: Encode optional text field
@spec maybe_encode(String.t() | nil, encoding()) :: String.t() | nil
defp maybe_encode(nil, _encoding), do: nil
defp maybe_encode("", _encoding), do: ""
defp maybe_encode(text, encoding) do
case Charset.encode(text, encoding) do
{:ok, encoded, _} -> encoded
{:error, _} -> ""
end
end
end