> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# DISPLAY CURRENCY

# Display currency

> Added 2026-10-01 (`feat/display-currency-v01`). Behind the PostHog flag
> `display-currency-v01`.

A user picks a display currency in Settings → General → **Display currency**.
Every money value on the migrated screens is then shown in that currency,
converted from USD.

**Read this before printing a money value in the app.** Then read
`docs/VALUATION-SURFACES.md` before touching how anything is valued.

## The one principle: convert at the edge

Nothing about storage or valuation changes. Every surface in
`docs/VALUATION-SURFACES.md` keeps computing and storing USD: the snapshot cron,
the DB views, the net-worth RPCs and `getFinancialMetrics`. The display
currency is applied only when a number is printed:

shown = amount\_usd × rate(USD → display currency, for the right day)

`rate` follows `exchange_rates.rate`'s own direction, display units per 1 USD
(EUR 0.89 means 1 USD = €0.89).

## Currencies

`lib/display-currency/currencies.ts` lists USD, AUD, GBP, CAD, EUR, INR, NOK,
PHP, CHF, BTC and SATS: each fiat one was asked for in a CFO Linear request
(the file lists them). BRL was in the first test list and was dropped because
nobody had requested it.
The short list is deliberate while testing (Felipe, 2026-10-01).

* Every code has a live row in `exchange_rates` and daily history in
  `exchange_rates_historical` from 2026-03-04.
* Both tables are written by the Silvia-Admin FX job, every 4 hours, from
  Finnhub. That job also derives BTC and SATS from the BTC price.
* Fiat currencies print through `Intl.NumberFormat('en-US', { currency })`:
  `€1,234`, `£1,234`, `CA$1,234`, `A$1,234`, `₹1,234`, `₱1,234`. CHF and NOK have
  no English symbol, so they print as `CHF 1,234` and `NOK 1,234`. Grouping stays
  English for every currency, including INR (`₹1,234,567`, not lakh-style).
* BTC (`₿0.0118`) and SATS (`1,180,000 sats`) are formatted by hand, because
  `Intl` rejects `SATS` and prints `BTC 0.01`.

## The pieces

| Piece | Where |
| - | - |
| Formatter (`format`, `signed`, `price`, `convert`, `*Converted`) | `lib/display-currency/format.ts` |
| Per-day rate lookup | `lib/display-currency/historical-rates.ts` |
| Series conversion, FX-inclusive change | `lib/display-currency/series.ts` |
| Provider and hooks (`useMoneyFormatter`, `useRateForDate`, `useConvertedSeries`) | `contexts/display-currency-context.tsx`, mounted in `app/(app)/providers.tsx` |
| Flag | `hooks/use-display-currency-flag.ts` |
| Preference read / write | `hooks/queries/use-display-currency-preference.ts`, `hooks/mutations/use-set-display-currency.ts` (`all_users.preferred_currency_code`, an existing column, so no migration) |
| Rates | `hooks/queries/use-display-currency-rates.ts` |
| Settings row | `components/settings/display-currency-row.tsx` |

## Rules for call sites

1. **Only convert USD amounts.** A `MoneyFormatter` takes USD. Never pass it a
   native amount:

   * a transaction in its account's currency
   * `*_native` / `native_currency_code` values
   * `other_assets.value` (use `value_usd`)
   * a non-USD card's raw `credit_limit`
   * QuickBooks book figures

   Multiplying a CAD amount by the USD→EUR rate is the bug this feature is
   most likely to produce. Activity feeds and transactions stay in each item's
   own currency, like a bank statement.
2. **Pure adapters take `money: MoneyFormatter = USD_MONEY`** (or a `money?`
   field on their context object). Components pass `useMoneyFormatter()`.
   Put `money` in every memo dependency list that uses it.
3. **With USD the formatter is byte-identical to the old helpers.**
   `lib/display-currency/__tests__/format.test.ts` pins `format` ≡
   `formatCurrencyString`, `signed` ≡ `formatSignedCurrency` and `price` ≡
   `formatSharePrice`. That is why migrated screens ship unflagged.
4. **Amounts you type stay USD.** Input fields are not converted. The Settings
   row says so. Entering a manual asset in its own currency is Phase 5b of the
   plan (the `other_assets` currency + `value_usd` pattern).

## Long figures fit their boxes

A converted figure is longer than its dollar original: `$22,537,105` becomes
`CA$22,537,105`, `CHF 18,669,834` or `26,595,000,000 sats`. Boxes sized for
dollars overflowed: the dashboard cards ran past their edge (Felipe,
2026-10-05), and a sweep of the converted screens, checked on the preview in
sats, found the same in the tables, the Allocation card and the sector
donut. So every money figure in a box that does not grow with its text is
fitted:

