lib/money/input/currency.ex

defmodule Money.Input.Currency do
  @moduledoc """
  Currency + locale display data for money input components.

  Given a locale and a currency, returns the data a UI needs to
  render a currency-aware input correctly: the locale's
  number-system separators, the currency symbol resolved per
  CLDR rules, the symbol's position relative to the digits (read
  from the locale's currency format pattern, not guessed), and
  the currency's fractional-digit precision.

  All locale lookups go through `Localize.validate_locale/1`;
  the resulting `t:Localize.LanguageTag.t/0`'s
  `:cldr_locale_id` is the canonical id we report back. Number
  system resolution goes through
  `Localize.Number.System.number_system_from_locale/1` so
  Arabic-Indic, Persian, and other non-Latin digit systems are
  handled correctly (no assumption of `:latn`).

  """

  alias Localize.Currency, as: LocalizeCurrency
  alias Localize.LanguageTag
  alias Localize.Number.Format
  alias Localize.Number.Symbol
  alias Localize.Number.System
  alias Money.Input.NoNumberSymbolsError

  @typedoc """
  Locale + currency display data resolved by
  `currency_for_locale/2`.

  * `:locale` — the canonical CLDR locale id (atom).

  * `:language_tag` — the full `t:Localize.LanguageTag.t/0` for
    callers needing access to extensions, regions, scripts, etc.

  * `:number_system` — the active number system (`:latn`,
    `:arab`, `:arabext`, …).

  * `:decimal` — the locale's decimal separator character.

  * `:group` — the locale's grouping separator character.

  * `:minus_sign` — the locale's minus sign character.

  * `:currency` — a `t:Localize.Currency.t/0` for the requested
    currency, or `nil` when no currency was supplied.

  * `:symbol` — the currency symbol resolved per
    `Localize.Currency.symbol/2` (kind selected via the
    `:symbol_kind` option). `nil` when no currency was supplied.

  * `:symbol_position` — `:prefix` or `:suffix`, derived from
    the locale's CLDR currency format pattern. `nil` when no
    currency was supplied.

  * `:iso_digits` — the currency's ISO 4217 fractional digit
    count (USD: 2, JPY: 0, BHD: 3). `nil` when no currency.

  """
  @type t :: %__MODULE__{
          locale: atom(),
          language_tag: LanguageTag.t(),
          number_system: atom(),
          decimal: String.t(),
          group: String.t(),
          minus_sign: String.t(),
          currency: LocalizeCurrency.t() | nil,
          symbol: String.t() | nil,
          symbol_position: :prefix | :suffix | nil,
          iso_digits: non_neg_integer() | nil
        }

  defstruct [
    :locale,
    :language_tag,
    :number_system,
    :decimal,
    :group,
    :minus_sign,
    :currency,
    :symbol,
    :symbol_position,
    :iso_digits
  ]

  @currency_indicator "¤"
  @digit_indicators ["#", "0", "@"]

  @doc """
  Resolves currency and locale display data for the given
  locale (and optional currency).

  ### Arguments

  * `locale` is a locale identifier (atom, string, or
    `t:Localize.LanguageTag.t/0`). Defaults to
    `Localize.get_locale/0`.

  * `options` is a keyword list of options.

  ### Options

  * `:currency` — an ISO 4217 currency code (atom or string).
    When given, currency-specific fields (`:currency`,
    `:symbol`, `:symbol_position`, `:iso_digits`) are populated.
    When omitted, those fields are `nil`.

  * `:symbol_kind` — which currency-marker variant to surface as
    `:symbol`. One of `:standard` (default), `:symbol`,
    `:narrow`, `:iso`, `:none`. Forwarded to
    `Localize.Currency.symbol/2`.

  ### Returns

  * `{:ok, t()}` on success.

  * `{:error, Exception.t()}` when the locale can't be parsed,
    the number system can't be resolved, number symbols are
    missing for the locale, or the currency code is unknown.

  ### Examples

      iex> {:ok, info} = Money.Input.Currency.currency_for_locale(:en, currency: :USD)
      iex> {info.decimal, info.group, info.symbol, info.symbol_position, info.iso_digits}
      {".", ",", "$", :prefix, 2}

      iex> {:ok, info} = Money.Input.Currency.currency_for_locale(:de, currency: :EUR)
      iex> {info.decimal, info.group, info.symbol, info.symbol_position}
      {",", ".", "€", :suffix}

      iex> {:ok, info} = Money.Input.Currency.currency_for_locale(:ja, currency: :JPY)
      iex> info.iso_digits
      0

  """
  @spec currency_for_locale(LanguageTag.t() | atom() | String.t() | nil, Keyword.t()) ::
          {:ok, t()} | {:error, Exception.t()}
  def currency_for_locale(locale \\ nil, options \\ []) do
    currency_code = Keyword.get(options, :currency)
    symbol_kind = Keyword.get(options, :symbol_kind, :standard)

    with {:ok, language_tag} <- Localize.validate_locale(locale || Localize.get_locale()),
         {:ok, number_system} <- System.number_system_from_locale(language_tag),
         {:ok, symbols} <- resolve_symbols(language_tag, number_system),
         {:ok, currency} <- resolve_currency(currency_code, language_tag),
         {:ok, symbol_position} <- resolve_symbol_position(currency, language_tag, number_system),
         {:ok, symbol} <- resolve_symbol(currency, symbol_kind) do
      {:ok,
       %__MODULE__{
         locale: language_tag.cldr_locale_id,
         language_tag: language_tag,
         number_system: number_system,
         decimal: symbol_string(symbols.decimal),
         group: symbol_string(symbols.group),
         minus_sign: symbol_string(symbols.minus_sign),
         currency: currency,
         symbol: symbol,
         symbol_position: symbol_position,
         iso_digits: currency && currency.iso_digits
       }}
    end
  end

  # ── locale + number system → symbols ───────────────────────

  defp resolve_symbols(language_tag, number_system) do
    case Symbol.number_symbols_for(language_tag, number_system) do
      {:ok, symbols} ->
        {:ok, symbols}

      {:error, _} ->
        default_number_system_symbols(language_tag, number_system)
    end
  end

  # CLDR doesn't always populate symbols for every
  # (locale, system) pair. Fall back to the locale's
  # *default* number system — the value at the `:default`
  # key of `Localize.Number.System.number_systems_for/1`
  # (e.g. `:latn` for `en`, `:arabext` for `fa`).
  defp default_number_system_symbols(language_tag, number_system) do
    with {:ok, %{default: default_system}} <- System.number_systems_for(language_tag),
         true <- default_system != number_system,
         {:ok, symbols} <- Symbol.number_symbols_for(language_tag, default_system) do
      {:ok, symbols}
    else
      _ ->
        {:error,
         NoNumberSymbolsError.exception(
           locale: language_tag.cldr_locale_id,
           number_system: number_system
         )}
    end
  end

  # Number-symbol fields are either a plain binary or a map of
  # variants keyed by `:standard` / `:accounting` / etc. Every
  # locale CLDR ships has one of these two shapes (verified
  # empirically across ~70 locales). If a new shape ever appears
  # we want a loud `FunctionClauseError` so it surfaces rather
  # than silently returning garbage from `Map.values/1`.
  defp symbol_string(value) when is_binary(value), do: value
  defp symbol_string(%{standard: value}) when is_binary(value), do: value

  # ── currency resolution ────────────────────────────────────

  defp resolve_currency(nil, _language_tag), do: {:ok, nil}

  defp resolve_currency(code, language_tag) do
    LocalizeCurrency.currency_for_code(code, locale: language_tag)
  end

  # ── symbol position from the CLDR format pattern ──────────
  #
  # The pattern looks like `¤#,##0.00` (prefix) or `#,##0.00 ¤`
  # (suffix). Find where the `¤` placeholder sits relative to
  # the first digit placeholder (`#`, `0`, or `@`). No locale
  # heuristics, no hardcoded list — CLDR is the source of truth.

  defp resolve_symbol_position(nil, _language_tag, _number_system) do
    {:ok, nil}
  end

  defp resolve_symbol_position(_currency, language_tag, number_system) do
    with {:ok, %{currency: pattern}} <- Format.formats_for(language_tag, number_system) do
      {:ok, position_in_pattern(pattern)}
    end
  end

  defp position_in_pattern(pattern) do
    cond do
      currency_position(pattern) <= digit_position(pattern) -> :prefix
      true -> :suffix
    end
  end

  defp currency_position(pattern) do
    case :binary.match(pattern, @currency_indicator) do
      {pos, _} -> pos
      :nomatch -> -1
    end
  end

  defp digit_position(pattern) do
    @digit_indicators
    |> Enum.map(&:binary.match(pattern, &1))
    |> Enum.flat_map(fn
      {pos, _} -> [pos]
      :nomatch -> []
    end)
    |> Enum.min(fn -> -1 end)
  end

  # ── currency symbol selection ──────────────────────────────

  defp resolve_symbol(nil, _kind), do: {:ok, nil}

  defp resolve_symbol(%LocalizeCurrency{} = currency, kind),
    do: LocalizeCurrency.symbol(currency, kind)
end