# `PhoenixKitBilling.Currency`
[🔗](https://github.com/BeamLabEU/phoenix_kit_billing/blob/0.13.0/lib/phoenix_kit_billing/schemas/currency.ex#L1)

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"

# `t`

```elixir
@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()
}
```

# `changeset`

Creates a changeset for currency creation and updates.

# `convert`

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`

```elixir
@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`

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`

Formats an amount without currency symbol.

# `get_request_currency`

```elixir
@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`

```elixir
@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`

```elixir
@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 `Task`s 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`

```elixir
@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?`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
