Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
# @suaveplan/i18n-formatter-currency - Locale-aware currency formatting backed by Intl.NumberFormat({ style: 'currency' }). Class wrapper + standalone helper + currency-info helpers (getCurrencySymbol, getCurrencyName, getCurrencyInfo), Zod-validated ISO 4217 codes, per-(locale, currency, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
## Summary
Locale-aware currency formatting backed by `Intl.NumberFormat` with
`style: "currency"`. Wave 3 of the 14-package i18n modular split.
Provides both a class (`CurrencyFormatter`) and a standalone helper
(`formatCurrency`) plus value-only formatting (`formatCurrencyValue`)
and currency-info helpers (`getCurrencySymbol`, `getCurrencyName`,
`getCurrencyInfo`) on top of a per-`(locale, currency, options-hash)`
cache, Zod-validated ISO 4217 codes, and the canonical OTel histogram
+ cache-hit counter that every Wave 3 formatter package emits.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — Locale-aware currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class…
- [formatter](./src/formatter/formatter.md) — Locale-aware currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class…
- [helpers](./src/helpers/helpers.md) — Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use thes…
- [helpers](./src/helpers/helpers.md) — Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use thes…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.curre…
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.curre…
<!-- end:auto-docs -->
## Why this exists
Each `Intl.*` wrapper lives in its own sibling package so consumers
can pull only the formatters they actually use. This package is the
only Wave 3 formatter with an intra-formatter dependency: it depends
on `@suaveplan/i18n-formatter-number workspace:^` because currency
formatting is `Intl.NumberFormat({ style: "currency" })`. The dep is
pinned in `package.json` per the design doc §3 so the slim core
(Wave 6) can wire `formatCurrency` and `formatNumber` consistently
into the default `FormatBridge`.
## Install
```bash
bun add @suaveplan/i18n-formatter-currency
```
## Quick Start
```ts
import {
CurrencyFormatter,
formatCurrency,
formatCurrencyValue,
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "@suaveplan/i18n-formatter-currency";
formatCurrency(1234.56, "USD", "en"); // → "$1,234.56"
formatCurrency(1234.56, "EUR", "de"); // → "1.234,56 €"
formatCurrency(100, "CHF", "en", { display: "code" }); // → "CHF 100.00"
formatCurrencyValue(9.99, "USD", "en"); // → "9.99"
getCurrencySymbol("GBP", "en"); // → "£"
getCurrencyName("USD", "fr"); // → "dollar des États-Unis"
getCurrencyInfo("EUR", "en-US");
// → { code: "EUR", symbol: "€", name: "Euro", decimalDigits: 2 }
const usd = new CurrencyFormatter("USD", { minimumFractionDigits: 2 });
usd.format(42, "en-US"); // → "$42.00"
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-currency` | Root barrel — class + standalone + helpers + observability |
| `@suaveplan/i18n-formatter-currency/formatter` | `CurrencyFormatter`, `formatCurrency`, `formatCurrencyValue`, types, schemas |
| `@suaveplan/i18n-formatter-currency/helpers` | `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo` only |
## Modules Documentation
+ [formatter.md](src/formatter.md) — `CurrencyFormatter`, `formatCurrency`, `formatCurrencyValue`
+ [helpers.md](src/helpers.md) — `getCurrencySymbol`, `getCurrencyName`, `getCurrencyInfo`
+ [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `CurrencyFormatter` | class | `formatter.ts` |
| `formatCurrency` | function | `formatter.ts` |
| `formatCurrencyValue` | function | `formatter.ts` |
| `clearCurrencyFormatterCache` | function | `formatter.ts` |
| `toBcp47` | function | `formatter.ts` |
| `CurrencyCodeSchema` | const | `formatter.schemas.ts` |
| `CurrencyFormatterOptionsSchema` | const | `formatter.schemas.ts` |
| `CommonCurrency` | type | `formatter.types.ts` |
| `CurrencyDisplay` | type | `formatter.types.ts` |
| `CurrencyFormatterOptions` | type | `formatter.types.ts` |
| `CurrencyInfo` | type | `formatter.types.ts` |
| `CurrencyFormatterCacheKey` | type | `formatter.types.ts` |
| `getCurrencySymbol` | function | `helpers.ts` |
| `getCurrencyName` | function | `helpers.ts` |
| `getCurrencyInfo` | function | `helpers.ts` |
| `recordCurrencyFormat` | function | `observability.ts` |
| `recordCurrencyCacheHit` | function | `observability.ts` |
| `getCurrencyFormatMetricsState` | function | `observability.ts` |
| `resetCurrencyFormatMetricsState` | function | `observability.ts` |
## Versioning
This package is part of the i18n modular-split rollout. Stays at
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0` release.
## Wave 3 Scope
This package is one of eight Wave 3 formatter packages and the only
Wave 3 formatter with an intra-formatter dependency. Pure addition —
`packages/i18n/i18n/src/formatting/currency/` is preserved in the
monolith until Wave 6 deletes it.

View File

@@ -0,0 +1,76 @@
{
"name": "@suaveplan/i18n-formatter-currency",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Locale-aware currency formatting backed by Intl.NumberFormat({ style: 'currency' }). Class wrapper + standalone helper + currency-info helpers (getCurrencySymbol, getCurrencyName, getCurrencyInfo), Zod-validated ISO 4217 codes, per-(locale, currency, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./formatter": {
"import": {
"types": "./dist/formatter/index.d.ts",
"default": "./dist/formatter/index.js"
}
},
"./helpers": {
"import": {
"types": "./dist/helpers/index.d.ts",
"default": "./dist/helpers/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-formatter-number": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,36 @@
import z from "zod";
/**
* ISO 4217 currency-code validator. Accepts exactly three uppercase
* ASCII letters; bogus codes (`"$"`, `"abc"`, `"USDT"`) fail Zod
* validation rather than fall through to a raw `RangeError` from
* `Intl.NumberFormat`.
*/
export const CurrencyCodeSchema = z
.string()
.regex(/^[A-Z]{3}$/, "currency must be a 3-letter ISO 4217 code");
export const CurrencyFormatterOptionsSchema = z
.object({
display: z.enum(["symbol", "code", "name", "narrowSymbol"]).optional(),
minimumFractionDigits: z.number().int().min(0).max(20).optional(),
maximumFractionDigits: z.number().int().min(0).max(20).optional(),
})
.superRefine((opts, ctx) => {
if (
opts.minimumFractionDigits !== undefined &&
opts.maximumFractionDigits !== undefined &&
opts.minimumFractionDigits > opts.maximumFractionDigits
) {
ctx.addIssue({
code: "custom",
path: ["fractionDigits"],
message:
"minimumFractionDigits must be <= maximumFractionDigits",
});
}
});
export type CurrencyFormatterOptionsInput = z.input<
typeof CurrencyFormatterOptionsSchema
>;

View File

@@ -0,0 +1,177 @@
/**
* Spec-conformance pins for CurrencyFormatter — Zod rejection paths
* (currency code + options), Intl.RangeError mapping, OTel emission,
* and cache reuse semantics.
*/
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type {
CurrencyDisplay,
CurrencyFormatterCacheKey,
CurrencyFormatterOptions,
} from "./formatter/formatter.js";
import {
CurrencyCodeSchema,
CurrencyFormatter,
CurrencyFormatterOptionsSchema,
clearCurrencyFormatterCache,
formatCurrency,
} from "./formatter/formatter.js";
import {
getCurrencyFormatMetricsState,
resetCurrencyFormatMetricsState,
} from "./observability/observability.js";
describe("CurrencyFormatter spec-conformance", () => {
it("rejects non-object options shape (no path → field='options')", {
timeout: 5000,
}, () => {
try {
formatCurrency(1, "USD", "en", "not-an-object" as never);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "options",
});
}
});
it("rejects unknown display with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new CurrencyFormatter("USD", { display: "magic" as never });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "display",
});
}
});
it("rejects ISO 4217 violation with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
formatCurrency(1, "abc", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "currency",
});
}
});
it("rejects fraction-digit inversion", {
timeout: 5000,
}, () => {
try {
formatCurrency(1, "USD", "en", {
minimumFractionDigits: 4,
maximumFractionDigits: 2,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "fractionDigits",
});
}
});
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
try {
formatCurrency(1, "USD", "en US");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
}
});
it("reuses cached Intl.NumberFormat on identical key", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(1, "USD", "en-US");
formatCurrency(1, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("currency");
});
it("does not reuse cache across distinct currencies", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(1, "USD", "en-US");
formatCurrency(1, "EUR", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.cacheHitSamples).toHaveLength(0);
});
it("records a duration_ms sample per call", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(1, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(1);
expect(state.currencyDurationSamples[0]?.attributes).toMatchObject({
locale: "en-US",
currency: "USD",
display: "symbol",
});
});
it("type assertions pin the public surface", { timeout: 5000 }, () => {
expectTypeOf<CurrencyDisplay>().toEqualTypeOf<
"symbol" | "code" | "name" | "narrowSymbol"
>();
expectTypeOf<CurrencyFormatterCacheKey>().toEqualTypeOf<string>();
expectTypeOf<CurrencyFormatterOptions>().toMatchTypeOf<{
readonly display?: CurrencyDisplay;
}>();
});
it("CurrencyCodeSchema accepts well-formed codes", {
timeout: 5000,
}, () => {
expect(CurrencyCodeSchema.safeParse("USD").success).toBe(true);
expect(CurrencyCodeSchema.safeParse("EUR").success).toBe(true);
expect(CurrencyCodeSchema.safeParse("USDT").success).toBe(false);
expect(CurrencyCodeSchema.safeParse("usd").success).toBe(false);
});
it("CurrencyFormatterOptionsSchema accepts a well-formed options bag", {
timeout: 5000,
}, () => {
const parsed = CurrencyFormatterOptionsSchema.safeParse({
display: "code",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
expect(parsed.success).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
/** Currency formatter type definitions. */
/** Common ISO 4217 currency codes (advisory only — runtime accepts any 3-letter ISO 4217 code). */
export type CommonCurrency =
| "USD"
| "EUR"
| "GBP"
| "JPY"
| "CNY"
| "CHF"
| "AUD"
| "CAD"
| "SEK"
| "NOK"
| "DKK";
export type CurrencyDisplay = "symbol" | "code" | "name" | "narrowSymbol";
export interface CurrencyFormatterOptions {
readonly display?: CurrencyDisplay;
readonly minimumFractionDigits?: number;
readonly maximumFractionDigits?: number;
}
/** Currency metadata returned by `getCurrencyInfo`. */
export interface CurrencyInfo {
readonly code: string;
readonly symbol: string;
readonly name: string;
readonly decimalDigits: number;
}
/** Cache key shape: `${locale}|${currency}|${jsonOptions}`. */
export type CurrencyFormatterCacheKey = string;

View File

@@ -0,0 +1,103 @@
# CurrencyFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Locale-aware currency formatting backed by `Intl.NumberFormat({ style: "currency" })`. The package exposes both a class (`CurrencyFormatter` — construct once with stable currency + options, reuse) and a standalone helper (`formatCurrency(amount, currency, locale, options?)`). Every call validates the currency code through `CurrencyCodeSchema` (Zod) at the boundary (rejecting bogus codes like `"$"`, `"abc"`, `"USDT"` with a structured `INVALID_FORMATTER_OPTIONS` rather than letting them surface as raw `RangeError` from `Intl.NumberFormat`), validates options through `CurrencyFormatterOptionsSchema`, and emits a wall-clock duration sample on the `i18n.format.currency.duration_ms` histogram tagged with `locale`, `currency`, and `display`.
This package is the only Wave 3 formatter that depends on a sibling formatter — it declares `@suaveplan/i18n-formatter-number workspace:^` because currency formatting is `Intl.NumberFormat` with `style: "currency"`. The dep is documentation-grade today (the implementation constructs its own `Intl.NumberFormat`) but pins the architectural relationship the slim core (Wave 6) will exercise when wiring the default `FormatBridge`.
A per-`(locale, currency, options-hash)` cache for `Intl.NumberFormat` instances amortises construction cost across many price renders. Cache reuse increments the `i18n.format.cache_hit{formatter="currency"}` counter.
## Features
- Class form (`CurrencyFormatter`) for fixed-currency reuse + standalone helper (`formatCurrency`) for one-off calls
- `formatCurrencyValue` returns the numeric portion only (no symbol) — useful for custom rendering layouts
- BCP-47 locale resolution via the shared `LOCALE_BCP47_MAP` (re-exported by `@suaveplan/i18n-shared`)
- Display modes: `"symbol"` (default), `"code"`, `"name"`, `"narrowSymbol"`
- ISO 4217 validation enforced by Zod (`/^[A-Z]{3}$/`)
- `minimumFractionDigits` / `maximumFractionDigits` Zod-validated; inverted pair rejected as `field === 'fractionDigits'`
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
- Process-wide cache; `clearCurrencyFormatterCache()` for tests
- OTel instrumentation: `i18n.format.currency.duration_ms` histogram, `i18n.format.cache_hit{formatter="currency"}` counter
## Usage: Basic Example
```typescript
import {
CurrencyFormatter,
formatCurrency,
formatCurrencyValue,
} from "@suaveplan/i18n-formatter-currency";
formatCurrency(1234.56, "USD", "en");
// → "$1,234.56"
formatCurrency(1234.56, "EUR", "de");
// → "1.234,56 €"
formatCurrency(100, "CHF", "en", { display: "code" });
// → "CHF 100.00"
formatCurrencyValue(1234.56, "USD", "en");
// → "1,234.56"
```
## Usage: Advanced Example — Class Form for Repeated Use
```typescript
import { CurrencyFormatter } from "@suaveplan/i18n-formatter-currency";
const usd = new CurrencyFormatter("USD", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Many call sites, single options + currency validation:
const lines = lineItems.map((item) => ({
label: item.label,
amount: usd.format(item.price, "en-US"),
}));
```
## API Reference
### `formatCurrency(amount, currency, locale, options?): string`
Formats `amount` per `currency`, `locale`, and `options`. Validates currency code through Zod (must match `/^[A-Z]{3}$/`), validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on either failure. Maps `Intl.NumberFormat`'s `RangeError` for unsupported locales / currencies to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
### `formatCurrencyValue(amount, currency, locale): string`
Returns the numeric portion of the formatted currency string with the currency symbol and literal whitespace separators stripped via `formatToParts`. Reuses the same per-(locale, currency, options-hash) cache as `formatCurrency`.
### `class CurrencyFormatter`
- `constructor(currency: string, options?: CurrencyFormatterOptions)` — validates currency code + options at construction
- `format(amount: number, locale: string): string` — formats with stored currency + options
### `clearCurrencyFormatterCache(): void`
Test-only.
### `toBcp47(locale): string`
Internal BCP-47 normaliser exposed for re-use; consults `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared` for known short codes (`"en"` → `"en-GB"`), tries the lowercase fallback, then passes through unchanged.
### `CurrencyCodeSchema` and `CurrencyFormatterOptionsSchema`
Zod schemas validating the ISO 4217 code and options surface respectively.
### Types
`CommonCurrency`, `CurrencyDisplay`, `CurrencyFormatterOptions`, `CurrencyInfo`, `CurrencyFormatterCacheKey`.
## Implementation Notes
The cache key is `${locale}|${currency}|${JSON.stringify(options)}`. Including the currency in the key (rather than rolling it into the options bag) keeps `formatCurrencyValue` cache-coherent with `formatCurrency` for the same `(locale, currency)` pair under default options — both call sites resolve to the same `Intl.NumberFormat` instance.
`formatCurrencyValue` filters `formatToParts` for non-`"currency"` and non-`"literal"` parts. This correctly handles locales that place the symbol after the number (e.g. some European locales) without regex hacks. The `.trim()` at the end normalises any leading/trailing whitespace produced by edge-case locales.
ISO 4217 validation is intentionally strict — `/^[A-Z]{3}$/` rejects every code that is not exactly three uppercase letters. Cryptocurrencies like `BTC` (3 letters, all upper) pass the regex but fail at `Intl.NumberFormat` level if the runtime ICU does not recognise them; both failure modes funnel into structured `I18nError` codes (`INVALID_FORMATTER_OPTIONS` for the regex case, `UNSUPPORTED_LOCALE` for the ICU case). For non-ISO-4217 cryptocurrencies the dedicated `@suaveplan/cryptocurrency` package owns formatting.

View File

@@ -0,0 +1,262 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CurrencyFormatter,
clearCurrencyFormatterCache,
formatCurrency,
formatCurrencyValue,
toBcp47,
} from "./formatter.js";
describe("formatCurrency", () => {
it("formats USD in English", { timeout: 5000 }, () => {
const r = formatCurrency(1234.56, "USD", "en");
expect(r).toContain("1,234.56");
expect(r).toContain("$");
});
it("formats EUR in German", { timeout: 5000 }, () => {
const r = formatCurrency(1234.56, "EUR", "de");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("supports code display", { timeout: 5000 }, () => {
const r = formatCurrency(100, "USD", "en", { display: "code" });
expect(r).toContain("USD");
});
it("supports name display", { timeout: 5000 }, () => {
const r = formatCurrency(1, "USD", "en", { display: "name" });
expect(typeof r).toBe("string");
});
it("supports minimumFractionDigits", { timeout: 5000 }, () => {
const r = formatCurrency(100, "USD", "en", {
minimumFractionDigits: 0,
});
expect(typeof r).toBe("string");
});
it("handles BCP-47 locale passthrough", { timeout: 5000 }, () => {
const r = formatCurrency(50, "GBP", "en-GB");
expect(typeof r).toBe("string");
});
it("passes through unknown locale codes unchanged", {
timeout: 5000,
}, () => {
const r = formatCurrency(100, "AUD", "en-AU");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("normalises uppercase locale code via toLowerCase fallback", {
timeout: 5000,
}, () => {
const r = formatCurrency(100, "USD", "EN");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("CurrencyFormatter rejects invalid currency code with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new CurrencyFormatter("$");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "currency",
});
}
});
it("formatCurrency rejects 4-letter code (e.g. USDT)", {
timeout: 5000,
}, () => {
try {
formatCurrency(1, "USDT", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
it("formatCurrency rejects lowercase ISO code", { timeout: 5000 }, () => {
try {
formatCurrency(1, "usd", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
it("rejects fraction-digit inversion", { timeout: 5000 }, () => {
try {
formatCurrency(1, "USD", "en", {
minimumFractionDigits: 4,
maximumFractionDigits: 2,
});
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
expect((err as I18nError).context).toMatchObject({
field: "fractionDigits",
});
}
});
it("unsupported locale throws UNSUPPORTED_LOCALE with cause", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
try {
formatCurrency(1, "USD", "en US");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
expect((err as I18nError).cause).toBeInstanceOf(RangeError);
}
});
it("identical key reuses cached Intl.NumberFormat", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
formatCurrency(1, "USD", "en-US");
const original = Intl.NumberFormat;
let constructed = 0;
const spy = function (
this: unknown,
...args: ConstructorParameters<typeof Intl.NumberFormat>
): Intl.NumberFormat {
constructed += 1;
return new original(...args);
} as unknown as typeof Intl.NumberFormat;
Object.defineProperty(globalThis.Intl, "NumberFormat", {
value: spy,
configurable: true,
});
try {
formatCurrency(1, "USD", "en-US");
} finally {
Object.defineProperty(globalThis.Intl, "NumberFormat", {
value: original,
configurable: true,
});
}
expect(constructed).toBe(0);
});
it("formatCurrency output twins CurrencyFormatter.format output", {
timeout: 5000,
}, () => {
const opts = { display: "code" as const };
const standalone = formatCurrency(42, "USD", "en-US", opts);
const klass = new CurrencyFormatter("USD", opts).format(42, "en-US");
expect(standalone).toBe(klass);
});
});
describe("formatCurrencyValue", () => {
it("returns numeric value without currency symbol", {
timeout: 5000,
}, () => {
const r = formatCurrencyValue(1234.56, "USD", "en");
expect(r).not.toContain("$");
expect(r).toContain("1,234.56");
});
it("returns trimmed string", { timeout: 5000 }, () => {
const r = formatCurrencyValue(9.99, "EUR", "fr");
expect(r.trim()).toBe(r);
});
it("rejects invalid currency code", { timeout: 5000 }, () => {
try {
formatCurrencyValue(1, "$", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
});
describe("CurrencyFormatter class", () => {
it("formats via stored currency + options", { timeout: 5000 }, () => {
const formatter = new CurrencyFormatter("USD", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
expect(formatter.format(42, "en-US")).toBe("$42.00");
});
it("constructs with no options (defaults to {})", {
timeout: 5000,
}, () => {
const formatter = new CurrencyFormatter("USD");
expect(formatter.format(1, "en-US")).toContain("$");
});
it("validates options at construction", { timeout: 5000 }, () => {
try {
new CurrencyFormatter("USD", {
display: "magic" as never,
});
expect.fail("expected throw");
} catch (err) {
expect((err as Error).message).toContain("display");
}
});
});
describe("toBcp47", () => {
it("returns the direct map entry for known short codes", {
timeout: 5000,
}, () => {
// "en" maps to "en-GB" per LOCALE_BCP47_MAP.
expect(toBcp47("en")).toBe("en-GB");
});
it("falls back to the lowercase entry for upper-case input", {
timeout: 5000,
}, () => {
expect(toBcp47("EN")).toBe("en-GB");
});
it("passes unknown locale codes through unchanged", {
timeout: 5000,
}, () => {
expect(toBcp47("zz-Unknown")).toBe("zz-Unknown");
});
});
describe("clearCurrencyFormatterCache", () => {
it("drops cached Intl.NumberFormat instances", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
formatCurrency(1, "USD", "en-US");
formatCurrency(1, "USD", "en-US");
clearCurrencyFormatterCache();
expect(formatCurrency(1, "USD", "en-US")).toContain("$");
});
});

View File

@@ -0,0 +1,184 @@
import {
I18N_CODES,
I18nError,
LOCALE_BCP47_MAP,
} from "@suaveplan/i18n-shared";
import {
CurrencyCodeSchema,
CurrencyFormatterOptionsSchema,
} from "../formatter.schemas.js";
import type {
CurrencyDisplay,
CurrencyFormatterCacheKey,
CurrencyFormatterOptions,
} from "../formatter.types.js";
import {
recordCurrencyCacheHit,
recordCurrencyFormat,
} from "../observability/observability.js";
const cache = new Map<CurrencyFormatterCacheKey, Intl.NumberFormat>();
function buildKey(
locale: string,
currency: string,
options: CurrencyFormatterOptions
): CurrencyFormatterCacheKey {
return `${locale}|${currency}|${JSON.stringify(options)}`;
}
/**
* BCP-47 locale normaliser. First tries the direct lookup, then a
* case-insensitive fallback, then passes the locale through unchanged.
* This mirrors the monolith's behaviour (currency.ts L4–L10) and keeps
* the `LOCALE_BCP47_MAP` lookup behind a single function.
*/
export function toBcp47(locale: string): string {
const direct = LOCALE_BCP47_MAP[locale];
if (direct !== undefined) return direct;
const lower = LOCALE_BCP47_MAP[locale.toLowerCase()];
if (lower !== undefined) return lower;
return locale;
}
function validateCurrencyCode(currency: string): void {
const parsed = CurrencyCodeSchema.safeParse(currency);
if (parsed.success) return;
throw new I18nError(
I18N_CODES.INVALID_FORMATTER_OPTIONS,
`CurrencyFormatter: invalid currency code '${currency}'`,
{ context: { field: "currency", currency } }
);
}
function validateOptions(options: CurrencyFormatterOptions): void {
const parsed = CurrencyFormatterOptionsSchema.safeParse(options);
if (parsed.success) return;
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
throw new I18nError(
I18N_CODES.INVALID_FORMATTER_OPTIONS,
`CurrencyFormatter: invalid options (field: ${field})`,
{ context: { field, issues: parsed.error.issues } }
);
}
function buildIntl(
locale: string,
currency: string,
options: CurrencyFormatterOptions
): Intl.NumberFormat {
try {
return new Intl.NumberFormat(toBcp47(locale), {
style: "currency",
currency,
currencyDisplay: options.display ?? "symbol",
minimumFractionDigits: options.minimumFractionDigits,
maximumFractionDigits: options.maximumFractionDigits,
});
} catch (cause) {
throw new I18nError(
I18N_CODES.UNSUPPORTED_LOCALE,
`CurrencyFormatter: unsupported locale '${locale}' (or unsupported currency '${currency}')`,
{ cause, context: { locale, currency } }
);
}
}
function getOrCreate(
locale: string,
currency: string,
options: CurrencyFormatterOptions
): { formatter: Intl.NumberFormat; cached: boolean } {
const key = buildKey(locale, currency, options);
const existing = cache.get(key);
if (existing) return { formatter: existing, cached: true };
const formatter = buildIntl(locale, currency, options);
cache.set(key, formatter);
return { formatter, cached: false };
}
/**
* Standalone currency-formatting helper. Wraps `Intl.NumberFormat` with
* `style: "currency"`, validates ISO 4217 codes through Zod, validates
* options through Zod, caches the underlying `Intl.NumberFormat` per
* `(locale, currency, options-hash)`, and records OTel histogram +
* cache-hit counter.
*/
export function formatCurrency(
amount: number,
currency: string,
locale: string,
options: CurrencyFormatterOptions = {}
): string {
validateCurrencyCode(currency);
validateOptions(options);
const t0 = performance.now();
const display: CurrencyDisplay = options.display ?? "symbol";
const { formatter, cached: hit } = getOrCreate(locale, currency, options);
const out = formatter.format(amount);
if (hit) recordCurrencyCacheHit();
recordCurrencyFormat(locale, currency, display, performance.now() - t0);
return out;
}
/**
* Format a monetary amount without the currency symbol. Returns only
* the numeric portion (integer + decimals). Reuses the cached
* `Intl.NumberFormat` instance so dashboards rendering many bare
* amounts share construction cost with the symbol-rendering call site.
*/
export function formatCurrencyValue(
amount: number,
currency: string,
locale: string
): string {
validateCurrencyCode(currency);
const { formatter } = getOrCreate(locale, currency, {});
const parts = formatter.formatToParts(amount);
return parts
.filter((p) => p.type !== "currency" && p.type !== "literal")
.map((p) => p.value)
.join("")
.trim();
}
/**
* Class wrapper for `formatCurrency` with construction-time validation
* of stable currency + options. Symmetric with `NumberFormatter` /
* `RelativeTimeFormatter` / `ListFormatter`.
*/
export class CurrencyFormatter {
private readonly currency: string;
private readonly options: CurrencyFormatterOptions;
public constructor(
currency: string,
options: CurrencyFormatterOptions = {}
) {
validateCurrencyCode(currency);
validateOptions(options);
this.currency = currency;
this.options = options;
}
public format(amount: number, locale: string): string {
return formatCurrency(amount, this.currency, locale, this.options);
}
}
/** Test-only: clear the per-(locale, currency, options-hash) cache. */
export function clearCurrencyFormatterCache(): void {
cache.clear();
}
export {
CurrencyCodeSchema,
CurrencyFormatterOptionsSchema,
} from "../formatter.schemas.js";
export type {
CommonCurrency,
CurrencyDisplay,
CurrencyFormatterCacheKey,
CurrencyFormatterOptions,
CurrencyInfo,
} from "../formatter.types.js";

View File

@@ -0,0 +1,16 @@
export type {
CommonCurrency,
CurrencyDisplay,
CurrencyFormatterCacheKey,
CurrencyFormatterOptions,
CurrencyInfo,
} from "./formatter.js";
export {
CurrencyCodeSchema,
CurrencyFormatter,
CurrencyFormatterOptionsSchema,
clearCurrencyFormatterCache,
formatCurrency,
formatCurrencyValue,
toBcp47,
} from "./formatter.js";

View File

@@ -0,0 +1,74 @@
# Currency Helper Functions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Currency-info helpers exposing structured metadata about ISO 4217 codes via `Intl.NumberFormat.formatToParts`. Use these when you need just the symbol, the localised name, or the full record (`{ code, symbol, name, decimalDigits }`) — without rendering an actual amount.
Each helper validates the currency code through the same `CurrencyCodeSchema` (Zod) used by the main `formatCurrency` standalone, so bogus codes (`"$"`, `"abc"`, `"USDT"`) raise structured `I18nError(INVALID_FORMATTER_OPTIONS)` rather than surfacing as raw `RangeError` from the underlying `Intl.NumberFormat`. Locale resolution goes through `toBcp47` from `formatter.ts` so callers can pass either a short code (`"en"` → `"en-GB"`) or a full BCP-47 tag.
## Features
- `getCurrencySymbol(currency, locale, display?)` — symbol / narrow symbol / code lookup
- `getCurrencyName(currency, locale)` — localised display name
- `getCurrencyInfo(currency, locale)` — aggregate `{ code, symbol, name, decimalDigits }` record
- ISO 4217 validation enforced before any `Intl.NumberFormat` construction
- Locale resolution via `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared`
## Usage: Basic Example
```typescript
import {
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "@suaveplan/i18n-formatter-currency/helpers";
getCurrencySymbol("GBP", "en"); // → "£"
getCurrencySymbol("USD", "en", "code"); // → "USD"
getCurrencyName("USD", "fr"); // → "dollar des États-Unis"
getCurrencyInfo("EUR", "en-US");
// → { code: "EUR", symbol: "€", name: "Euro", decimalDigits: 2 }
```
## Usage: Advanced Example — Currency picker
```typescript
import { getCurrencyInfo } from "@suaveplan/i18n-formatter-currency/helpers";
const supported = ["USD", "EUR", "GBP", "JPY", "CHF"];
function buildCurrencyOptions(locale) {
return supported.map((code) => {
const info = getCurrencyInfo(code, locale);
return {
value: info.code,
label: `${info.symbol} ${info.name} (${info.code})`,
decimals: info.decimalDigits,
};
});
}
```
## API Reference
### `getCurrencySymbol(currency, locale, display?)`
Returns the currency symbol string. The `display` argument restricts to `"symbol"` (default), `"narrowSymbol"`, or `"code"` — the same surface as the corresponding `Intl.NumberFormat` option restricted to the symbol-shaped subset. Throws `I18nError(INVALID_FORMATTER_OPTIONS)` if the currency code does not match `/^[A-Z]{3}$/`.
### `getCurrencyName(currency, locale)`
Returns the localised display name (e.g. `"US dollar"` in `en`, `"dollar américain"` in `fr`). Throws `I18nError(INVALID_FORMATTER_OPTIONS)` for malformed currency codes.
### `getCurrencyInfo(currency, locale)`
Returns an aggregate `CurrencyInfo` record containing the code, symbol, localised name, and decimal-digit count. Decimal digits are resolved from `formatter.resolvedOptions().maximumFractionDigits`, ensuring JPY returns 0, BHD returns 3, and most other currencies return 2 automatically.
## Implementation Notes
The helpers do NOT participate in the `formatCurrency` cache because they ask `Intl.NumberFormat` for a different option set per call (`currencyDisplay: "code"` for symbol-as-code, `currencyDisplay: "name"` for the name). Sharing a cache would require keying on those options, which would defeat the simpler `(locale, currency, options-hash)` shape used by the main formatter.
The `formatToParts(0)` / `formatToParts(1)` calls use a small constant amount because some locales render different symbols at different magnitudes (rare but possible in less-common ICU builds). Using `0` for symbol lookup and `1` for name lookup matches the monolith's behaviour for cross-package compatibility.

View File

@@ -0,0 +1,93 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "./helpers.js";
describe("getCurrencySymbol", () => {
it("returns $ for USD in en-US", { timeout: 5000 }, () => {
const sym = getCurrencySymbol("USD", "en-US");
expect(sym).toBe("$");
});
it("returns £ for GBP in en", { timeout: 5000 }, () => {
expect(getCurrencySymbol("GBP", "en")).toBe("£");
});
it("returns narrowSymbol", { timeout: 5000 }, () => {
const r = getCurrencySymbol("USD", "en", "narrowSymbol");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("returns code display", { timeout: 5000 }, () => {
const r = getCurrencySymbol("USD", "en", "code");
expect(r).toBe("USD");
});
it("rejects invalid currency code", { timeout: 5000 }, () => {
try {
getCurrencySymbol("$", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
});
describe("getCurrencyName", () => {
it("returns US dollar for USD in en", { timeout: 5000 }, () => {
const r = getCurrencyName("USD", "en");
expect(r.toLowerCase()).toContain("dollar");
});
it("returns localised name in French", { timeout: 5000 }, () => {
const r = getCurrencyName("USD", "fr");
expect(typeof r).toBe("string");
expect(r.length).toBeGreaterThan(0);
});
it("rejects invalid currency code", { timeout: 5000 }, () => {
try {
getCurrencyName("$", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
});
describe("getCurrencyInfo", () => {
it("returns info for USD", { timeout: 5000 }, () => {
const info = getCurrencyInfo("USD", "en-US");
expect(info.code).toBe("USD");
expect(info.symbol).toBe("$");
expect(info.decimalDigits).toBe(2);
expect(typeof info.name).toBe("string");
});
it("returns 0 decimal digits for JPY", { timeout: 5000 }, () => {
const info = getCurrencyInfo("JPY", "en");
expect(info.decimalDigits).toBe(0);
});
it("rejects invalid currency code", { timeout: 5000 }, () => {
try {
getCurrencyInfo("$", "en");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
I18N_CODES.INVALID_FORMATTER_OPTIONS
);
}
});
});

View File

@@ -0,0 +1,75 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { toBcp47 } from "../formatter/formatter.js";
import { CurrencyCodeSchema } from "../formatter.schemas.js";
import type { CurrencyDisplay, CurrencyInfo } from "../formatter.types.js";
function validateCurrencyCode(currency: string): void {
const parsed = CurrencyCodeSchema.safeParse(currency);
if (parsed.success) return;
throw new I18nError(
I18N_CODES.INVALID_FORMATTER_OPTIONS,
`currency-info: invalid currency code '${currency}'`,
{ context: { field: "currency", currency } }
);
}
/**
* Get the currency symbol for a currency in a given locale.
* Pass `display: "narrowSymbol"` for compact symbols where available;
* `display: "code"` returns the ISO code itself.
*/
export function getCurrencySymbol(
currency: string,
locale: string,
display: Extract<
CurrencyDisplay,
"symbol" | "narrowSymbol" | "code"
> = "symbol"
): string {
validateCurrencyCode(currency);
const parts = new Intl.NumberFormat(toBcp47(locale), {
style: "currency",
currency,
currencyDisplay: display,
}).formatToParts(0);
// Every conforming Intl.NumberFormat with style=currency emits a
// "currency" part, so the cast is sound after validateCurrencyCode.
return (parts.find((p) => p.type === "currency") as Intl.NumberFormatPart)
.value;
}
/**
* Get the localised display name of a currency in a given locale
* (e.g. `"US dollar"` in `en`, `"dollar américain"` in `fr`).
*/
export function getCurrencyName(currency: string, locale: string): string {
validateCurrencyCode(currency);
const parts = new Intl.NumberFormat(toBcp47(locale), {
style: "currency",
currency,
currencyDisplay: "name",
}).formatToParts(1);
return (parts.find((p) => p.type === "currency") as Intl.NumberFormatPart)
.value;
}
/**
* Get aggregate metadata for a currency: code, symbol, localised
* name, and decimal digits (resolved from `Intl.NumberFormat`'s
* `resolvedOptions().maximumFractionDigits`).
*/
export function getCurrencyInfo(
currency: string,
locale: string
): CurrencyInfo {
validateCurrencyCode(currency);
const symbol = getCurrencySymbol(currency, locale);
const name = getCurrencyName(currency, locale);
const formatter = new Intl.NumberFormat(toBcp47(locale), {
style: "currency",
currency,
});
const decimalDigits = formatter.resolvedOptions()
.maximumFractionDigits as number;
return { code: currency, symbol, name, decimalDigits };
}

View File

@@ -0,0 +1,5 @@
export {
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "./helpers.js";

View File

@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
describe("@suaveplan/i18n-formatter-currency root barrel", () => {
it("re-exports CurrencyFormatter class", { timeout: 5000 }, () => {
expect(typeof root.CurrencyFormatter).toBe("function");
});
it("re-exports formatCurrency standalone", { timeout: 5000 }, () => {
expect(typeof root.formatCurrency).toBe("function");
});
it("re-exports formatCurrencyValue", { timeout: 5000 }, () => {
expect(typeof root.formatCurrencyValue).toBe("function");
});
it("re-exports clearCurrencyFormatterCache", { timeout: 5000 }, () => {
expect(typeof root.clearCurrencyFormatterCache).toBe("function");
});
it("re-exports CurrencyCodeSchema and CurrencyFormatterOptionsSchema", {
timeout: 5000,
}, () => {
expect(root.CurrencyCodeSchema).toBeDefined();
expect(root.CurrencyFormatterOptionsSchema).toBeDefined();
});
it("re-exports toBcp47", { timeout: 5000 }, () => {
expect(typeof root.toBcp47).toBe("function");
});
it("re-exports getCurrencySymbol / getCurrencyName / getCurrencyInfo", {
timeout: 5000,
}, () => {
expect(typeof root.getCurrencySymbol).toBe("function");
expect(typeof root.getCurrencyName).toBe("function");
expect(typeof root.getCurrencyInfo).toBe("function");
});
it("re-exports observability surface", { timeout: 5000 }, () => {
expect(typeof root.getCurrencyFormatMetricsState).toBe("function");
expect(typeof root.resetCurrencyFormatMetricsState).toBe("function");
expect(typeof root.recordCurrencyFormat).toBe("function");
expect(typeof root.recordCurrencyCacheHit).toBe("function");
});
});
describe("@suaveplan/i18n-formatter-currency package.json invariants", () => {
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("every documented subpath export resolves", {
timeout: 5000,
}, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as { exports?: Record<string, unknown> };
const exports = pkg.exports ?? {};
const required = [".", "./formatter", "./helpers"];
for (const subpath of required) {
expect(exports).toHaveProperty(subpath);
}
});
it("declares dep on @suaveplan/i18n-formatter-number", {
timeout: 5000,
}, () => {
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
};
expect(pkg.dependencies).toBeDefined();
expect(pkg.dependencies?.["@suaveplan/i18n-formatter-number"]).toBe(
"workspace:^"
);
});
});

View File

@@ -0,0 +1,32 @@
// Root barrel for @suaveplan/i18n-formatter-currency. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export type {
CommonCurrency,
CurrencyDisplay,
CurrencyFormatterCacheKey,
CurrencyFormatterOptions,
CurrencyInfo,
} from "./formatter/index.js";
export {
CurrencyCodeSchema,
CurrencyFormatter,
CurrencyFormatterOptionsSchema,
clearCurrencyFormatterCache,
formatCurrency,
formatCurrencyValue,
toBcp47,
} from "./formatter/index.js";
export {
getCurrencyInfo,
getCurrencyName,
getCurrencySymbol,
} from "./helpers/index.js";
export {
type CurrencyFormatMetricsState,
type FormatMetricSample,
getCurrencyFormatMetricsState,
recordCurrencyCacheHit,
recordCurrencyFormat,
resetCurrencyFormatMetricsState,
} from "./observability/index.js";

View File

@@ -0,0 +1,10 @@
export type {
CurrencyFormatMetricsState,
FormatMetricSample,
} from "./observability.js";
export {
getCurrencyFormatMetricsState,
recordCurrencyCacheHit,
recordCurrencyFormat,
resetCurrencyFormatMetricsState,
} from "./observability.js";

View File

@@ -0,0 +1,91 @@
# CurrencyFormatter Observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-currency`. Exposes a histogram (`i18n.format.currency.duration_ms`) recording wall-clock duration of every `formatCurrency` call (tagged with `locale`, `currency`, and `display`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "currency"` that increments on every cache reuse.
The currency formatter introduces these instruments as part of the Wave 3 uplift — the monolith currently emits no OTel for currency formatting. The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low.
## Features
- Lazy instrument construction
- Stable histogram name `i18n.format.currency.duration_ms` with attributes `{ locale, currency, display }`
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "currency" }`
- In-memory sample ring (`currencyDurationSamples`, `cacheHitSamples`)
- `resetCurrencyFormatMetricsState()` clears both arrays so tests stay isolated
## Usage: Basic Example
```typescript
import {
recordCurrencyFormat,
recordCurrencyCacheHit,
getCurrencyFormatMetricsState,
resetCurrencyFormatMetricsState,
} from "@suaveplan/i18n-formatter-currency/observability";
resetCurrencyFormatMetricsState();
recordCurrencyFormat("en-US", "USD", "symbol", 0.4);
recordCurrencyCacheHit();
const state = getCurrencyFormatMetricsState();
state.currencyDurationSamples.length; // 1
state.cacheHitSamples.length; // 1
```
## Usage: Advanced Example — Asserting OTel emission in a test
```typescript
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
formatCurrency,
clearCurrencyFormatterCache,
} from "@suaveplan/i18n-formatter-currency";
import {
getCurrencyFormatMetricsState,
resetCurrencyFormatMetricsState,
} from "@suaveplan/i18n-formatter-currency/observability";
describe("metrics emission", () => {
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(42, "USD", "en-US");
formatCurrency(42, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});
```
## API Reference
### `recordCurrencyFormat(locale, currency, display, durationMs): void`
Records one observation on the `i18n.format.currency.duration_ms` histogram and pushes a `{ attributes: { locale, currency, display } }` entry into the in-memory sample ring.
### `recordCurrencyCacheHit(): void`
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "currency"` and pushes a matching entry into the in-memory sample ring.
### `getCurrencyFormatMetricsState(): CurrencyFormatMetricsState`
Returns the in-memory sample ring (live reference — do not mutate).
### `resetCurrencyFormatMetricsState(): void`
Empties both sample arrays in place.
### `CurrencyFormatMetricsState`
`{ currencyDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
## Implementation Notes
The instruments are built lazily so consumers who never call `formatCurrency` do not pay the OTel meter-lookup cost. The cache check is a strict identity check against the closure-scoped `cached` slot — once built, the instruments are never rebuilt, even across `resetCurrencyFormatMetricsState` calls.
The `currency` attribute is the ISO 4217 code (`"USD"`, `"EUR"`, `"JPY"`); the `display` attribute is the resolved display mode (`"symbol"`, `"code"`, `"name"`, `"narrowSymbol"`). Both are included in the histogram tags so consumers can dashboard the most-formatted currencies and the most-used display modes per locale.

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearCurrencyFormatterCache,
formatCurrency,
} from "../formatter/formatter.js";
import {
getCurrencyFormatMetricsState,
recordCurrencyCacheHit,
recordCurrencyFormat,
resetCurrencyFormatMetricsState,
} from "./observability.js";
describe("CurrencyFormatter observability", () => {
it("emits i18n.format.currency.duration_ms histogram on every call", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(42, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(1);
expect(state.currencyDurationSamples[0]?.attributes).toMatchObject({
locale: "en-US",
currency: "USD",
display: "symbol",
});
});
it("cache-hit counter increments only on second call with same options", {
timeout: 5000,
}, () => {
clearCurrencyFormatterCache();
resetCurrencyFormatMetricsState();
formatCurrency(42, "USD", "en-US");
expect(getCurrencyFormatMetricsState().cacheHitSamples).toHaveLength(0);
formatCurrency(42, "USD", "en-US");
const state = getCurrencyFormatMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("currency");
});
it("recordCurrencyFormat exposed for direct use", { timeout: 5000 }, () => {
resetCurrencyFormatMetricsState();
recordCurrencyFormat("fr-FR", "EUR", "code", 0.1);
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(1);
expect(state.currencyDurationSamples[0]?.attributes).toMatchObject({
locale: "fr-FR",
currency: "EUR",
display: "code",
});
});
it("recordCurrencyCacheHit can be invoked directly", {
timeout: 5000,
}, () => {
resetCurrencyFormatMetricsState();
recordCurrencyCacheHit();
recordCurrencyCacheHit();
const state = getCurrencyFormatMetricsState();
expect(state.cacheHitSamples).toHaveLength(2);
});
it("instruments are reused across resets (no double-construction)", {
timeout: 5000,
}, () => {
resetCurrencyFormatMetricsState();
recordCurrencyFormat("en-US", "USD", "symbol", 0.1);
recordCurrencyFormat("en-US", "USD", "symbol", 0.2);
const state = getCurrencyFormatMetricsState();
expect(state.currencyDurationSamples).toHaveLength(2);
});
});

View File

@@ -0,0 +1,82 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for the currency formatter.
*
* Histogram records per-format wall-clock duration tagged with
* `locale`, `currency`, and `display`; the counter increments on
* every cache reuse so consumers can observe cache effectiveness.
*
* The currency formatter introduces these instruments as part of the
* Wave 3 uplift — the monolith currently emits no OTel for currency
* formatting.
*/
export interface FormatMetricSample {
readonly attributes: Record<string, string | number>;
}
export interface CurrencyFormatMetricsState {
readonly currencyDurationSamples: FormatMetricSample[];
readonly cacheHitSamples: FormatMetricSample[];
}
let cached:
| {
readonly currencyDurationMs: Histogram;
readonly cacheHit: Counter;
}
| undefined;
const state: CurrencyFormatMetricsState = {
currencyDurationSamples: [],
cacheHitSamples: [],
};
function ensureInstruments() {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n-formatter-currency");
cached = {
currencyDurationMs: meter.createHistogram(
"i18n.format.currency.duration_ms",
{
description:
"Duration in ms of CurrencyFormatter.format; labels: locale, currency, display.",
unit: "ms",
}
),
cacheHit: meter.createCounter("i18n.format.cache_hit", {
description:
'Format-time cache reuse counter; labels: formatter ("currency").',
}),
};
return cached;
}
export function recordCurrencyFormat(
locale: string,
currency: string,
display: string,
durationMs: number
): void {
const ins = ensureInstruments();
const attributes = { locale, currency, display };
ins.currencyDurationMs.record(durationMs, attributes);
state.currencyDurationSamples.push({ attributes });
}
export function recordCurrencyCacheHit(): void {
const ins = ensureInstruments();
const attributes = { formatter: "currency" };
ins.cacheHit.add(1, attributes);
state.cacheHitSamples.push({ attributes });
}
export function getCurrencyFormatMetricsState(): CurrencyFormatMetricsState {
return state;
}
export function resetCurrencyFormatMetricsState(): void {
state.currencyDurationSamples.length = 0;
state.cacheHitSamples.length = 0;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-formatter-currency",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-formatter-number",
"@suaveplan/i18n-shared",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});