Internationalisation (i18n)¶
All user-facing strings are externalised in src/i18n/. No hardcoded strings in handlers, use cases, or cron runners.
Supported Locales¶
| Code | Language |
|---|---|
es |
Spanish (default) |
en |
English |
fr |
French |
de |
German |
pt |
Portuguese |
it |
Italian |
tr |
Turkish |
nl |
Dutch |
ca |
Catalan |
ro |
Romanian |
pl |
Polish |
et |
Estonian |
Architecture¶
src/i18n/
├── types.ts — Locale type union + Messages interface + param types
├── index.ts — getMessages(locale?), DEFAULT_LOCALE, catalog map
└── *.ts — One file per locale (es.ts, en.ts, fr.ts, ...)
The Messages Interface¶
Every locale file exports a single object satisfying Messages. Strings with parameters use function types — no runtime template parsing, full TypeScript type-checking.
export interface Messages {
bot: {
error: (msg: string) => string;
txConfirm: (p: TxConfirmParams) => string;
onboardWelcome: (oauthUrl: string, templateUrl: string) => string;
// ...
};
parser: {
invalidAmount: (val: string) => string;
unknownAccount: (account: string, valid: string) => string;
// ...
};
daily: { ... };
weekly: { ... };
deferred: {
settled: (count: number) => string;
};
}
TypeScript enforces completeness — a missing key is a compile error.
getMessages¶
export function getMessages(locale: Locale = DEFAULT_LOCALE): Messages
Returns catalog for requested locale, falls back to DEFAULT_LOCALE ('es') if not found.
Default Locale¶
DEFAULT_LOCALE = 'es'. Used for:
- Pre-auth commands (/start, /config) where no user record exists yet
- Fallback in getMessages() if unknown locale passed
Per-User Locale¶
users.locale (D1, migration 002_add_locale.sql) stores each user's preferred language. Defaults to 'es' on onboarding.
WebhookHandler uses per-user locale for all post-auth messages and parser errors.
Known gap: SendDailyDigestUseCase and SendWeeklyDigestUseCase currently use DEFAULT_LOCALE for all users. They need to pass user.locale once per-user locale has been proven stable.
Adding a New Locale¶
- Create
src/i18n/XX.ts:import { Messages } from './types'; export const xx: Messages = { /* ... */ }; - Add
'xx'to theLocaleunion intypes.ts - Import and register in
index.ts:import { xx } from './xx'; const catalogs: Record<Locale, Messages> = { ..., xx }; - Run
npx tsc --noEmit— compiler catches missing keys
Gender-Neutral Copy Conventions¶
Languages with grammatical gender must avoid expressions that assume the user's gender.
| Language | Pattern | Example |
|---|---|---|
| Spanish | Noun-based | No tienes cuenta registrada |
| Spanish | Inclusive verb | Te damos la bienvenida |
| Catalan | Inclusive verb | Et donem la benvinguda |
| French | Noun-based | Pas de compte enregistré |
| Italian | Inclusive verb | Ti diamo il benvenuto |
| Portuguese | Invariable phrase | Boas-vindas |
| Romanian | Invariable phrase | Bun venit |
| Polish | Noun-based | Brak konta (neuter noun "konto") |
| German / Dutch / Turkish / Estonian | N/A | No grammatical gender issue |
Rule: make the agreement target a noun (the account, the transaction), never the user.
The aiPrompt Key¶
Each locale's weekly.aiPrompt builds the Gemini prompt in the target language and requests the AI response in that language:
// pl.ts
aiPrompt: (...) =>
`Przeanalizuj... Podaj 3-4 zdania analizy po polsku...`
Users receive AI analysis in their own language without post-processing.
Pluralization Notes¶
| Locale | Strategy |
|---|---|
| es, fr, it, de, nl, ca, ro, pt, tr | count !== 1 |
| ca, ro, it | Full adjective agreement via ternary |
| pt | Vowel mutation (transação / transações) |
| pl | 3-way paucal rule: 1 / 2–4 (excluding 12–14) / 5+ |
| et | Partitive with numerals (tehing / tehingut) |