Initial Commit
This commit is contained in:
119
packages/localization/i18n/i18n-formatter-currency/README.md
Normal file
119
packages/localization/i18n/i18n-formatter-currency/README.md
Normal 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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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("$");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getCurrencyInfo,
|
||||
getCurrencyName,
|
||||
getCurrencySymbol,
|
||||
} from "./helpers.js";
|
||||
@@ -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:^"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
CurrencyFormatMetricsState,
|
||||
FormatMetricSample,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getCurrencyFormatMetricsState,
|
||||
recordCurrencyCacheHit,
|
||||
recordCurrencyFormat,
|
||||
resetCurrencyFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user