| Where | File |
| - | - |
| Dashboard metric cards, and their add-widget previews | `metric-cards-grid.tsx`, `add-widget-overlay.tsx` |
| Header total on the My Portfolio pages | `detail-header-card.tsx` |
| Assets and Debts chips, phones only (from `sm` up they grow with their text) | `portfolio-stat-chips.tsx` |
| Table value column (86/104px), expanded sub-rows, and money metric cells | `detail-asset-row.tsx` |
| Accounts page balance, beside its "≈" and icons | `account-list-view.tsx`, `account-row.tsx` |
| Allocation card amounts (80px) | `portfolio-allocation-card.tsx` |
| Public Investments donut total | `allocation-donut-chart.tsx` (`fitCenterValue`) |
| Top movers cards' change | `detail-top-movers.tsx` |
| Side cards (Real estate Equity, Businesses Net worth): wraps, then fits | `detail-figure-list.tsx` |
| Tax estimate dialog: cells fit, the two totals wrap | `tax-estimate-card.tsx` |

`components/fit-text.tsx` sets the figure's font size from its box's width
and the text's measured Figtree width (`lib/fit-text.ts`, weights 300 to
700\). Nothing is measured in JavaScript, so there is no flash and the server
markup matches. A figure that fits keeps its usual size. Only one that would
overflow shrinks, down to a floor. Verified in Chromium against the served
fonts: 1,537 box, size and figure combinations, no overflow.

* **Only converted figures are fitted.** The call sites pass
  `enabled={!money.isUsd}`, so a dollar figure renders exactly the markup it
  had. Dollars are all a flag-off user ever sees. This also leaves an existing
  dollar overflow (a 10-digit net worth on a narrow card) as it was. Drop the
  gate at GA to fix that too.
* **Figtree lacks `₱` and `₿`.** The browser borrows them from a system font
  whose width varies by device and weight, so they are budgeted at 0.75em (0.9em
  at 600 and up), wider than any rendering measured. A peso or bitcoin figure
  may shrink a hair early, but it never overflows. Any character missing from
  the table counts as 0.8em for the same reason.
* **A currency output gains a new character** (a symbol, a suffix): measure it
  in Chromium against the served Figtree files and add it to `lib/fit-text.ts`.
  Until then it uses the 0.8em fallback, which is safe but shrinks early.
* **A new money figure in a fixed box:** wrap it in `FitText` the same way, at
  the weight it renders in. A size container takes no width from its content,
  so the box must get its width from the layout (a block, a grid cell, a
  `flex-1` item, a fixed `w-*`), never from the text:
  * the default `fit='parent'` adds a block wrapper that measures its parent
  * `fit='ancestor'` measures a container you set yourself
    (`[container-type:inline-size]` on a fixed-width column) and keeps
    siblings on the line, with `reservePx` for their width
  * in a content-sized parent, use `fit='parent-below-sm'`, wrap the row
    (as the side cards do), or don't fit

## History and change include FX

Decision 1 (Felipe, 2026-10-01): a euro reader's wealth moves when the euro
moves.

* **Chart series** convert point by point at each day's rate.
  `useDetailChartSeries` does this itself, so its `points` and `dollarChange`
  come back in the DISPLAY currency. Print them with `money.formatConverted` /
  `money.signedConverted`; `money.format` would convert twice. Lines that skip
  that hook (cost basis, hover series) use `useConvertedSeries`.
* **Period changes** are `current × rateNow − start × rateThen`, not
  `convert(usdChange)` (`fxInclusiveChange`). The detail headers use
  `useDisplayPeriodChange`, with the rate day from `periodStartIso`. The
  dashboard cards restate theirs with `periodBaselineDate` from
  `useDailySnapshots`. A flat USD month can therefore read as a gain in euros,
  so arrows, colours and signs follow the restated number.
* **Per-holding rows stay price-only.** Their 1D/period change is the USD move
  at today's rate, so rows need not sum to an FX-inclusive header. Cost basis
  and gains also convert at today's rate (decision 2), so a gain's percentage
  equals the USD one.
* **Before 2026-03-04 there is no FX history.** Older days use the earliest
  stored rate (decision 3, "use earliest rate + backfill later"). Backfilling
  `exchange_rates_historical` in Silvia-Admin makes those days exact with no
  code change.
* **Today always uses the live rate**, even after today's history row lands,
  so a chart's right-hand point equals the header.

