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 stringdeepMerge (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
| File | Role | Read by |
|---|---|---|
apps/web/messages/en.json | App base (SPENT) — web-only namespaces (Dashboard, Identify, Engage, web-only Spotlight) | live app |
apps/web/messages/en.invested.json | App INVESTED delta | live app only |
packages/core/src/i18n/messages/en.json | Core base (SPENT) — SupplyChain, Analyst, report-shared Spotlight subset | live app + reports |
packages/core/src/i18n/messages/en.invested.json | Core INVESTED delta | live 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" } }| Surface | Where it merges from |
|---|---|
Live app — apps/web/src/i18n/request.ts | base + core delta + app delta |
Offline report — createReportTranslator | core 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 baseVocabulary
Keep flips internally consistent — a value that flips "spend" but leaves "supplier" reads wrong.
| SPENT | INVESTED |
|---|---|
| spend / Spend | investment / Investment |
| supplier(s) — as an entity | company / companies (e.g. Active Suppliers → Active Companies) |
| supplier(s) — as the relationship/amount | investment(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:
- Add a
{{copy.x}}slot (or{{{copy.x}}}for values with apostrophes/slashes — they must not be HTML-escaped) to all threebody-template-*.html. - Add the field to
SupplyChainCopyand thecopyobject insupply-chain-snapshot/view-model.ts(resolved viat('SupplyChain.x')). - Re-run
node packages/core/scripts/vendor-snapshot-assets.mjs— the templates are vendored to.jsonsiblings that Vercel's file tracer bundles.
Slots that already exist flip on their own; only brand-new strings need this.
Checklist
- Confirm the string renders via
t('Namespace.key')(not a hardcoded literal). - Add
Namespace.keyto the righten.invested.json— core if it ever appears in a report, app otherwise. - Keep the SPENT base key; flip only the value; flip spend and supplier wording together.
- Run the i18n tests (above) to confirm there are no orphan keys.
- 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.