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"), applied byCurrency.present/3(§5);"exact"reproduces pre-Э2 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).
The ONE table of §5, applied to the RAW converted figure — so each rule
rounds exactly once and charm_99 can never round up (18.985 → 17.99,
not 18.99 → 18.99)
Whether currency's exchange_rate is older than max_age_days
(§6.2). The base currency is never stale (its rate is 1.0 by
definition, renormalization keeps it current); a nil
rate_updated_at (never dated — a row from before this column had a
writer) is an unknown age, not a known-stale one.
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 — a rounding_rule only ever applies to a
converted display amount). 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.
The target's rounding_rule (§5) is applied on BOTH paths below, to
the raw amount × rate figure, exactly once — so a catalog price
shown live and the same price frozen into a cart snapshot always
agree (§12). It is never applied to the base currency: the
passthrough above returns the base amount before either path is
reached.
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 and rounding_rule (both applied on this
frozen path too, §5) — 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 and rule, 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 (or 2) and "exact".
@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.
@spec round_for_display(Decimal.t(), non_neg_integer(), String.t() | nil) :: Decimal.t()
The ONE table of §5, applied to the RAW converted figure — so each rule
rounds exactly once and charm_99 can never round up (18.985 → 17.99,
not 18.99 → 18.99):
"exact"(default, andnil) —Decimal.round/2bydecimal_places;"charm_99"— DOWN to the nearest X.99 (18.17 → 17.99, 22.00 → 21.99);"charm_90"— to the NEAREST X.90 (18.17 → 17.90, 125.45 → 125.90);"integer"— whole units, half-up, from the raw figure.
Charm rules assume two minor-unit digits (the changeset enforces
decimal_places == 2 for them) and leave figures below 1.00 — and zero —
at exact rounding: there is no X.99 below one unit, and "free" must stay
free. Never applied to the base currency: present/3 returns the base
amount before reaching this function (§5 п.3).
@spec stale?(t(), pos_integer()) :: boolean()
Whether currency's exchange_rate is older than max_age_days
(§6.2). The base currency is never stale (its rate is 1.0 by
definition, renormalization keeps it current); a nil
rate_updated_at (never dated — a row from before this column had a
writer) is an unknown age, not a known-stale one.
Compares in SECONDS against the threshold expressed in seconds, not
DateTime.diff/3 with :day — that unit TRUNCATES elapsed seconds
rather than rounding, so a rate aged 30 days, 23 hours, 59 minutes and
59 seconds would still diff to 30 and report "not stale" against a
30-day threshold; the flag would only flip a full day later than
promised. Comparing seconds against seconds has no such rounding step
to get wrong.