defmodule QRNBU.Validators.Purpose do
@moduledoc """
Validates payment purpose/description according to NBU QR code specifications.
## Rules
- V001/V002: Maximum 140 characters
- V003: Maximum 280 characters
- Cannot be empty or contain only whitespace
- Should contain valid printable characters
## Examples
iex> QRNBU.Validators.Purpose.validate("Оплата за товари згідно рахунку №123", version: 2)
{:ok, "Оплата за товари згідно рахунку №123"}
iex> QRNBU.Validators.Purpose.validate("Payment for services rendered under contract", version: 3)
{:ok, "Payment for services rendered under contract"}
iex> QRNBU.Validators.Purpose.validate("", version: 2)
{:error, "Purpose cannot be empty"}
iex> long_purpose = String.duplicate("Х", 141)
iex> QRNBU.Validators.Purpose.validate(long_purpose, version: 2)
{:error, "Purpose exceeds maximum length of 140 characters for version 2"}
"""
@max_length_v1_v2 140
@max_length_v3 280
@type version :: 1 | 2 | 3
@type options :: [version: version()]
@doc """
Validates payment purpose/description for NBU QR code.
Options:
- `version`: QR code version (1, 2, or 3). Defaults to 2.
Returns `{:ok, trimmed_purpose}` or `{:error, reason}`.
"""
@spec validate(String.t(), options()) :: {:ok, String.t()} | {:error, String.t()}
def validate(purpose, opts \\ [])
def validate(purpose, opts) when is_binary(purpose) do
version = Keyword.get(opts, :version, 2)
trimmed = String.trim(purpose)
with :ok <- validate_not_empty(trimmed),
:ok <- validate_length(trimmed, version),
:ok <- validate_printable(trimmed) do
{:ok, trimmed}
end
end
def validate(_, _), do: {:error, "Purpose must be a string"}
@doc """
Gets the maximum length for purpose based on version.
## Examples
iex> QRNBU.Validators.Purpose.max_length(1)
140
iex> QRNBU.Validators.Purpose.max_length(2)
140
iex> QRNBU.Validators.Purpose.max_length(3)
280
"""
@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, "Purpose cannot be empty"}
defp validate_not_empty(_), do: :ok
@spec validate_length(String.t(), version()) :: :ok | {:error, String.t()}
defp validate_length(purpose, version) do
max = max_length(version)
length = String.length(purpose)
if length <= max do
:ok
else
{:error, "Purpose exceeds maximum length of #{max} characters for version #{version}"}
end
end
@spec validate_printable(String.t()) :: :ok | {:error, String.t()}
defp validate_printable(purpose) 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? =
purpose
|> 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, "Purpose contains invalid control characters"}
else
:ok
end
end
@doc """
Truncates purpose to maximum length for the specified version.
Useful for gracefully handling slightly too-long input.
## Examples
iex> long_purpose = String.duplicate("А", 150)
iex> QRNBU.Validators.Purpose.truncate(long_purpose, version: 2)
String.slice(long_purpose, 0, 139)
iex> QRNBU.Validators.Purpose.truncate("Short purpose", version: 2)
"Short purpose"
"""
@spec truncate(String.t(), options()) :: String.t()
def truncate(purpose, opts \\ [])
def truncate(purpose, opts) when is_binary(purpose) do
version = Keyword.get(opts, :version, 2)
max = max_length(version)
purpose
|> String.trim()
|> String.slice(0, max - 1)
end
@doc """
Normalizes purpose by trimming and removing extra whitespace.
## Examples
iex> QRNBU.Validators.Purpose.normalize(" Payment for goods ")
"Payment for goods"
"""
@spec normalize(String.t()) :: String.t()
def normalize(purpose) when is_binary(purpose) do
purpose
|> String.trim()
|> String.replace(~r/\s+/, " ")
end
@doc """
Validates and suggests if a purpose might need a category purpose code (V003).
Returns `{:ok, purpose}` or `{:warning, purpose, suggestion}` for V003.
"""
@spec validate_with_suggestion(String.t(), options()) ::
{:ok, String.t()} | {:warning, String.t(), String.t()} | {:error, String.t()}
def validate_with_suggestion(purpose, opts \\ [])
def validate_with_suggestion(purpose, opts) do
version = Keyword.get(opts, :version, 2)
case validate(purpose, opts) do
{:ok, validated} ->
if version == 3 and should_have_category?(validated) do
{:warning, validated,
"Consider adding a category purpose code (ISO 20022) for structured payment reference"}
else
{:ok, validated}
end
error ->
error
end
end
# Check if purpose looks like it should have a structured category code
defp should_have_category?(purpose) do
# Check for keywords that suggest structured payments
structured_keywords = [
"рахунок",
"invoice",
"contract",
"договір",
"salary",
"зарплата",
"pension",
"пенсія"
]
purpose_lower = String.downcase(purpose)
Enum.any?(structured_keywords, &String.contains?(purpose_lower, &1))
end
end