defmodule QRNBU.Validators.Amount do
@moduledoc """
Validates monetary amounts according to NBU QR code specifications.
## Rules
- Amount must be positive (minimum 0.01)
- Maximum amount: 999,999,999.99 UAH
- Exactly 2 decimal places
- Accepts multiple input formats: Decimal, integer (kopiykas), float, string
## Examples
iex> QRNBU.Validators.Amount.validate(Decimal.new("100.50"))
{:ok, #Decimal<100.50>}
iex> QRNBU.Validators.Amount.validate(10050)
{:ok, #Decimal<100.50>}
iex> QRNBU.Validators.Amount.validate("100.50")
{:ok, #Decimal<100.50>}
iex> QRNBU.Validators.Amount.validate(0)
{:error, "Amount must be at least 0.01"}
iex> QRNBU.Validators.Amount.validate("100.123")
{:error, "Amount must have at most 2 decimal places"}
"""
@min_amount Decimal.new("0.01")
@max_amount Decimal.new("999999999.99")
@type amount_input :: Decimal.t() | integer() | float() | String.t()
@doc """
Validates and normalizes a monetary amount.
All numeric inputs are treated as **hryvnias** (UAH):
| Input | Result | Description |
|-------|--------|-------------|
| `10` | 10 UAH | Integer as hryvnias |
| `100.50` | 100 UAH 50 kop | Float with kopiykas |
| `"100,5"` | 100 UAH 50 kop | String with comma separator |
Accepts multiple input types:
- `Decimal.t()` - used directly
- `integer()` - treated as hryvnias (e.g., 100 = 100 UAH)
- `float()` - converted to Decimal (e.g., 100.50 = 100 UAH 50 kop)
- `String.t()` - parsed to Decimal (supports both `.` and `,` as decimal separator)
Returns `{:ok, Decimal.t()}` with normalized amount or `{:error, String.t()}`.
"""
@spec validate(amount_input()) :: {:ok, Decimal.t()} | {:error, String.t()}
def validate(amount) when is_integer(amount) do
# Integer is treated as hryvnias (whole UAH units)
amount
|> Decimal.new()
|> validate()
end
def validate(amount) when is_float(amount) do
amount
|> Decimal.from_float()
|> validate()
end
def validate(amount) when is_binary(amount) do
# Trim whitespace and normalize comma to dot as decimal separator
normalized =
amount
|> String.trim()
|> String.replace(",", ".")
case Decimal.parse(normalized) do
{decimal, ""} -> validate(decimal)
{_decimal, _rest} -> {:error, "Invalid amount format: contains non-numeric characters"}
:error -> {:error, "Invalid amount format: cannot parse as number"}
end
end
def validate(%Decimal{} = amount) do
with :ok <- validate_positive(amount),
:ok <- validate_minimum(amount),
:ok <- validate_maximum(amount),
:ok <- validate_precision(amount) do
{:ok, amount}
end
end
def validate(_), do: {:error, "Amount must be a Decimal, integer, float, or string"}
@spec validate_positive(Decimal.t()) :: :ok | {:error, String.t()}
defp validate_positive(amount) do
if Decimal.positive?(amount) do
:ok
else
{:error, "Amount must be positive"}
end
end
@spec validate_minimum(Decimal.t()) :: :ok | {:error, String.t()}
defp validate_minimum(amount) do
if Decimal.compare(amount, @min_amount) != :lt do
:ok
else
{:error, "Amount must be at least 0.01"}
end
end
@spec validate_maximum(Decimal.t()) :: :ok | {:error, String.t()}
defp validate_maximum(amount) do
if Decimal.compare(amount, @max_amount) != :gt do
:ok
else
{:error, "Amount cannot exceed 999,999,999.99"}
end
end
@spec validate_precision(Decimal.t()) :: :ok | {:error, String.t()}
defp validate_precision(amount) do
# Multiply by 100 and check if result is an integer
multiplied = Decimal.mult(amount, Decimal.new(100))
if Decimal.integer?(multiplied) do
:ok
else
{:error, "Amount must have at most 2 decimal places"}
end
end
@doc """
Converts a validated amount to its kopiykas (cents) representation.
## Examples
iex> amount = Decimal.new("100.50")
iex> QRNBU.Validators.Amount.to_kopiykas(amount)
10050
"""
@spec to_kopiykas(Decimal.t()) :: integer()
def to_kopiykas(%Decimal{} = amount) do
amount
|> Decimal.mult(100)
|> Decimal.to_integer()
end
@doc """
Formats an amount as a string with exactly 2 decimal places.
## Examples
iex> amount = Decimal.new("100.5")
iex> QRNBU.Validators.Amount.format(amount)
"100.50"
iex> amount = Decimal.new("1000")
iex> QRNBU.Validators.Amount.format(amount)
"1000.00"
"""
@spec format(Decimal.t()) :: String.t()
def format(%Decimal{} = amount) do
Decimal.to_string(amount, :normal)
|> ensure_two_decimals()
end
defp ensure_two_decimals(str) do
case String.split(str, ".") do
[integer_part] ->
"#{integer_part}.00"
[integer_part, decimal_part] when byte_size(decimal_part) == 1 ->
"#{integer_part}.#{decimal_part}0"
[integer_part, decimal_part] ->
"#{integer_part}.#{decimal_part}"
end
end
end