PhoenixKitBilling.Currency (PhoenixKitBilling v0.13.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"), applied by Currency.present/3 (§5); "exact" reproduces pre-Э2 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).

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

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 — 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".

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.

round_for_display(raw, places, arg3)

@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, and nil) — Decimal.round/2 by decimal_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).

stale?(currency, max_age_days)

@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.