History is fetched by the provider, once, only after a chart on screen asks
(`requestHistory`). So chart components need no Clerk or React Query wrapper in
tests.

## Fail-safes

Every failure resolves to USD, never to a silent 1:1 under a foreign symbol:

* flag off → USD (the saved preference is kept, which makes the flag a lossless
  kill switch)
* preference USD → USD, and no rate request
* rate loading → USD for that moment
* no rate / read failed → USD; the Settings row says so
* unrecognised stored code → USD (prod held a stray `CNY`)

## Rollout (`display-currency-v01`)

* Non-production builds are on by default; `?display_currency=control` shows
  the flag-off app.
* Production fails closed.
* Ladder: internal cohort → a small group of non-USD users (CAD first) → a
  percentage ramp → 100% → remove the flag.
* Adoption event: `display_currency_changed` (`from`, `to`).
* Known limit: flags resolve after first paint, so a flagged EUR user briefly
  sees USD on load. Bootstrap the flag, or skeleton the money, before
  real-user steps.

## What is NOT converted yet

| Area | Why / phase |
| - | - |
| Silvia's answers, GenUI charts, custom dashboard widgets | Phase 4: needs a per-user prompt section and a server flag read |
| Transactions, spending, cash-flow, and the Cash Runway card's monthly burn | Their amounts are native, and `get_spending_by_group` sums mixed currencies, so converting the total would print a wrong number (a CAD user's burn is CAD units). Fixing that sum comes first (Phase 5). The Cash Runway card stays wholly USD (its cash too), so it never mixes two currencies. |
| Goals (`/goals`) | A separate page, not converted yet: its "current" amounts come from net worth, cash and debts, and targets are typed in USD. Both can convert at today's rate in a follow-up. |
| CSV / PDF exports, daily email, push | Phase 5 / 6; outbound user comms need sign-off |
| Legacy category pages | Not live behind the portfolio flags (every link resolves to the new pages; old URLs redirect after the flag answers, briefly showing USD) |
| `/accounts` Hidden Accounts dialog | Reads raw `accounts` rows, whose balances are native, so it stays as it was. The rest of `/accounts` (it IS live: My Portfolio's Accounts groups and the account page's back link go there) is converted: header, chart and every row's balance, available and limit. |
| Non-USD credit card limits | `credit_limit` is native and `useAccounts` does not convert it, so only USD cards' limits convert |
| QuickBooks figures on business pages | In the books' currency; convert only when the books say USD |
| Market data (index quotes, earnings, citation chips, watchlist quotes) | Quotes, not the user's money. The watchlist dialog's "You own N shares, worth X" line IS converted. |
| Inputs and edit forms | Typed amounts stay USD (Phase 5b); the Settings row says so. Dialogs' read-only VIEW mode is converted, so it matches the row it was opened from. |
| Value-rule text ("Auto-updates +\$500 / mo") and automatic-update activity rows | The typed rule amount and the account's own activity feed |

## Period changes: the matched basket and the baseline day

Three rules keep a page's header change and its chart caption equal, and
right, in every currency (pinned by
`contexts/__tests__/display-currency-context.test.tsx`):

* **Restate from the day the change was measured from.** The change hooks
  resolve it (`useHoldingsDailySnapshots().periodBaselineDate`,
  `useCryptoPeriodChanges().baselineDate`; for "All time", the user's first
  snapshot day). Pass it to `useDisplayPeriodChange` and to
  `useDetailChartSeries`' `changeBaselineDay`. Without it, the calendar
  baseline (`periodRateDay`) is used.
* **Only matched positions move with the currency.** A position opened inside
  the window has no baseline, so its USD change is 0 (CFO-6403); its currency
  move must be 0 too. Pages pass `matchedCurrentUsd` (today's value of the
  positions with a baseline: `matchedMarketValue` / `matchedCryptoValue`) and
  `previousUsd` (their percentage base) in a `UsdPeriodChange`.
* **Each position at its own baseline day.** A period change reads each
  position's EARLIEST snapshot inside a 7-day window from the requested day:
  per source for holdings, per holding for crypto. A snapshot gap or a
  position first seen inside that week puts its real baseline a few days
  later, so the hooks also report the day per position
  (`periodBaselineDays` / `baselineDays`). The pages split their matched
  positions by that day (`matchedChangeByBaselineDay`,
  `aggregateChange(…, { days, fallbackDay })`) into
  `UsdPeriodChange.byBaselineDay`. `restatePeriodChange`, which the header and
  the caption share, moves each share at its own day's rate. With one day it is
  exactly `fxInclusiveChange` (CodeRabbit on #4601).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.