Fair Supply LogoFair Supply - Docs

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$).

CurrencyOutput
USD$1,234
AUDA$1,234
CADCA$1,234
NZDNZ$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 AUD

The 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

DecisionConvention
Symbol displaycurrencyDisplay: 'symbol' (AUD → A$, CAD → CA$, NZD → NZ$)
Abbreviation precision1 decimal, trailing .0 stripped (A$1.5M, A$2M, A$3.1B)
Sub-threshold renderingThousand-separated, no decimals (A$98,684, A$0)
Negative sign placementBefore the symbol (-A$1.5M, not A$-1.5M)
Currency code suffixNever. The picker / report header communicates the code; no trailing (USD)
Source currencyUSD by default; pass sourceCurrency to convert from other codes

Common mistakes

  • Importing formatCurrency directly into app code with hardcoded 'USD'. The user's picker is ignored. Route through useCurrency().
  • 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.json siblings. Run node packages/core/scripts/vendor-snapshot-assets.mjs after 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.

On this page