Files
suaveplan/openspec/archive/2026-05-09-i18n-formatter-currency/proposal.md
2026-05-27 23:29:53 +01:00

110 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@suaveplan/i18n-formatter-currency` — `Intl.NumberFormat` (currency style) wrapper + ISO 4217 metadata
**Package**: `@suaveplan/i18n-formatter-currency` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port of `formatting/currency/` plus class wrapper + Zod schema + observability uplift)
**Date**: 2026-05-05
**Wave**: 3 (depends on Wave 1 foundation AND on `@suaveplan/i18n-formatter-number`; required by Wave 6 core slim)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §3 — currency is the only formatter that depends on a sibling formatter)
**Path**: `packages/i18n/i18n-formatter-currency/`
**Tier**: universal
## Why
The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package. `@suaveplan/i18n-formatter-currency` is the only Wave-3 formatter that depends on another formatter — `@suaveplan/i18n-formatter-number` — because `Intl.NumberFormat` with `style: "currency"` is the canonical currency-formatting API. Rather than duplicate the per-(locale, options-hash) cache + Zod surface from `i18n-formatter-number`, this package delegates the underlying `Intl.NumberFormat` construction to its sibling and adds a thin currency-specific layer on top:
- ISO 4217 currency-code validation (length 3, uppercase letters)
- Currency-symbol / currency-name / currency-info metadata extraction via `formatToParts`
- A `display` option (`"symbol" | "code" | "name" | "narrowSymbol"`) mapped to `currencyDisplay`
Today `formatting/currency/` ships:
- `formatCurrency(amount, currency, locale, options?)` standalone (`packages/i18n/i18n/src/formatting/currency/currency.ts` L16–L29)
- `formatCurrencyValue(amount, currency, locale)` standalone — returns numeric portion only (`currency.ts` L35–L49)
- `getCurrencySymbol(currency, locale, display?)` (`currency.ts` L55–L67)
- `getCurrencyName(currency, locale)` (`currency.ts` L72–L80)
- `getCurrencyInfo(currency, locale)` returning `{code, symbol, name, decimalDigits}` (`currency.ts` L85–L98)
- BCP-47 normalisation via `LOCALE_BCP47_MAP` (`currency.ts` L4–L10)
- `CommonCurrency`, `CurrencyFormatOptions`, `CurrencyInfo` type aliases (`currency.types.ts` L1–L34)
The split adds the `CurrencyFormatter` class wrapper, Zod-validated options (including ISO 4217 code validation), per-(locale, currency, options-hash) cache, and OTel histogram + cache-hit counter for parity with the rest of the family.
## What changes
### New package: `@suaveplan/i18n-formatter-currency`
- ADDED: `packages/i18n/i18n-formatter-currency/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
- ADDED: `src/formatter.ts` — `CurrencyFormatter` class (NEW, holds stable `currency` + options); `formatCurrency` standalone (port of monolith L16–L29) extended with Zod validation, cache, and OTel emission; `formatCurrencyValue` (port of L35–L49); `clearCurrencyFormatterCache` test-only helper. Per-(locale, currency, options-hash) cache key `${locale}|${currency}|${JSON.stringify(options)}`. Currency-style construction delegates to a thin `Intl.NumberFormat` wrapper (`@suaveplan/i18n-formatter-number` exposes the same Zod option-validation pattern this package mirrors for currency-specific options).
- ADDED: `src/formatter.types.ts` — `CommonCurrency` (ISO 4217 alias union), `CurrencyDisplay` (`"symbol" | "code" | "name" | "narrowSymbol"`), `CurrencyFormatterOptions`, `CurrencyInfo`, `CurrencyFormatterCacheKey`. Sourced from monolith `currency.types.ts` L1–L34.
- ADDED: `src/formatter.schemas.ts` — `CurrencyCodeSchema` Zod schema validating ISO 4217 (`/^[A-Z]{3}$/`); `CurrencyFormatterOptionsSchema` validating `display`, `minimumFractionDigits`, `maximumFractionDigits`, `locale`. NEW.
- ADDED: `src/helpers.ts` — `getCurrencySymbol(currency, locale, display?)` (port of monolith L55–L67); `getCurrencyName(currency, locale)` (port of L72–L80); `getCurrencyInfo(currency, locale)` (port of L85–L98).
- ADDED: `src/observability.ts` — local OTel histogram `i18n.format.currency.duration_ms` + counter `i18n.format.cache_hit{formatter="currency"}`.
- ADDED: `src/formatter.test.ts` — port of monolith `currency.test.ts` (141 lines) plus new tests for `CurrencyFormatter` class, ISO 4217 validation, cache reuse.
- ADDED: `src/formatter.spec-conformance.test.ts` — every requirement scenario in this delta.
- ADDED: `src/helpers.test.ts` — covers `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo`.
- ADDED: `src/index.ts` — explicit named re-exports.
- ADDED: `src/formatter.md`, `src/helpers.md`, `src/observability.md` — sibling docs.
### Subpath exports
- `.` — root barrel
- `./formatter` — `CurrencyFormatter` class + `formatCurrency` + `formatCurrencyValue` + types + schema only
- `./helpers` — `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError`, `LOCALE_BCP47_MAP`
- **`@suaveplan/i18n-formatter-number workspace:^`** — currency formatting is `Intl.NumberFormat({ style: "currency", ... })`; this package mirrors the Zod-validation + cache + OTel pattern from its sibling so consumers get a uniform surface
- `@suaveplan/types workspace:^`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms`
- **No** dep on any other formatter package, on `@suaveplan/i18n` core, or on any backend package.
## Out of scope
- **Removing `formatting/currency/` from `packages/i18n/i18n/src/`** — happens in Wave 6.
- **Custom ISO 4217 metadata table beyond what `Intl.NumberFormat` supports** — out of scope; runtime ICU handles every active ISO 4217 code.
- **Cryptocurrency formatting** (e.g. `BTC`, `ETH` not in ISO 4217) — out of scope; falls outside `Intl.NumberFormat`'s currency table. The separate `@suaveplan/cryptocurrency` package owns crypto formatting.
- **Currency-conversion arithmetic** — out of scope; a separate `@suaveplan/currency` package owns conversion.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `formatCurrency` standalone | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L16–L29 | Verbatim port; extended with Zod validation + cache + OTel emission |
| `formatCurrencyValue` standalone | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L35–L49 | Verbatim port |
| `getCurrencySymbol` | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L55–L67 | Verbatim port |
| `getCurrencyName` | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L72–L80 | Verbatim port |
| `getCurrencyInfo` | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L85–L98 | Verbatim port |
| `toBcp47` BCP-47 normalisation | `packages/i18n/i18n/src/formatting/currency/currency.ts` | L4–L10 | Verbatim port; use `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared` |
| `CommonCurrency`, `CurrencyFormatOptions`, `CurrencyInfo` types | `packages/i18n/i18n/src/formatting/currency/currency.types.ts` | L1–L34 | Verbatim port; rename `CurrencyFormatOptions` → `CurrencyFormatterOptions` for symmetry |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `CurrencyFormatter` class wrapper | New — symmetric with `NumberFormatter`, `RelativeTimeFormatter`, `ListFormatter`, `DisplayNameFormatter`, `DateFormatter` |
| ISO 4217 `CurrencyCodeSchema` | New — current monolith does not validate that the currency string matches `^[A-Z]{3}$`; bogus codes today silently produce `Intl.NumberFormat` errors that surface as raw `RangeError` instead of structured `INVALID_FORMATTER_OPTIONS` |
| `CurrencyFormatterOptionsSchema` Zod schema | New — current monolith does not validate options |
| `INVALID_FORMATTER_OPTIONS` mapping | Bound to the new Zod schemas |
| Per-(locale, currency, options-hash) cache | New — current monolith constructs a fresh `Intl.NumberFormat` per call; Wave-3 formatter shape mandates the cache for parity |
| OTel histogram `i18n.format.currency.duration_ms` + cache-hit counter | New — canonical Wave-3 formatter shape mandates these |
| Dep on `@suaveplan/i18n-formatter-number` | Currency is `Intl.NumberFormat` with `style: "currency"`; the design doc §3 explicitly lists this dep so the cache + Zod patterns can be shared rather than duplicated |
## Verification approach
- `packages/i18n/i18n-formatter-currency/src/formatter.test.ts` — port of monolith `currency.test.ts`; cover `USD` / `GBP` / `EUR` / `JPY` (zero decimal digits) / `CHF`; symbol / code / name / narrowSymbol display; min/max fraction digits; class wrapper twins helper output; ISO 4217 violation (`"$"`, `"abc"`, `"USDT"`) → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`; cache reuse on identical `(locale, currency, options)`
- `packages/i18n/i18n-formatter-currency/src/formatter.spec-conformance.test.ts` — every requirement scenario runs as an executable test
- `packages/i18n/i18n-formatter-currency/src/helpers.test.ts` — `getCurrencySymbol("USD", "en-GB")` returns `"US$"` (en-GB locale); `getCurrencyName("USD", "en-GB")` returns `"US Dollar"`; `getCurrencyInfo("USD", "en-GB")` returns `{code: "USD", symbol, name, decimalDigits: 2}`
- `packages/i18n/i18n-formatter-currency/src/observability.test.ts` — histogram named `i18n.format.currency.duration_ms`; counter `i18n.format.cache_hit{formatter="currency"}` increments only on cache hit
- `packages/i18n/i18n-formatter-currency/src/index.test.ts` — every documented public export resolves; subpaths resolve; dep on `@suaveplan/i18n-formatter-number` declared in `package.json`
## Impact
- New universal-tier package — joins the rest of the i18n family.
- Only Wave-3 package with an intra-formatter dep (on `@suaveplan/i18n-formatter-number`).
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatCurrency` into the default `FormatBridge` for ICU `{amount, number, currency=USD}` placeholder rendering.
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.