# Capability Spec: i18n-formatter-currency (i18n-formatter-currency) **Package**: `@suaveplan/i18n-formatter-currency` **Status**: PROPOSED — first delta; becomes canonical spec on archive. > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) > > Only Wave-3 formatter with a sibling-formatter dep — depends on `@suaveplan/i18n-formatter-number`. ## ADDED Requirements ### Requirement: CurrencyFormatter class wrapping `Intl.NumberFormat` (currency style) The package SHALL export a `CurrencyFormatter` class that wraps `Intl.NumberFormat` with `style: "currency"`. Construction SHALL validate the currency code against ISO 4217 (`/^[A-Z]{3}$/`) and validate options via Zod. Construction with a malformed currency code or malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`. #### Scenario: format with USD - **GIVEN** `const f = new CurrencyFormatter("USD")` - **WHEN** `f.format(99.99, "en-GB")` is called - **THEN** the result equals `"US$99.99"` (en-GB rendering of USD) #### Scenario: bogus currency code throws INVALID_FORMATTER_OPTIONS - **WHEN** `new CurrencyFormatter("usd")` is called (lowercase) - **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"` #### Scenario: too-long currency code throws INVALID_FORMATTER_OPTIONS - **WHEN** `new CurrencyFormatter("USDT")` is called - **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"` **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"CurrencyFormatter formats USD"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"CurrencyFormatter rejects lowercase currency code"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"CurrencyFormatter rejects too-long currency code"` --- ### Requirement: `formatCurrency` standalone helper The package SHALL export a `formatCurrency(amount, currency, locale, options?)` standalone helper that delivers the same behaviour as `CurrencyFormatter.format` without requiring class construction. Defaults: `display: "symbol"`. #### Scenario: standalone helper formats GBP - **WHEN** `formatCurrency(100, "GBP", "en-GB")` is called - **THEN** the result equals `"£100.00"` #### Scenario: standalone helper twins class output - **WHEN** both `formatCurrency(amt, cur, l, opts)` and `new CurrencyFormatter(cur, opts).format(amt, l)` are called for the same `(amt, cur, l, opts)` - **THEN** the results are identical **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"formatCurrency formats GBP"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"formatCurrency output twins CurrencyFormatter.format output"` --- ### Requirement: `formatCurrencyValue` returns numeric portion only The package SHALL export a `formatCurrencyValue(amount, currency, locale)` standalone helper that returns the numeric portion of the formatted currency without the symbol or code (uses `formatToParts` and filters out `currency` and `literal` parts). #### Scenario: numeric portion only - **WHEN** `formatCurrencyValue(99.99, "USD", "en-GB")` is called - **THEN** the result equals `"99.99"` (no `"US$"` prefix) **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"formatCurrencyValue returns numeric portion only"` --- ### Requirement: `display` option supports symbol / code / name / narrowSymbol The package SHALL accept a `display` option mapping to `Intl.NumberFormat`'s `currencyDisplay` field with the four documented values. Defaults to `"symbol"`. #### Scenario: display: code renders ISO code - **WHEN** `formatCurrency(100, "USD", "en-GB", { display: "code" })` is called - **THEN** the result includes `"USD"` and the numeric value #### Scenario: display: name renders currency name - **WHEN** `formatCurrency(100, "USD", "en-GB", { display: "name" })` is called - **THEN** the result includes `"US dollars"` (locale-correct rendering) #### Scenario: display: narrowSymbol renders compact symbol - **WHEN** `formatCurrency(100, "USD", "en-GB", { display: "narrowSymbol" })` is called - **THEN** the result starts with `"$"` (narrow form, no `"US"` prefix) **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"display: code renders ISO code"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"display: name renders currency name"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"display: narrowSymbol renders compact symbol"` --- ### Requirement: `Intl.NumberFormat` `RangeError` maps to `UNSUPPORTED_LOCALE` The package SHALL catch every `RangeError` thrown by the underlying `Intl.NumberFormat` constructor and re-throw it as `I18nError(I18N.UNSUPPORTED_LOCALE)` with the original error attached as `cause` and the locale string in `context`. #### Scenario: bogus locale throws UNSUPPORTED_LOCALE - **WHEN** `formatCurrency(1, "USD", "xx-bogus-tag-!!!")` is called - **THEN** the call throws `I18nError` with code `"I18N.UNSUPPORTED_LOCALE"` AND the error's `cause` is the original `RangeError` **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"` --- ### Requirement: Per-(locale, currency, options-hash) cache The package SHALL maintain an internal `Map` keyed by `${locale}|${currency}|${JSON.stringify(options)}`. Repeated calls with identical `(locale, currency, options)` SHALL reuse the same `Intl.NumberFormat` instance. The package SHALL export a `clearCurrencyFormatterCache()` test-only helper. #### Scenario: identical key reuses the same Intl.NumberFormat - **GIVEN** the cache is empty - **WHEN** `formatCurrency(1, "USD", "en-GB")` is called twice in sequence - **THEN** only one `Intl.NumberFormat` constructor invocation occurs #### Scenario: distinct currency creates distinct cache entries - **WHEN** `formatCurrency(1, "USD", "en-GB")` and `formatCurrency(1, "EUR", "en-GB")` are called - **THEN** two distinct `Intl.NumberFormat` instances are created and cached **Verified by:** `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"identical key reuses cached Intl.NumberFormat"` AND `packages/i18n/i18n-formatter-currency/src/formatter.test.ts::"distinct currency creates distinct cache entries"` --- ### Requirement: OTel histogram `i18n.format.currency.duration_ms` The package SHALL emit an OTel histogram named exactly `i18n.format.currency.duration_ms` (unit `ms`) on every successful `formatCurrency` or `formatCurrencyValue` call, tagged with `locale`, `currency`, and `display`. #### Scenario: histogram emitted on every call - **GIVEN** an OTel test exporter capturing histogram observations - **WHEN** `formatCurrency(1, "USD", "en-GB", { display: "symbol" })` is called - **THEN** exactly one observation appears under `i18n.format.currency.duration_ms` with `locale`, `currency`, and `display` attributes **Verified by:** `packages/i18n/i18n-formatter-currency/src/observability.test.ts::"emits i18n.format.currency.duration_ms histogram on every call"` --- ### Requirement: OTel cache-hit counter `i18n.format.cache_hit` The package SHALL emit an OTel counter `i18n.format.cache_hit` tagged with `formatter: "currency"` on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call. #### Scenario: cache-hit counter increments only on second call - **GIVEN** an OTel test exporter capturing counter observations and an empty cache - **WHEN** `formatCurrency(1, "USD", "en-GB")` is called twice in sequence - **THEN** the counter `i18n.format.cache_hit{formatter="currency"}` shows exactly one increment **Verified by:** `packages/i18n/i18n-formatter-currency/src/observability.test.ts::"cache-hit counter increments only on second call with same options"` --- ### Requirement: ISO 4217 metadata helpers — symbol, name, info The package SHALL export three metadata helpers: - `getCurrencySymbol(currency, locale, display?)` — returns the symbol string per the configured `display` mode (default `"symbol"`) - `getCurrencyName(currency, locale)` — returns the localized currency name - `getCurrencyInfo(currency, locale)` — returns `{ code, symbol, name, decimalDigits }` aggregating the previous two plus the runtime-resolved decimal-digit count #### Scenario: getCurrencySymbol renders en-GB USD symbol - **WHEN** `getCurrencySymbol("USD", "en-GB")` is called - **THEN** the result equals `"US$"` (en-GB locale rendering) #### Scenario: getCurrencyName renders US Dollar - **WHEN** `getCurrencyName("USD", "en-GB")` is called - **THEN** the result equals `"US Dollar"` #### Scenario: getCurrencyInfo aggregates symbol, name, decimal digits - **WHEN** `getCurrencyInfo("USD", "en-GB")` is called - **THEN** the result equals `{ code: "USD", symbol: "US$", name: "US Dollar", decimalDigits: 2 }` #### Scenario: JPY has zero decimal digits - **WHEN** `getCurrencyInfo("JPY", "en-GB")` is called - **THEN** the `decimalDigits` field equals `0` **Verified by:** `packages/i18n/i18n-formatter-currency/src/helpers.test.ts::"getCurrencySymbol renders en-GB USD symbol"` AND `packages/i18n/i18n-formatter-currency/src/helpers.test.ts::"getCurrencyName renders US Dollar"` AND `packages/i18n/i18n-formatter-currency/src/helpers.test.ts::"getCurrencyInfo aggregates symbol, name, decimal digits"` AND `packages/i18n/i18n-formatter-currency/src/helpers.test.ts::"getCurrencyInfo JPY has zero decimal digits"` --- ### Requirement: Subpath exports The package SHALL declare subpath exports in `package.json`: - `@suaveplan/i18n-formatter-currency` — root barrel - `@suaveplan/i18n-formatter-currency/formatter` — `CurrencyFormatter` class + `formatCurrency` + `formatCurrencyValue` + types + schema only - `@suaveplan/i18n-formatter-currency/helpers` — `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo` only #### Scenario: every documented subpath resolves - **WHEN** every documented subpath is imported in a consumer test fixture - **THEN** every import resolves with no `Module not found` errors **Verified by:** `packages/i18n/i18n-formatter-currency/src/index.test.ts::"every documented subpath export resolves"` --- ### Requirement: Sibling dep on `@suaveplan/i18n-formatter-number` The package SHALL declare `@suaveplan/i18n-formatter-number` as a `workspace:^` dependency in `package.json`. This dep is documented because currency formatting is `Intl.NumberFormat({ style: "currency", ... })` — the two packages share the same Zod-validation + cache + OTel pattern. #### Scenario: package.json declares dep on i18n-formatter-number - **WHEN** `package.json` is parsed - **THEN** `dependencies["@suaveplan/i18n-formatter-number"]` equals `"workspace:^"` **Verified by:** `packages/i18n/i18n-formatter-currency/src/index.test.ts::"package.json declares dep on @suaveplan/i18n-formatter-number"` --- ### Requirement: Side-effect-free package The package SHALL declare `"sideEffects": false` in `package.json`. #### Scenario: package.json declares sideEffects false - **WHEN** `package.json` is parsed - **THEN** the `sideEffects` field equals `false` **Verified by:** `packages/i18n/i18n-formatter-currency/src/index.test.ts::"package.json declares sideEffects false"`