defmodule QRNBU.Versions.V003 do
@moduledoc """
NBU QR Code Version 003 structure with validation and encoding.
V003 is the extended Base64URL format with 17 fields including category purpose,
field lock, and invoice timestamps.
## Fields
### Required Fields
- `:recipient` - Name of the recipient (validated by RecipientValidator)
- `:iban` - Bank account IBAN (validated by IBANValidator)
- `:recipient_code` - Recipient's identification code (validated by RecipientCodeValidator)
- `:purpose` - Payment purpose description (validated by PurposeValidator)
### Optional Fields
- `:amount` - Payment amount as Decimal (validated by AmountValidator)
- `:reference` - Payment reference number (validated by ReferenceValidator)
- `:unique_recipient_id` - Unique recipient identifier (validated by UniqueRecipientIDValidator)
- `:category_purpose` - Categorized purpose of payment (validated by CategoryPurposeValidator)
- `:display` - Display text for the QR code (validated by DisplayValidator)
- `:field_lock` - Field edit control bitmap as integer 0x0000-0xFFFF (validated by FieldLockValidator)
- `:invoice_validity` - Invoice expiration datetime (validated by InvoiceValidityValidator)
- `:invoice_creation` - Invoice creation datetime (validated by InvoiceCreationValidator)
- `:digital_signature` - Digital signature string (validated by DigitalSignatureValidator)
- `:function` - Payment function code: `:uct`, `:ict`, or `:xct` (default: `:uct`)
- `:encoding` - Character encoding: `:utf8` or `:cp1251` (default: `:utf8`)
## Examples
iex> QRNBU.Versions.V003.new(%{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
{:ok, %QRNBU.Versions.V003{...}}
iex> QRNBU.Versions.V003.new(%{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> amount: Decimal.new("100.50"),
...> recipient_code: "12345678",
...> purpose: "Оплата товарів",
...> category_purpose: "Товари",
...> field_lock: 0x1234,
...> invoice_validity: ~N[2025-12-31 23:59:59],
...> invoice_creation: ~N[2025-01-08 10:30:00]
...> })
{:ok, %QRNBU.Versions.V003{...}}
"""
alias QRNBU.Validators.{
Recipient,
IBAN,
RecipientCode,
Purpose,
Amount,
Reference,
UniqueRecipientID,
CategoryPurpose,
Display,
FieldLock,
InvoiceValidity,
InvoiceCreation,
DigitalSignature,
FunctionCode
}
alias QRNBU.Encoders.Formatter
@type t :: %__MODULE__{
recipient: String.t(),
iban: String.t(),
recipient_code: String.t(),
purpose: String.t(),
amount: Decimal.t() | nil,
reference: String.t() | nil,
unique_recipient_id: String.t() | nil,
category_purpose: String.t() | nil,
display: String.t() | nil,
field_lock: non_neg_integer() | nil,
invoice_validity: NaiveDateTime.t() | nil,
invoice_creation: NaiveDateTime.t() | nil,
digital_signature: String.t() | nil,
function: :uct | :ict | :xct,
encoding: :utf8 | :cp1251
}
@enforce_keys [:recipient, :iban, :recipient_code, :purpose]
defstruct [
:recipient,
:iban,
:recipient_code,
:purpose,
:amount,
:reference,
:unique_recipient_id,
:category_purpose,
:display,
:field_lock,
:invoice_validity,
:invoice_creation,
:digital_signature,
function: :uct,
encoding: :utf8
]
@doc """
Creates a new V003 QR code structure with validation.
## Parameters
- `attrs` - Map with required and optional fields
## Returns
- `{:ok, %QRNBU.Versions.V003{}}` - Valid V003 structure
- `{:error, String.t()}` - Validation error message
## Examples
iex> QRNBU.Versions.V003.new(%{
...> recipient: "ТОВ Компанія",
...> iban: "UA213223130000026007233566001",
...> recipient_code: "12345678",
...> purpose: "Оплата товарів"
...> })
{:ok, %QRNBU.Versions.V003{}}
iex> QRNBU.Versions.V003.new(%{
...> recipient: "",
...> iban: "invalid",
...> recipient_code: "123",
...> purpose: ""
...> })
{:error, "Recipient name is required"}
"""
@spec new(map()) :: {:ok, t()} | {:error, String.t()}
def new(attrs) when is_map(attrs) do
with :ok <- validate_required_fields(attrs),
{:ok, validated_attrs} <- validate_fields(attrs) do
struct = struct(__MODULE__, validated_attrs)
{:ok, struct}
end
end
@doc """
Encodes a V003 structure into the NBU QR code data string.
Returns the Base64URL encoded format with https://qr.bank.gov.ua/ prefix.
## Parameters
- `v003` - V003 structure to encode
## Returns
- `{:ok, String.t()}` - Encoded QR data string
- `{:error, String.t()}` - Encoding error
## Examples
iex> {:ok, v003} = QRNBU.Versions.V003.new(%{...})
iex> QRNBU.Versions.V003.encode(v003)
{:ok, "https://qr.bank.gov.ua/MQpVQ1QKVUFICgo..."}
"""
@spec encode(t()) :: {:ok, String.t()} | {:error, String.t()}
def encode(%__MODULE__{} = v003) do
data = %{
recipient: v003.recipient,
iban: v003.iban,
recipient_code: v003.recipient_code,
purpose: v003.purpose,
amount: v003.amount,
reference: v003.reference,
unique_recipient_id: v003.unique_recipient_id,
category_purpose: v003.category_purpose,
display: v003.display,
field_lock: v003.field_lock,
invoice_validity: v003.invoice_validity,
invoice_creation: v003.invoice_creation,
digital_signature: v003.digital_signature,
function: v003.function
}
opts = [encoding: v003.encoding]
Formatter.format_v003(data, opts)
end
# Private Functions
defp validate_required_fields(attrs) do
required = [:recipient, :iban, :recipient_code, :purpose]
missing =
Enum.filter(required, fn key ->
not Map.has_key?(attrs, key) or is_nil(Map.get(attrs, key))
end)
case missing do
[] -> :ok
[field | _] -> {:error, "Missing required field: #{field}"}
end
end
defp validate_fields(attrs) do
encoding = Map.get(attrs, :encoding, :utf8)
with {:ok, recipient} <- Recipient.validate(attrs.recipient, encoding: encoding),
{:ok, iban} <- IBAN.validate(attrs.iban),
{:ok, recipient_code} <- RecipientCode.validate(attrs.recipient_code),
{:ok, purpose} <- Purpose.validate(attrs.purpose, encoding: encoding),
{:ok, amount} <- validate_optional_amount(Map.get(attrs, :amount)),
{:ok, reference} <- validate_optional_reference(Map.get(attrs, :reference)),
{:ok, unique_recipient_id} <-
validate_optional_unique_recipient_id(Map.get(attrs, :unique_recipient_id)),
{:ok, category_purpose} <-
validate_optional_category_purpose(Map.get(attrs, :category_purpose), encoding),
{:ok, display} <- validate_optional_display(Map.get(attrs, :display), encoding),
{:ok, field_lock} <- validate_optional_field_lock(Map.get(attrs, :field_lock)),
{:ok, invoice_validity} <-
validate_optional_invoice_validity(Map.get(attrs, :invoice_validity)),
{:ok, invoice_creation} <-
validate_optional_invoice_creation(Map.get(attrs, :invoice_creation)),
{:ok, digital_signature} <-
validate_optional_digital_signature(Map.get(attrs, :digital_signature)),
{:ok, function} <- validate_function(Map.get(attrs, :function, :uct)),
{:ok, encoding} <- validate_encoding(encoding) do
validated = %{
recipient: recipient,
iban: iban,
recipient_code: recipient_code,
purpose: purpose,
amount: amount,
reference: reference,
unique_recipient_id: unique_recipient_id,
category_purpose: category_purpose,
display: display,
field_lock: field_lock,
invoice_validity: invoice_validity,
invoice_creation: invoice_creation,
digital_signature: digital_signature,
function: function,
encoding: encoding
}
{:ok, validated}
end
end
defp validate_optional_amount(nil), do: {:ok, nil}
defp validate_optional_amount(amount), do: Amount.validate(amount)
defp validate_optional_reference(nil), do: {:ok, nil}
defp validate_optional_reference(reference), do: Reference.validate(reference)
defp validate_optional_unique_recipient_id(nil), do: {:ok, nil}
defp validate_optional_unique_recipient_id(id),
do: UniqueRecipientID.validate(id)
defp validate_optional_category_purpose(nil, _encoding), do: {:ok, nil}
defp validate_optional_category_purpose(category, encoding),
do: CategoryPurpose.validate(category, encoding: encoding)
defp validate_optional_display(nil, _encoding), do: {:ok, nil}
defp validate_optional_display(display, encoding),
do: Display.validate(display, encoding: encoding)
defp validate_optional_field_lock(nil), do: {:ok, nil}
defp validate_optional_field_lock(lock), do: FieldLock.validate(lock)
defp validate_optional_invoice_validity(nil), do: {:ok, nil}
defp validate_optional_invoice_validity(datetime),
do: InvoiceValidity.validate(datetime)
defp validate_optional_invoice_creation(nil), do: {:ok, nil}
defp validate_optional_invoice_creation(datetime),
do: InvoiceCreation.validate(datetime)
defp validate_optional_digital_signature(nil), do: {:ok, nil}
defp validate_optional_digital_signature(signature),
do: DigitalSignature.validate(signature)
defp validate_function(function), do: FunctionCode.validate(function)
defp validate_encoding(encoding) when encoding in [:utf8, :cp1251] do
{:ok, encoding}
end
defp validate_encoding(encoding) do
{:error, "Invalid encoding: #{inspect(encoding)}. Must be :utf8 or :cp1251"}
end
end