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 currencyenabled: Whether currency is available for useexchange_rate: Rate relative to base currencysort_order: Display order in currency listsrounding_rule: Display rounding strategy ("exact","charm_99","charm_90","integer"); no reader uses this yet —"exact"reproduces today's behaviorrate_updated_at: Whenexchange_ratewas 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
@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
Creates a changeset for currency creation and updates.
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")
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.
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"
Formats an amount without currency symbol.
@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.
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.
@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.