defmodule QRNBU.Validators.Recipient do
@moduledoc """
Validates recipient name/description according to NBU QR code specifications.
## Rules
- V001/V002: Maximum 70 characters
- V003: Maximum 140 characters
- Cannot be empty or contain only whitespace
- Should contain valid printable characters
## Examples
iex> QRNBU.Validators.Recipient.validate("ТОВ Приватбанк", version: 2)
{:ok, "ТОВ Приватбанк"}
iex> QRNBU.Validators.Recipient.validate("Фізична особа-підприємець Іваненко Іван Іванович", version: 3)
{:ok, "Фізична особа-підприємець Іваненко Іван Іванович"}
iex> QRNBU.Validators.Recipient.validate("", version: 2)
{:error, "Recipient cannot be empty"}
iex> long_name = String.duplicate("А", 71)
iex> QRNBU.Validators.Recipient.validate(long_name, version: 2)
{:error, "Recipient exceeds maximum length of 70 characters for version 2"}
"""
@max_length_v1_v2 70
@max_length_v3 140
@type version :: 1 | 2 | 3
@type options :: [version: version()]
@doc """
Validates recipient name/description for NBU QR code.
Options:
- `version`: QR code version (1, 2, or 3). Defaults to 2.
Returns `{:ok, trimmed_recipient}` or `{:error, reason}`.
"""
@spec validate(String.t(), options()) :: {:ok, String.t()} | {:error, String.t()}
def validate(recipient, opts \\ [])
def validate(recipient, opts) when is_binary(recipient) do
version = Keyword.get(opts, :version, 2)
trimmed = String.trim(recipient)
with :ok <- validate_not_empty(trimmed),
:ok <- validate_length(trimmed, version),
:ok <- validate_printable(trimmed) do
{:ok, trimmed}
end
end
def validate(_, _), do: {:error, "Recipient must be a string"}
@doc """
Gets the maximum length for recipient based on version.
## Examples
iex> QRNBU.Validators.Recipient.max_length(1)
70
iex> QRNBU.Validators.Recipient.max_length(2)
70
iex> QRNBU.Validators.Recipient.max_length(3)
140
"""
@spec max_length(version()) :: pos_integer()
def max_length(version) when version in [1, 2], do: @max_length_v1_v2
def max_length(3), do: @max_length_v3
@spec validate_not_empty(String.t()) :: :ok | {:error, String.t()}
defp validate_not_empty(""), do: {:error, "Recipient cannot be empty"}
defp validate_not_empty(_), do: :ok
@spec validate_length(String.t(), version()) :: :ok | {:error, String.t()}
defp validate_length(recipient, version) do
max = max_length(version)
length = String.length(recipient)
if length <= max do
:ok
else
{:error, "Recipient exceeds maximum length of #{max} characters for version #{version}"}
end
end
@spec validate_printable(String.t()) :: :ok | {:error, String.t()}
defp validate_printable(recipient) do
# Check for ASCII control characters (codepoints U+0000-U+001F, U+007F-U+009F)
# We check each codepoint directly since Elixir regex doesn't support \uXXXX
has_control_char? =
recipient
|> String.to_charlist()
|> Enum.any?(fn codepoint ->
(codepoint >= 0x0000 and codepoint <= 0x001F) or
(codepoint >= 0x007F and codepoint <= 0x009F)
end)
if has_control_char? do
{:error, "Recipient contains invalid control characters"}
else
:ok
end
end
@doc """
Truncates recipient to maximum length for the specified version.
Useful for gracefully handling slightly too-long input.
## Examples
iex> long_name = String.duplicate("А", 80)
iex> QRNBU.Validators.Recipient.truncate(long_name, version: 2)
String.slice(long_name, 0, 69)
iex> QRNBU.Validators.Recipient.truncate("Short name", version: 2)
"Short name"
"""
@spec truncate(String.t(), options()) :: String.t()
def truncate(recipient, opts \\ []) when is_binary(recipient) do
version = Keyword.get(opts, :version, 2)
max = max_length(version)
recipient
|> String.trim()
|> String.slice(0, max - 1)
end
@doc """
Normalizes recipient name by trimming and removing extra whitespace.
## Examples
iex> QRNBU.Validators.Recipient.normalize(" ТОВ Приватбанк ")
"ТОВ Приватбанк"
"""
@spec normalize(String.t()) :: String.t()
def normalize(recipient) when is_binary(recipient) do
recipient
|> String.trim()
|> String.replace(~r/\s+/, " ")
end
end