PhoenixKitBilling.Currency (PhoenixKitBilling v0.11.0)

Copy Markdown View Source

Currency schema for PhoenixKit Billing system.

Manages supported currencies with exchange rates for multi-currency billing.

Schema Fields

  • code: ISO 4217 currency code (e.g., "EUR", "USD", "GBP")
  • name: Full currency name (e.g., "Euro", "US Dollar")
  • symbol: Currency symbol (e.g., "€", "$", "£")
  • decimal_places: Number of decimal places (usually 2)
  • is_default: Whether this is the default currency
  • enabled: Whether currency is available for use
  • exchange_rate: Rate relative to base currency
  • sort_order: Display order in currency lists
  • rounding_rule: Display rounding strategy ("exact", "charm_99", "charm_90", "integer"); no reader uses this yet — "exact" reproduces today's behavior
  • rate_updated_at: When exchange_rate was last refreshed; no reader uses this yet

Usage Examples

# List all enabled currencies
currencies = PhoenixKitBilling.list_currencies()

# Get default currency
currency = PhoenixKitBilling.get_default_currency()

# Format amount in currency
PhoenixKitBilling.Currency.format_amount(99.99, currency)
# => "€99.99"

Summary

Functions

Creates a changeset for currency creation and updates.

Converts amount from one currency to another.

The multiplier base -> target a cart freezes at creation (§4.4): the target's rate over the base's rate, rounded to six decimal places — enough headroom that repeated freeze/thaw does not accumulate visible drift, matching phoenix_kit_shop_carts.exchange_rate's numeric(15,6) column.

Formats an amount with currency symbol.

Formats an amount without currency symbol.

Returns the request/process-scoped display-currency code override, if any set by put_request_currency/1 on this process.

The ONE place a base-currency amount becomes a display-currency amount (§4.3, §12 of the per-domain-currency spec). Currency.convert/3 above is NOT that place — it is never called from anywhere but its own moduledoc example (§12.1); every other caller in this codebase must come through here.

Sets (or, with nil/"", clears) the request-scoped display-currency CODE — the currency the shopper on THIS request should see and be charged in, as opposed to the shop's base currency (§4.2 of the per-domain-currency spec: authoring/storage always stays in the base; only display and checkout resolve per request).

Types

t()

@type t() :: %PhoenixKitBilling.Currency{
  __meta__: term(),
  code: term(),
  decimal_places: term(),
  enabled: term(),
  exchange_rate: term(),
  inserted_at: term(),
  is_default: term(),
  name: term(),
  rate_updated_at: term(),
  rounding_rule: term(),
  sort_order: term(),
  symbol: term(),
  updated_at: term(),
  uuid: term()
}

Functions

changeset(currency, attrs)

Creates a changeset for currency creation and updates.

convert(amount, currency1, currency2)

Converts amount from one currency to another.

Examples

iex> from = %Currency{exchange_rate: Decimal.new("1.0")}  # EUR (base)
iex> to = %Currency{exchange_rate: Decimal.new("1.1")}    # USD
iex> Currency.convert(100, from, to)
Decimal.new("110.00")

effective_rate(currency1, currency2)

@spec effective_rate(t(), t()) :: Decimal.t()

The multiplier base -> target a cart freezes at creation (§4.4): the target's rate over the base's rate, rounded to six decimal places — enough headroom that repeated freeze/thaw does not accumulate visible drift, matching phoenix_kit_shop_carts.exchange_rate's numeric(15,6) column.

format_amount(amount, currency)

Formats an amount with currency symbol.

Examples

iex> currency = %Currency{symbol: "€", decimal_places: 2}
iex> Currency.format_amount(Decimal.new("99.99"), currency)
"€99.99"

iex> Currency.format_amount(1234.5, currency)
"€1,234.50"

format_amount_plain(amount, currency)

Formats an amount without currency symbol.

get_request_currency()

@spec get_request_currency() :: String.t() | nil

Returns the request/process-scoped display-currency code override, if any set by put_request_currency/1 on this process.

present(amount, display_code, opts \\ [])

@spec present(Decimal.t() | number() | String.t(), String.t() | nil, keyword()) ::
  Decimal.t()

The ONE place a base-currency amount becomes a display-currency amount (§4.3, §12 of the per-domain-currency spec). Currency.convert/3 above is NOT that place — it is never called from anywhere but its own moduledoc example (§12.1); every other caller in this codebase must come through here.

Takes a display-currency CODE, not a %Currency{}, and resolves both the base and the target through PhoenixKitBilling.get_base_currency/0 and PhoenixKitBilling.resolve_display_currency/1 on EVERY call — so nothing upstream can cache a %Currency{} (and, inside it, a rate) in a struct or an assign and have that rate go stale the moment an admin edits it (§4.2.1). A nil code (no display override in play) and the base currency's own code both return amount unrounded: an author's stored price is not "converted to itself" and then rounded away from what they typed (§5, exact rounding only in Э1 — no psychological rounding yet). The same passthrough covers a target this call cannot resolve to anything but the base (resolve_display_currency/1's fail-safe, §6.3) — the fallback has already logged its own warning by the time present/3 sees it, so this function does not warn again.

opts[:rate] is the ONE way this function does not read phoenix_kit_currencies for the target's rate: a caller's frozen exchange_rate (a cart's, an order's), taken as-is regardless of what the currency table says right now (§12.2 — a snapshot rate is never mixed with a live one). With :rate given, the code is looked up ONLY for its decimal_places (rounding, cosmetic) — never through resolve_display_currency/1, whose own fail-safe (§6.3) would substitute the base as target the moment the code is disabled or its live rate turns unusable, and this function would then see target.code == base.code and return the amount unconverted, silently discarding the very rate the caller froze it at. A frozen rate must survive the target currency being disabled AFTER the freeze — that is the whole reason a caller freezes one in the first place (found in review: an EUR cart disabled mid-checkout used to lose its conversion this way). Rounding still happens once, by the resolved decimal places, same as the live-rate path; a code this shop's table has never heard of at all falls back to the base's own decimal places, then 2.

put_request_currency(code)

@spec put_request_currency(String.t() | nil) :: :ok

Sets (or, with nil/"", clears) the request-scoped display-currency CODE — the currency the shopper on THIS request should see and be charged in, as opposed to the shop's base currency (§4.2 of the per-domain-currency spec: authoring/storage always stays in the base; only display and checkout resolve per request).

Process-scoped, mirroring PhoenixKit.Languages.put_request_default_language/1: the host app (a Plug for the dead render, an on_mount hook for LiveView) sets it per request, and it does NOT propagate to spawned Tasks or Oban jobs. Always call it — including with nil — on every request, even ones with no override, so a previous request's code can never leak forward on a reused process. "" is treated the same as nil for a host that builds the code from a possibly-blank domain map lookup.

Stores the CODE, never a %Currency{} struct (§4.2.1) — a cached struct across requests could go stale the moment an admin changes a rate, while the code is re-resolved through PhoenixKitBilling.resolve_display_currency/1 on every read.