lib/qr_nbu/validators/amount.ex

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