Files
2026-05-27 23:29:53 +01:00

11 KiB

Capability Spec: i18n-formatter-currency (i18n-formatter-currency)

Package: @suaveplan/i18n-formatter-currency Status: ACTIVE

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.

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<string, Intl.NumberFormat> 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"