Currency Guide
Currency in this platform follows one rule: one formatter, two converters. Every visible currency string flows through luz formatCurrency. Conversion is split by surface — live pages convert via the user's active picker, reports convert via a snapshot captured at generation time.
Architecture
amount in USD (canonical storage unit)
│
┌─────────────────────────┴─────────────────────────┐
│ │
Live pages (assessment, Report (HTML / PDF)
spotlight, identify, dashboard) │
│ │
Convert via session FX Convert via CurrencySnapshot
(useCurrency.convert / rates) (frozen at generation time)
│ │
└─────────────────────────┬─────────────────────────┘
▼
luz.formatCurrency(value, { currency, abbreviateThreshold? })
│
▼
$25,850 / $1.5M / $0 / —Why split conversion: live pages must respond instantly to the picker, so they read live FX from the useCurrency context. Reports are persisted artefacts (audit, compliance, customer email attachments) — their numbers must remain stable across time, so they convert via a snapshot of the FX rates at the moment they were generated.
formatCurrency — the single string formatter
Lives in packages/luz/src/lib/format.ts. One options-bag function:
import { formatCurrency } from '@repo/luz';
formatCurrency(25_850); // '$25,850'
formatCurrency(25_850, { currency: 'AUD' }); // 'A$25,850'
formatCurrency(1_500_000, { currency: 'AUD', abbreviateThreshold: 1_000_000 }); // 'A$1.5M'
formatCurrency(undefined, { placeholder: 'N/A' }); // 'N/A'
formatCurrency(0, { currency: 'AUD' }); // 'A$0'
formatCurrency(1234.56, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); // '$1,234.56'Symbol display uses currencyDisplay: 'symbol', so AUD/CAD/NZD render with their locale-disambiguated prefixes (A$/CA$/NZ$).
| Currency | Output |
|---|---|
| USD | $1,234 |
| AUD | A$1,234 |
| CAD | CA$1,234 |
| NZD | NZ$1,234 |
| GBP | £1,234 |
| EUR | €1,234 |
The deprecated formatCurrencyAbbreviated still exists as a thin wrapper, but new code uses formatCurrency with abbreviateThreshold.
useCurrency — the live-page hook
Mounted from the root layout via CurrencyProvider. Lives at apps/web/src/contexts/currency-context.tsx.
import { useCurrency } from '@/contexts/currency-context';
function MyComponent() {
const currency = useCurrency();
// currency.code // 'AUD'
// currency.rate // 0.65 (USD per 1 AUD)
// currency.rates // { USD: 1, AUD: 0.65, CAD: 0.74, ... }
// currency.convert(amountUsd, { sourceCurrency: 'USD' }) → display-currency number
// currency.format(amountUsd, { abbreviateThreshold?, sourceCurrency? }) → formatted string
return <span>{currency.format(data.spendUsd)}</span>;
}Conversion: (amount * rates[sourceCurrency]) / targetRate. Source currency defaults to USD.
CurrencyAmount — the simplest renderer
For server-component pages that pass USD numbers down, wrap them in the client-only CurrencyAmount:
import { CurrencyAmount } from '@/components/shared/currency-amount';
<CurrencyAmount amountUsd={data.transaction_amount_usd} abbreviateThreshold={100_000} />This is the smallest possible client boundary — useful for emissions/biodiversity tiles, scorecards, and any tile that just needs to render a single value.
createCurrencyColumn — table spend columns
Use the luz factory for any data-table column showing a currency value:
import { createCurrencyColumn } from '@repo/luz';
import { useCurrency } from '@/contexts/currency-context';
const { code, convert } = useCurrency();
createCurrencyColumn<Row>({
id: 'spend',
header: 'Spend',
accessorFn: (row) => convert(row.spendUsd, { sourceCurrency: 'USD' }),
currency: code,
abbreviateThreshold: 1_000_000, // omit for full thousand-separated numbers
});The accessor converts USD → display once so column sorting operates on the displayed magnitude. The cell renderer formats the already-converted value — it never converts a second time.
The supply-chain assessment's spendColumn helper at shared.tsx is a thin wrapper over createCurrencyColumn that captures the threshold convention; use it inside the assessment.
CurrencySnapshot — frozen FX for reports
When generating a persisted report, capture the FX snapshot at generation time and pass it through the view model. The snapshot lives in packages/core/src/domain/schemas/currency-snapshot.ts:
interface CurrencySnapshot {
code: string; // 'AUD', 'USD', ...
targetRateUsd: number; // USD per 1 unit of `code`
}The supply-chain report's makeFormatCurrency factory (view-model.ts) returns a string formatter bound to the snapshot:
const fmt = makeFormatCurrency(snapshot);
fmt(usdAmount); // 'A$77.8M' when snapshot.code is AUDThe formatter inside makeFormatCurrency produces output bit-identical to formatCurrency({ currency, abbreviateThreshold: 1_000_000 }) — a deliberate mirror because @repo/core does not depend on @repo/luz. See the agent rule for the contract.
Decisions baked in
| Decision | Convention |
|---|---|
| Symbol display | currencyDisplay: 'symbol' (AUD → A$, CAD → CA$, NZD → NZ$) |
| Abbreviation precision | 1 decimal, trailing .0 stripped (A$1.5M, A$2M, A$3.1B) |
| Sub-threshold rendering | Thousand-separated, no decimals (A$98,684, A$0) |
| Negative sign placement | Before the symbol (-A$1.5M, not A$-1.5M) |
| Currency code suffix | Never. The picker / report header communicates the code; no trailing (USD) |
| Source currency | USD by default; pass sourceCurrency to convert from other codes |
Common mistakes
- Importing
formatCurrencydirectly into app code with hardcoded'USD'. The user's picker is ignored. Route throughuseCurrency(). - Appending
{currency.code}or(USD)next to a value. The picker (or report header) communicates the active currency; per-value codes read as noise. - Converting twice. If the column's accessor converts USD → display, the cell must format the already-converted value. Calling
currency.format()on a row that's already been converted re-applies the FX rate. - Editing a report HTML template without re-vendoring. The runtime reads
body-template-*.html.jsonsiblings. Runnode packages/core/scripts/vendor-snapshot-assets.mjsafter any template edit.
See also
- The agent rule at
.claude/rules/currency-formatting.md— concise rules enforced by review. - components.md — navigation buttons for spotlight and analyst trigger Inngest report jobs.