lib/money/input/validator.ex

defmodule Money.Input.Validator do
  @moduledoc """
  Business-rule validation for `Money.t/0` values.

  ## Why this is separate from `Money.Input.Cast`

  `Money.Input.Cast.cast/2` answers the question *"can I turn
  this user input into a Money?"* — shape and parse validation
  only. It rejects missing currencies and unparseable amounts.

  This module answers the question *"is this Money acceptable
  under my business rules?"* — bounds (`:min`/`:max`),
  required-ness, an enforced currency, and currency-aware
  fractional-digit precision (USD ≤ 2, JPY ≤ 0, BHD ≤ 3).
  `Money.new(:JPY, "1.5")` is shape-valid but business-invalid
  for most apps.

  Both pieces are usually wired through the `Ecto.Changeset`
  helpers in `Money.Input.Changeset` — `cast_money/3` then
  `validate_money/3`.

  """

  alias Money.Input.ValidationError

  @doc """
  Validates a `Money.t/0` against bounds, precision, and
  required-ness.

  ### Arguments

  * `value` is a `Money.t/0` or `nil`.

  * `options` is a keyword list of options.

  ### Options

  * `:required` — when `true`, `nil` is rejected.

  * `:min` — minimum allowed value. A `Money.t/0`, or any value
    `Money.new/2` accepts (e.g. a `Decimal` or string) — when
    the latter, the bound takes the value's currency.

  * `:max` — maximum allowed value. Same shape options as
    `:min`.

  * `:currency` — when set, enforces that the value's currency
    matches.

  ### Returns

  * `:ok` when every check passes.

  * `{:error, %Money.Input.ValidationError{errors: [{atom(),
    String.t()}]}}` with one entry per failing check, in the
    order `:required`, `:currency`, `:decimals`, `:min`, `:max`.
    `Money.Input.Changeset.validate_money/3` unpacks the entries
    into per-field changeset errors.

  ### Examples

      iex> Money.Input.Validator.validate_money(Money.new(:USD, "1234.56"), max: Money.new(:USD, "10000"))
      :ok

      iex> {:error, %Money.Input.ValidationError{errors: errors}} =
      ...>   Money.Input.Validator.validate_money(Money.new(:USD, "1.234"), max: Money.new(:USD, "10000"))
      iex> errors
      [{:decimals, "must have at most 2 fractional digits"}]

      iex> {:error, %Money.Input.ValidationError{errors: errors}} =
      ...>   Money.Input.Validator.validate_money(Money.new(:USD, 1), currency: :EUR)
      iex> errors
      [{:currency, "must be EUR"}]

  """
  @spec validate_money(term(), Keyword.t()) :: :ok | {:error, ValidationError.t()}
  def validate_money(value, options \\ []) do
    errors =
      []
      |> check_required(value, options)
      |> check_currency(value, options)
      |> check_money_precision(value)
      |> check_money_range(value, options)
      |> Enum.reverse()

    if errors == [], do: :ok, else: {:error, ValidationError.exception(errors: errors)}
  end

  defp check_required(errors, nil, options) do
    if Keyword.get(options, :required, false) do
      [{:required, "is required"} | errors]
    else
      errors
    end
  end

  defp check_required(errors, _value, _options), do: errors

  defp check_currency(errors, %Money{currency: code}, options) do
    case Keyword.get(options, :currency) do
      nil -> errors
      ^code -> errors
      expected -> [{:currency, "must be #{expected}"} | errors]
    end
  end

  defp check_currency(errors, _value, _options), do: errors

  defp check_money_precision(errors, %Money{currency: code, amount: amount}) do
    iso_digits =
      case Money.Currency.currency_for_code(code) do
        {:ok, %{iso_digits: digits}} -> digits
        _ -> nil
      end

    if iso_digits && decimal_places(amount) > iso_digits do
      [{:decimals, "must have at most #{iso_digits} fractional digits"} | errors]
    else
      errors
    end
  end

  defp check_money_precision(errors, _value), do: errors

  defp check_money_range(errors, nil, _options), do: errors

  defp check_money_range(errors, %Money{} = value, options) do
    errors
    |> maybe_check_min(value, normalize_money_bound(Keyword.get(options, :min), value))
    |> maybe_check_max(value, normalize_money_bound(Keyword.get(options, :max), value))
  end

  defp maybe_check_min(errors, _value, nil), do: errors

  defp maybe_check_min(errors, value, min) do
    case Money.cmp(value, min) do
      -1 -> [{:min, "must be at least #{Money.to_string!(min)}"} | errors]
      _ -> errors
    end
  end

  defp maybe_check_max(errors, _value, nil), do: errors

  defp maybe_check_max(errors, value, max) do
    case Money.cmp(value, max) do
      1 -> [{:max, "must be at most #{Money.to_string!(max)}"} | errors]
      _ -> errors
    end
  end

  defp normalize_money_bound(nil, _value), do: nil
  defp normalize_money_bound(%Money{} = bound, _value), do: bound

  defp normalize_money_bound(bound, %Money{currency: currency}) do
    Money.new(currency, bound)
  end

  defp decimal_places(%Decimal{exp: exp}) when exp < 0, do: -exp
  defp decimal_places(%Decimal{}), do: 0
  defp decimal_places(value) when is_integer(value), do: 0
end