defmodule QRNBU.FieldLock do
@moduledoc """
Human-readable field lock bitmask builder for NBU QR Code V003.
Field lock controls which fields can be edited by the payer when processing
a QR code payment. Setting a bit to 1 prevents the payer from modifying that field.
## Quick Start
# Lock all fields except amount
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount]) |> FieldLock.to_integer()
65279
# Get preset for fixed-amount payments
iex> alias QRNBU.FieldLock
iex> FieldLock.preset(:fixed_payment) |> FieldLock.to_integer()
65535
# Build custom lock
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.lock([:recipient, :iban]) |> FieldLock.to_integer()
51455
## NBU Requirement
Per NBU specification (Додаток 4, розділ IV, пункт 14), fields 1-5, 11, 14-17
must ALWAYS be locked. This module automatically enforces this requirement.
## Field Reference
| Field # | Name | Editable | Description |
|---------|---------------------|----------|--------------------------------|
| 1 | service_mark | No | BCD marker |
| 2 | format_version | No | Version (003) |
| 3 | encoding | No | Character encoding |
| 4 | function | No | UCT/ICT/XCT |
| 5 | unique_recipient_id | No | Reserved |
| 6 | recipient | Yes | Recipient name |
| 7 | iban | Yes | Account number |
| 8 | amount | Yes | Payment amount |
| 9 | recipient_code | Yes | EDRPOU/Tax ID |
| 10 | category_purpose | Yes | ISO 20022 category |
| 11 | reference | No | Invoice reference |
| 12 | purpose | Yes | Payment purpose text |
| 13 | display | Yes | Display text |
| 14 | field_lock | No | This field |
| 15 | invoice_validity | No | Expiration datetime |
| 16 | invoice_creation | No | Creation datetime |
"""
import Bitwise
@enforce_keys [:value]
defstruct [:value]
# Bit positions for each field
# Per NBU spec: "Цей номер відповідає номеру біта (починаючи з наймолодшого)"
# Field number N corresponds to bit N in the bitmask.
# Example from spec: all_except(amount) = 0xFEFF, which is 0xFFFF with bit 8 cleared.
# So field 8 (amount) = bit 8, meaning field N = bit N.
# Note: Field 16 uses bit 0 since bit 16 would overflow 16-bit range.
@field_bits %{
# Field 1 = bit 1
service_mark: 1,
# Field 2 = bit 2
format_version: 2,
# Field 3 = bit 3
encoding: 3,
# Field 4 = bit 4
function: 4,
# Field 5 = bit 5
unique_recipient_id: 5,
# Field 6 = bit 6
recipient: 6,
# Field 7 = bit 7
iban: 7,
# Field 8 = bit 8
amount: 8,
# Field 9 = bit 9
recipient_code: 9,
# Field 10 = bit 10
category_purpose: 10,
# Field 11 = bit 11
reference: 11,
# Field 12 = bit 12
purpose: 12,
# Field 13 = bit 13
display: 13,
# Field 14 = bit 14
field_lock: 14,
# Field 15 = bit 15
invoice_validity: 15,
# Field 16 = bit 0 (wraps since bit 16 overflows)
invoice_creation: 0
}
# Fields that MUST always be locked per NBU spec (1-5, 11, 14-17)
# Note: Field 17 (digital_signature) is beyond 16 bits, handled separately
@mandatory_fields [
:service_mark,
:format_version,
:encoding,
:function,
:unique_recipient_id,
:reference,
:field_lock,
:invoice_validity,
:invoice_creation
]
# Pre-calculated mandatory bits
# Per NBU spec, fields 1-5, 11, 14-16 must always be locked.
# Field N = bit N, except field 16 = bit 0 (overflow wrap).
# Bits: 0 (field 16), 1-5 (fields 1-5), 11 (field 11), 14-15 (fields 14-15)
# = 1 + 2 + 4 + 8 + 16 + 32 + 2048 + 16384 + 32768 = 51263 = 0xC83F
@mandatory_bits 0xC83F
# Fields that CAN be unlocked (editable by payer)
@editable_fields [
:recipient,
:iban,
:amount,
:recipient_code,
:category_purpose,
:purpose,
:display
]
# All field bits set = 0xFFFF
@all_bits 0xFFFF
@type t :: %__MODULE__{value: non_neg_integer()}
@type field ::
:service_mark
| :format_version
| :encoding
| :function
| :unique_recipient_id
| :recipient
| :iban
| :amount
| :recipient_code
| :category_purpose
| :reference
| :purpose
| :display
| :field_lock
| :invoice_validity
| :invoice_creation
@type preset_name ::
:minimal | :fixed_payment | :editable_amount | :editable_purpose | :flexible
# ============================================================================
# Constructors
# ============================================================================
@doc """
Creates a new FieldLock with only mandatory fields locked.
This is the most permissive valid configuration - all editable fields
can be modified by the payer.
## Examples
iex> alias QRNBU.FieldLock
iex> fl = FieldLock.new()
iex> FieldLock.to_integer(fl)
51263
iex> alias QRNBU.FieldLock
iex> fl = FieldLock.new()
iex> FieldLock.to_hex(fl)
"C83F"
"""
@spec new() :: t()
def new, do: %__MODULE__{value: @mandatory_bits}
@doc """
Creates a FieldLock with ALL fields locked.
Equivalent to `0xFFFF`. The payer cannot modify any field.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.to_integer()
65535
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.to_hex()
"FFFF"
"""
@spec all() :: t()
def all, do: %__MODULE__{value: @all_bits}
@doc """
Creates a FieldLock from an existing integer value.
Returns `{:ok, field_lock}` if valid, or `{:error, reason}` if invalid.
The value must be between 0 and 65535, and mandatory fields will be
automatically locked if not already set.
## Examples
iex> alias QRNBU.FieldLock
iex> {:ok, fl} = FieldLock.from_integer(0xFEFF)
iex> FieldLock.to_integer(fl)
65279
iex> alias QRNBU.FieldLock
iex> FieldLock.from_integer(70000)
{:error, "Value must be between 0 and 65535"}
iex> alias QRNBU.FieldLock
iex> {:ok, fl} = FieldLock.from_integer(0)
iex> FieldLock.to_integer(fl)
51263
"""
@spec from_integer(integer()) :: {:ok, t()} | {:error, String.t()}
def from_integer(value) when is_integer(value) and value >= 0 and value <= @all_bits do
# Ensure mandatory bits are always set
{:ok, %__MODULE__{value: value ||| @mandatory_bits}}
end
def from_integer(value) when is_integer(value) do
{:error, "Value must be between 0 and 65535"}
end
def from_integer(_), do: {:error, "Value must be an integer"}
@doc """
Creates a FieldLock from an existing integer value, raising on error.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.from_integer!(0xFEFF) |> FieldLock.to_hex()
"FEFF"
"""
@spec from_integer!(integer()) :: t()
def from_integer!(value) do
case from_integer(value) do
{:ok, fl} -> fl
{:error, reason} -> raise ArgumentError, reason
end
end
# ============================================================================
# Convenience Builders
# ============================================================================
@doc """
Creates a FieldLock with all fields locked EXCEPT the specified ones.
Mandatory fields remain locked regardless of the input.
This is the most common way to create a field lock for payments
where you want to allow the payer to edit specific fields.
## Examples
# Allow only amount to be edited
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount]) |> FieldLock.to_integer()
65279
# Allow amount and purpose to be edited
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount, :purpose]) |> FieldLock.to_integer()
61183
# Attempting to unlock mandatory fields has no effect
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:service_mark]) |> FieldLock.to_integer()
65535
"""
@spec all_except([field()]) :: t()
def all_except(fields) when is_list(fields) do
all() |> unlock(fields)
end
@doc """
Creates a FieldLock with only mandatory fields locked, then adds the specified ones.
Use this when you want maximum payer flexibility but need to lock
specific additional fields.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.only([:recipient, :iban]) |> FieldLock.to_integer()
51455
iex> alias QRNBU.FieldLock
iex> FieldLock.only([]) |> FieldLock.to_integer()
51263
"""
@spec only([field()]) :: t()
def only(fields) when is_list(fields) do
new() |> lock(fields)
end
# ============================================================================
# Presets
# ============================================================================
@doc """
Returns a preset FieldLock for common payment scenarios.
## Available Presets
- `:minimal` - Only mandatory fields locked (maximum payer flexibility)
- `:fixed_payment` - All fields locked (fixed invoice, no changes allowed)
- `:editable_amount` - All except amount locked (common for variable payments)
- `:editable_purpose` - All except purpose locked
- `:flexible` - Mandatory + recipient info locked; amount, purpose, display editable
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.preset(:fixed_payment) |> FieldLock.to_hex()
"FFFF"
iex> alias QRNBU.FieldLock
iex> FieldLock.preset(:editable_amount) |> FieldLock.to_hex()
"FEFF"
iex> alias QRNBU.FieldLock
iex> FieldLock.preset(:minimal) |> FieldLock.to_hex()
"C83F"
"""
@spec preset(preset_name()) :: t()
def preset(:minimal), do: new()
def preset(:fixed_payment), do: all()
def preset(:editable_amount), do: all_except([:amount])
def preset(:editable_purpose), do: all_except([:purpose])
def preset(:flexible) do
# Lock recipient info, allow amount/purpose/display to be edited
new() |> lock([:recipient, :iban, :recipient_code, :category_purpose])
end
# ============================================================================
# Field Operations
# ============================================================================
@doc """
Locks the specified field(s) (adds to existing locks).
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.lock(:amount) |> FieldLock.to_integer()
51519
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.lock([:amount, :recipient]) |> FieldLock.to_integer()
51583
"""
@spec lock(t(), field() | [field()]) :: t()
def lock(%__MODULE__{} = fl, fields) when is_list(fields) do
Enum.reduce(fields, fl, &lock(&2, &1))
end
def lock(%__MODULE__{value: value} = fl, field) when is_atom(field) do
case Map.get(@field_bits, field) do
nil -> fl
bit -> %{fl | value: value ||| 1 <<< bit}
end
end
@doc """
Unlocks the specified field(s) (removes from locks).
Cannot unlock mandatory fields - they remain locked regardless.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.unlock(:amount) |> FieldLock.to_integer()
65279
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.unlock([:amount, :purpose]) |> FieldLock.to_integer()
61183
# Cannot unlock mandatory fields
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.unlock(:service_mark) |> FieldLock.to_integer()
65535
"""
@spec unlock(t(), field() | [field()]) :: t()
def unlock(%__MODULE__{} = fl, fields) when is_list(fields) do
Enum.reduce(fields, fl, &unlock(&2, &1))
end
def unlock(%__MODULE__{value: value} = fl, field) when is_atom(field) do
# Cannot unlock mandatory fields
if field in @mandatory_fields do
fl
else
case Map.get(@field_bits, field) do
nil -> fl
bit -> %{fl | value: value &&& ~~~(1 <<< bit)}
end
end
end
@doc """
Checks if a specific field is locked.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.locked?(:amount)
false
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.locked?(:service_mark)
true
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.locked?(:amount)
true
"""
@spec locked?(t(), field()) :: boolean()
def locked?(%__MODULE__{value: value}, field) when is_atom(field) do
case Map.get(@field_bits, field) do
nil -> false
bit -> (value &&& 1 <<< bit) != 0
end
end
@doc """
Checks if a specific field is unlocked (editable).
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.unlocked?(:amount)
true
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.unlocked?(:amount)
false
"""
@spec unlocked?(t(), field()) :: boolean()
def unlocked?(%__MODULE__{} = fl, field), do: not locked?(fl, field)
# ============================================================================
# Conversion
# ============================================================================
@doc """
Converts FieldLock to integer for use in QR data.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.to_integer()
65535
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.to_integer()
51263
"""
@spec to_integer(t()) :: non_neg_integer()
def to_integer(%__MODULE__{value: value}), do: value
@doc """
Converts FieldLock to uppercase hex string (without 0x prefix).
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.to_hex()
"FFFF"
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.to_hex()
"C83F"
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount]) |> FieldLock.to_hex()
"FEFF"
"""
@spec to_hex(t()) :: String.t()
def to_hex(%__MODULE__{value: value}) do
value
|> Integer.to_string(16)
|> String.pad_leading(4, "0")
end
# ============================================================================
# Introspection
# ============================================================================
@doc """
Returns list of all currently locked fields.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.locked_fields()
[:invoice_creation, :service_mark, :format_version, :encoding, :function, :unique_recipient_id, :reference, :field_lock, :invoice_validity]
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.locked_fields() |> length()
16
"""
@spec locked_fields(t()) :: [field()]
def locked_fields(%__MODULE__{} = fl) do
@field_bits
|> Map.keys()
|> Enum.filter(&locked?(fl, &1))
|> Enum.sort_by(&Map.get(@field_bits, &1))
end
@doc """
Returns list of all currently unlocked (editable) fields.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.unlocked_fields()
[:recipient, :iban, :amount, :recipient_code, :category_purpose, :purpose, :display]
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.unlocked_fields()
[]
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount]) |> FieldLock.unlocked_fields()
[:amount]
"""
@spec unlocked_fields(t()) :: [field()]
def unlocked_fields(%__MODULE__{} = fl) do
@field_bits
|> Map.keys()
|> Enum.reject(&locked?(fl, &1))
|> Enum.sort_by(&Map.get(@field_bits, &1))
end
@doc """
Returns a human-readable description of the field lock.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.all() |> FieldLock.describe()
"0xFFFF: All fields locked"
iex> alias QRNBU.FieldLock
iex> FieldLock.new() |> FieldLock.describe()
"0xC83F: Editable fields: recipient, iban, amount, recipient_code, category_purpose, purpose, display"
iex> alias QRNBU.FieldLock
iex> FieldLock.all_except([:amount]) |> FieldLock.describe()
"0xFEFF: Editable fields: amount"
"""
@spec describe(t()) :: String.t()
def describe(%__MODULE__{} = fl) do
hex = "0x" <> to_hex(fl)
unlocked = unlocked_fields(fl)
case unlocked do
[] ->
"#{hex}: All fields locked"
fields ->
field_names = fields |> Enum.map(&Atom.to_string/1) |> Enum.join(", ")
"#{hex}: Editable fields: #{field_names}"
end
end
@doc """
Returns list of all available field names.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.fields() |> length()
16
"""
@spec fields() :: [field()]
def fields, do: Map.keys(@field_bits) |> Enum.sort_by(&Map.get(@field_bits, &1))
@doc """
Returns list of mandatory (always-locked) field names.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.mandatory_fields()
[:invoice_creation, :service_mark, :format_version, :encoding, :function, :unique_recipient_id, :reference, :field_lock, :invoice_validity]
"""
@spec mandatory_fields() :: [field()]
def mandatory_fields, do: @mandatory_fields |> Enum.sort_by(&Map.get(@field_bits, &1))
@doc """
Returns list of editable field names.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.editable_fields()
[:recipient, :iban, :amount, :recipient_code, :category_purpose, :purpose, :display]
"""
@spec editable_fields() :: [field()]
def editable_fields, do: @editable_fields |> Enum.sort_by(&Map.get(@field_bits, &1))
@doc """
Returns the bit position for a given field name.
## Examples
iex> alias QRNBU.FieldLock
iex> FieldLock.bit_position(:amount)
8
iex> alias QRNBU.FieldLock
iex> FieldLock.bit_position(:service_mark)
1
iex> alias QRNBU.FieldLock
iex> FieldLock.bit_position(:unknown)
nil
"""
@spec bit_position(field()) :: non_neg_integer() | nil
def bit_position(field), do: Map.get(@field_bits, field)
# ============================================================================
# Protocol Implementations
# ============================================================================
defimpl Inspect do
def inspect(%QRNBU.FieldLock{value: value}, _opts) do
hex = value |> Integer.to_string(16) |> String.pad_leading(4, "0")
"#QRNBU.FieldLock<0x#{hex}>"
end
end
defimpl String.Chars do
def to_string(%QRNBU.FieldLock{} = fl) do
QRNBU.FieldLock.to_hex(fl)
end
end
end