Fair Supply LogoFair Supply - Docs

Investment Copy (i18n)

Some accounts read the product in an investment vocabulary instead of a spend one: "spend" becomes "investment", "supplier" becomes "company". This is driven by a single account-wide setting (account.defaultTransactionType, SPENT or INVESTED — the "basket").

The wording is not branched in code (invested ? … : …). Instead, every basket-sensitive string is an i18n key whose name stays SPENT-flavoured, and an INVESTED delta catalog is deep-merged over the base when the account's basket is INVESTED. Components just call t(key) and get the basket-correct string. SPENT / unset / unauthenticated accounts get the base.

base (SPENT)                INVESTED delta              account basket = INVESTED
"Spend on Tier 1 …"    +    "Investment in Tier 1 …"  →  "Investment in Tier 1 …"
"Active Suppliers"     +    "Active Companies"        →  "Active Companies"

Architecture

              account.defaultTransactionType  (SPENT | INVESTED)

                  getDisplayTransactionType()   (React.cache, per request)

          ┌───────────────────┴────────────────────┐
          │                                        │
   Live app (request.ts)                  Offline PDF report
          │                               (createReportTranslator)
   base = luz + core + app                base = core only
   INVESTED → deepMerge(base,             INVESTED → deepMerge(core base,
     coreInvested + appInvested)            coreInvested)         ◀── core only!
          │                                        │
          └───────────────────┬────────────────────┘

                          t('Namespace.key')  →  basket-correct string

deepMerge (packages/core/src/i18n/deep-merge.ts) is immutable and recursive: the override's value wins, nested objects merge, and the shared base catalog is never mutated. Crucially it does not delete base keys that the override omits — so an override only needs to carry the keys that actually change.

The four catalogs

FileRoleRead by
apps/web/messages/en.jsonApp base (SPENT) — web-only namespaces (Dashboard, Identify, Engage, web-only Spotlight)live app
apps/web/messages/en.invested.jsonApp INVESTED deltalive app only
packages/core/src/i18n/messages/en.jsonCore base (SPENT) — SupplyChain, Analyst, report-shared Spotlight subsetlive app + reports
packages/core/src/i18n/messages/en.invested.jsonCore INVESTED deltalive app and reports

Adding an investment version of a string — three rules

1. The string must render through t()

An override only flips a value that is looked up by key at render time. A hardcoded literal is invisible to the catalog.

// ✅ flips with the catalog
const t = useTranslations('SupplyChain');
<DataLabel>{t('spendOnTier1Suppliers')}</DataLabel>

// ❌ never flips — hardcoded literal
<DataLabel>Spend on Tier 1 Suppliers</DataLabel>

If the words are a literal in a component or template, route them through t() first.

2. Put the override in the catalog that matches the base — and mind reports

This is the easiest mistake. Offline PDF reports merge the core delta only — an app-side override never reaches them.

// Live-only string (e.g. a Dashboard tile) → apps/web/messages/en.invested.json
{ "Dashboard": { "overview": { "activeSpend": "Active Investment" } } }

// String that appears in a report (SupplyChain / Analyst / report-shared Spotlight)
// → packages/core/src/i18n/messages/en.invested.json  (base key must be in core too)
{ "SupplyChain": { "Spend": "Investment" } }
SurfaceWhere it merges from
Live app — apps/web/src/i18n/request.tsbase + core delta + app delta
Offline report — createReportTranslatorcore base + core delta only

3. The key path must match the base exactly

deepMerge overlays by nested key path. A path that isn't in the base silently does nothing — no error, no flip. i18n/invested-overrides.test.ts (app and core) guards this: every override key must exist in its base catalog.

pnpm --filter @repo/core exec vitest run src/i18n      # core delta vs core base
pnpm --filter web exec vitest run src/i18n             # app delta vs merged base

Vocabulary

Keep flips internally consistent — a value that flips "spend" but leaves "supplier" reads wrong.

SPENTINVESTED
spend / Spendinvestment / Investment
supplier(s) — as an entitycompany / companies (e.g. Active Suppliers → Active Companies)
supplier(s) — as the relationship/amountinvestment(s) (e.g. Direct Suppliers → Direct Investments)
sourcing (status)prospective

Traps

  • Hardcoded literal with spend/supplier wording — won't flip. Route through t().
  • App-side override for a string that renders in a report — reports merge core only, so it never applies. The mirror trap: duplicating/renaming a key app-side that the report reads from core is dead code (both surfaces resolve the core key).
  • Orphan override — a key path absent from the base. Silently no-ops; caught by the i18n test.
  • Partial flip — flipping spend but leaving "supplier" (or vice-versa) in the same value.

The supply-chain report is special

The Analyst and Spotlight reports build their copy from t() programmatically, so a new core key flips automatically. The supply-chain report renders three captured-HTML Mustache templates, so the copy is pre-baked into slots — a new basket-sensitive string there needs plumbing, not just a key:

  1. Add a {{copy.x}} slot (or {{{copy.x}}} for values with apostrophes/slashes — they must not be HTML-escaped) to all three body-template-*.html.
  2. Add the field to SupplyChainCopy and the copy object in supply-chain-snapshot/view-model.ts (resolved via t('SupplyChain.x')).
  3. Re-run node packages/core/scripts/vendor-snapshot-assets.mjs — the templates are vendored to .json siblings that Vercel's file tracer bundles.

Slots that already exist flip on their own; only brand-new strings need this.

Checklist

  1. Confirm the string renders via t('Namespace.key') (not a hardcoded literal).
  2. Add Namespace.key to the right en.invested.jsoncore if it ever appears in a report, app otherwise.
  3. Keep the SPENT base key; flip only the value; flip spend and supplier wording together.
  4. Run the i18n tests (above) to confirm there are no orphan keys.
  5. Supply-chain report only: add the {{copy.x}} slot + view-model field, then re-vendor.

See also

  • Agent rule: .claude/rules/i18n-investment-copy.md
  • Currency rendering (the other surface-split concern): Currency
  • Design background: docs/superpowers/specs/2026-06-24-i18n-catalog-merge-design.md, ADR-0021.

On this page