# Add @suaveplan/currency — pluggable currency conversion engine **Package**: `@suaveplan/currency` **Status**: PROPOSED **Type**: migration (upstream `../suaveplan/packages/finance/currency`) - **Design**: ./design.md - **Spec delta**: ./specs/currency/spec.md ## Why `STATUS.md` lists `@suaveplan/currency` as Pending under the `commerce` family. With `@suaveplan/money` now shipped on genesis, the currency package is the next link in the commerce chain: it owns the `CurrencyProvider` interface every rate source implements, the TTL-bounded `RateCache` every provider answer is folded into, the cross-rate triangulation that lets callers convert between two arbitrary currencies without a direct pair quote, and the resilient fallback chain that keeps a quote flowing when the primary provider is degraded. Every downstream commerce package that has to talk in heterogeneous currencies — `@suaveplan/currency-providers` (the concrete ECB / Fixer / CoinGecko / Binance plugins), `@suaveplan/payment`, `@suaveplan/payment-providers`, `@suaveplan/booking`, the `api-plugin-bank` / `api-plugin-payment` / `api-plugin-banking` / `api-plugin-virtual-currency` plugins, and the eventual `@suaveplan/cryptocurrency` unit-conversion surface — depends on the contract this package owns. The migration preserves the entire upstream public surface verbatim (same class names, same option fields, same `ConversionResult` shape) and lifts the package to the genesis quality bar: every plain `throw new Error(...)` becomes a typed `CurrencyError` carrying a stable `CURRENCY.*` code; `ConverterConfig`, `ConvertOptions`, `VirtualCurrencyConfig`, and `RateFetchOptions` are validated by Zod at the public boundary; `convert` / `convertAmount` / `getRate` accept an `AbortSignal` so a slow fallback chain can be cancelled; the upstream `updateVirtualRate` method gains a sibling `setVirtualRate` alias and is the canonical post-construction mutation point; `ConversionResult` is enriched with `providerName: string` and `triangulatedThrough: string[] | null` so callers can observe which provider answered and whether the rate is direct, inverse, or triangulated. Every file lives under `/.ts` per the no-prefix and feature-folder rules; every feature module has a subpath export, explicit named barrels, a sibling `.md`, and `{ timeout: 5000 }` on every `it(...)`. The enterprise audit (`../suaveplan/enterprise/currency.md`) lists eleven ambitious additions; the ones whose dependencies do not yet exist on genesis (BFS triangulation across the full cached-rate graph, per-provider circuit breaker, persistent `CacheAdapter` backed by `@suaveplan/cache` / `@suaveplan/storage-browser`, subscription API, batch convert, tenant markup policy, observability via `@suaveplan/logger` / `@suaveplan/events` / OpenTelemetry, deterministic record/replay) are explicitly out of scope here and will land in follow-up changes once their dependency packages migrate. The migration baseline matches the upstream behaviour bit-for-bit and unblocks every commerce-tier consumer. ## What changes - ADDED: `CurrencyConverter` class — pluggable primary `provider` + `fallbackProviders` chain, TTL-bounded internal `RateCache`, automatic cross-rate triangulation, inverse-rate calculation from cached data, force-fresh rate fetching, virtual-currency seeding on construction with post-construction `setVirtualRate` / `updateVirtualRate` mutators and `getVirtualRates` inspector. Public methods: `convert(money, to, options?)`, `convertAmount(amount, from, to, options?)`, `getRate(from, to, options?)`, `setRate(from, to, rate)`, `clearCache()`, `getCache()`, `setVirtualRate(code, rate)`, `updateVirtualRate(code, rate)`, `getVirtualRates()`. - ADDED: `RateCache` class — `set(base, result)`, `get(base)`, `has(base)`, `getRate(from, to)` with direct / inverse / triangulated lookup, `getTimestamp(base)`, `clear()`, `prune()`, `size`. - ADDED: `InMemoryProvider` class — `setRates(base, rates)`, `setRate(from, to, rate)`, `fetchRates(options?)` honouring `RateFetchOptions.base` and `targets`. - ADDED: `createStaticProvider(name, rateMap, options?)` factory — returns a `CurrencyProvider` answering from a static rate map. - ADDED: `CURRENCY_CODES` registry via `defineCodes("CURRENCY", [...])` — 7 codes: `INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`, `UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`, `ABORTED`. - ADDED: `CurrencyError extends SuaveplanError` carrying optional `from?` / `to?` / `providerName?` discriminants. - ADDED: Zod schemas — `ConverterConfigSchema`, `ConvertOptionsSchema`, `VirtualCurrencyConfigSchema`, `RateFetchOptionsSchema`. Validated at the `CurrencyConverter` constructor and at every `convert` / `convertAmount` / `getRate` entry; failures raise `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` / `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })`. - ADDED: `AbortSignal` support on `convert` / `convertAmount` / `getRate` — honoured between fallback attempts so a slow chain can be cancelled; aborted calls raise `CurrencyError({ code: CURRENCY.ABORTED })`. - ADDED: `ConversionResult` is enriched — same `amount` / `rate` / `inverseRate` / `from` / `to` / `rateAgeMs` as upstream, plus new `providerName: string` (`""` when the result was served from cache, `""` for virtual rates, otherwise the provider's `name`) and `triangulatedThrough: string[] | null` (the chain of intermediate currencies, or `null` for direct / inverse / identity conversions). - ADDED: Types — `ConversionResult`, `ConverterConfig`, `ConvertOptions`, `CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`, `VirtualCurrencyConfig` (re-exported from root and from `./types/common`). - ADDED: Subpath exports per public module — `./converter/converter`, `./rate-cache/rate-cache`, `./provider/provider`, `./error-codes/error-codes`, `./schemas/schemas`, `./types/common`. Tree-shakeable. ## Out of scope - **BFS / multi-hop triangulation across the full cached-rate graph.** The upstream library triangulates via the configured `defaultBase` and via any cached base (the latter via `RateCache.getRate`); the audit asks for a BFS that tries every cached-rate path including two-hop chains (EUR → USD → JPY → IDR). Reason: the upstream behaviour is the migration baseline; full-graph BFS changes both the search semantics and the diagnostics (`triangulatedThrough` becomes a list of length ≥ 2), and warrants its own proposal with worked examples. The current single-hop-via-cache triangulation is preserved verbatim and is sufficient for every currently-known consumer. - **Per-provider circuit breaker.** The audit asks for a circuit breaker that tracks consecutive failures per provider and enters open / half-open states. Reason: introduces stateful machinery the upstream library does not have and whose policy surface (failure threshold, cooldown, half-open trial count) belongs in a dedicated proposal. The fallback chain already short-circuits per call; persistent state is the new feature. - **Persistent `CacheAdapter` interface with `@suaveplan/cache` and `@suaveplan/storage-browser` backings.** Both dependency packages are still in the `Pending` list. Reason: adding the `CacheAdapter` interface here would either commit to a contract before its consumers exist, or hand-roll a stub that we'd rewrite when those packages migrate. The in-memory `RateCache` is preserved bit-for-bit; the persistent variant lands in a follow-up change. - **Subscription API (`subscribe(from, to, cb)`).** Reason: requires either an event-bus integration (`@suaveplan/events` is migrated, but the streaming contract for provider rate-push events is a new design surface) or a polling loop with its own scheduling policy. Belongs in a follow-up change once a real provider with push semantics arrives. - **`convertBatch([{from, to, amount}])` with provider-call coalescing.** Reason: additive new feature; the audit lists it under "feature-rich enhancements". Belongs in a follow-up. - **Tenant markup / spread policy.** Reason: requires a policy registry and belongs in a future `@suaveplan/payment` or pricing package. - **`isHealthy()` on providers + health-aware fallback skip.** Reason: requires defining the health contract; not present upstream. Belongs in the circuit-breaker follow-up. - **OpenTelemetry spans / counters + structured logging via `@suaveplan/logger` / `@suaveplan/events`.** Reason: keeps the package universal-tier with a zero workspace-dep observability surface; an `obs/` companion that wires `@suaveplan/logger` and the OTel API stays composable. Belongs in a follow-up change. - **Deterministic record / replay (dump cache as JSON; re-seed in CI).** Reason: additive testing feature; the existing in-memory cache is already inspect-able via `getCache()`. Belongs in a follow-up. - **Rate-precision rounding policy at the boundary.** Reason: the upstream library uses `Math.round` on the converted minor amount; the audit asks for configurable per-pair rounding that defers to `applyRounding` from `@suaveplan/money`. This is a behaviour change touching every consumer, and warrants its own proposal with migration notes. ## Source mapping This change derives from `../suaveplan/enterprise/currency.md` and the upstream source at `../suaveplan/packages/finance/currency/src/`. | Requirement | Source lines | Notes | |---|---|---| | Universal-tier purity | enterprise/currency.md L54-L60 (cross-cutting) | Genesis-wide rule; reinforced here | | Pluggable `CurrencyProvider` interface with `name` / `supportsFiat` / `supportsCrypto` / `supportsVirtual?` / `fetchRates` | enterprise/currency.md L12, L17; upstream `src/types/currency.types.ts` L33-L45 | Verbatim | | `CurrencyConverter` class — primary + fallback chain, force-fresh, virtual-currency seeding | enterprise/currency.md L12, L20-L23; upstream `src/engine/converter.ts` L20-L207 | Verbatim, plus typed-error + Zod + AbortSignal upgrades | | `RateCache` with TTL eviction, direct / inverse / triangulated lookup, prune | enterprise/currency.md L12, L23-L25; upstream `src/engine/rate-cache.ts` L1-L105 | Verbatim | | `InMemoryProvider` + `createStaticProvider` factory | enterprise/currency.md L12, L17; upstream `src/provider/provider.ts` L1-L103 | Verbatim | | Virtual-currency seeding + post-construction mutation | enterprise/currency.md L30-L31, L51; upstream `src/engine/converter.ts` L38-L80 | Verbatim plus `setVirtualRate` alias | | `ConversionResult` carries `amount` / `rate` / `inverseRate` / `from` / `to` / `rateAgeMs` | enterprise/currency.md L17, L33-L34; upstream `src/types/currency.types.ts` L83-L97 | Verbatim | | `ConvertOptions` `rounding` / `fresh` | enterprise/currency.md L17; upstream `src/types/currency.types.ts` L75-L82 | Verbatim | | `ConverterConfig` `provider` / `fallbackProviders` / `staleDurationMs` / `defaultBase` / `virtualCurrencies` | enterprise/currency.md L17, L20-L23; upstream `src/types/currency.types.ts` L61-L73 | Verbatim | | Public types (`ExchangeRate`, `RateFetchOptions`, `RateFetchResult`, `VirtualCurrencyConfig`) | enterprise/currency.md L17; upstream `src/types/currency.types.ts` L1-L60 | Verbatim | | Typed error hierarchy `CurrencyError` with stable `CURRENCY.*` codes | enterprise/currency.md L45, L51, L61 | Migration upgrade | | Zod boundary on `ConverterConfig` / `ConvertOptions` / `VirtualCurrencyConfig` / `RateFetchOptions` | enterprise/currency.md L44, L50 | Migration upgrade | | `AbortSignal` on `convert` / `convertAmount` / `getRate` | enterprise/currency.md L45 | Migration upgrade | | `ConversionResult.providerName` + `triangulatedThrough` enrichment | enterprise/currency.md L30-L31, L48 | Migration upgrade — cheap and closes the upstream gap of "no way to see which fallback was used or whether the result was triangulated" | | Subpath exports per public module | enterprise/currency.md L40 (sub-path exports) | Reorganised: drop `engine/` ancestor, every feature gets `/.ts` | ### Requirements with no source line | Requirement | Justification | |---|---| | 100% test coverage (istanbul, all four axes) | Genesis-wide policy (CLAUDE.md §7) — upstream was ~99% | | Sibling `.md` per implementation file (≥200 words, basic + advanced examples) | Genesis-wide policy (CLAUDE.md §4) — upstream had partial docs | | Test files import runner primitives from `@suaveplan/testing/runner` | Genesis-wide policy (AGENTS.md §7) — upstream imported `vitest` directly | | Explicit named re-exports in every feature-module barrel (no `export *`) | Genesis-wide policy (AGENTS.md §6a) | | Package version starts at `0.1.0` | Genesis-wide policy (CLAUDE.md "Editing") — upstream was `1.0.5` | | No package-name prefix inside `/` (`converter/converter.ts`, not `currency-converter/currency-converter.ts`) | Genesis-wide policy (user memory: no-pkg-prefix) | ### Parity Checklist | Capability | Upstream | Migrated | Notes | |---|---|---|---| | `CurrencyConverter` — primary + fallback chain | ✅ | ✅ | Verbatim | | `CurrencyConverter.convert` (MoneyLike → ConversionResult) | ✅ | ⬆️ Enhanced | Adds `providerName` + `triangulatedThrough` + `AbortSignal` + Zod option validation | | `CurrencyConverter.convertAmount` (number → number) | ✅ | ⬆️ Enhanced | Adds `AbortSignal` + Zod option validation | | `CurrencyConverter.getRate` | ✅ | ⬆️ Enhanced | Adds `AbortSignal` + raises `CurrencyError({ code: CURRENCY.RATE_UNAVAILABLE })` | | `CurrencyConverter.setRate` / `clearCache` / `getCache` | ✅ | ✅ | Verbatim | | Virtual-currency seeding + `updateVirtualRate` + `getVirtualRates` | ✅ | ⬆️ Enhanced | Adds `setVirtualRate` alias (audit asks for "setVirtualRate"); rates are reseeded after every provider fetch | | `RateCache` — TTL eviction, direct / inverse / triangulated lookup, prune, `getTimestamp`, `size` | ✅ | ✅ | Verbatim | | `InMemoryProvider` + `createStaticProvider` factory | ✅ | ⬆️ Enhanced | `fetchRates` honours `AbortSignal`; missing-base error raises `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })` | | `ExchangeRate` / `RateFetchOptions` / `RateFetchResult` / `VirtualCurrencyConfig` types | ✅ | ✅ | Verbatim | | Plain `throw new Error(...)` from boundaries | ✅ | ⬆️ Replaced | All raised errors extend `SuaveplanError` with stable `CURRENCY.*` codes | | Sub-path exports per public module | ✅ | ⬆️ Enhanced | 6 subpaths (vs upstream's 3) — every feature is tree-shakeable | | Zod boundary on public options | 🚫 upstream (audit asks) | ⬆️ Added | Migration upgrade | | `AbortSignal` on `convert` / `convertAmount` / `getRate` | 🚫 upstream (audit asks) | ⬆️ Added | Migration upgrade | | `ConversionResult.providerName` + `triangulatedThrough` | 🚫 upstream (audit asks: "no way to see which fallback was used or whether the result was triangulated vs direct") | ⬆️ Added | Cheap diagnostic addition | | Smart BFS triangulation (multi-hop) | 🚫 upstream (audit asks) | 🚫 deferred | Single-hop via cache is preserved; multi-hop changes semantics, belongs in a follow-up | | Per-provider circuit breaker | 🚫 upstream (audit asks) | 🚫 deferred | Stateful machinery; needs its own proposal | | Persistent `CacheAdapter` (`@suaveplan/cache` + `@suaveplan/storage-browser`) | 🚫 upstream (audit asks) | 🚫 deferred | Both dependency packages are Pending | | Subscription API | 🚫 upstream (audit asks) | 🚫 deferred | Requires push-semantics provider contract | | `convertBatch` | 🚫 upstream (audit asks) | 🚫 deferred | Additive feature | | Tenant markup / spread policy | 🚫 upstream (audit asks) | 🚫 deferred | Belongs in pricing / payment package | | `isHealthy()` on providers | 🚫 upstream (audit asks) | 🚫 deferred | Coupled with circuit-breaker follow-up | | OpenTelemetry spans + counters via `@suaveplan/logger` / `@suaveplan/events` | 🚫 upstream (audit asks) | 🚫 deferred | Composable companion; keeps this package universal-tier light | | Deterministic record / replay | 🚫 upstream (audit asks) | 🚫 deferred | Additive testing feature | | Per-pair rounding-mode policy | 🚫 upstream (audit asks) | 🚫 deferred | Behaviour change; needs its own proposal | ### Reference docs consulted - ✅ `../suaveplan/packages/finance/currency/src/` — all 4 implementation files + 4 test files read - ✅ `../suaveplan/packages/finance/currency/README.md` — public API documentation - ✅ `../suaveplan/packages/finance/currency/package.json` — dep graph + subpath exports - ✅ `../suaveplan/enterprise/currency.md` — enterprise audit, in full - 🚫 `../suaveplan/openspec/specs/currency/spec.md` — does not exist (no formal upstream spec) - 🚫 `../suaveplan/openspec/changes/*/proposal.md` touching currency — only `enterprise-currency` (the audit-derivation change) ## Verification approach Every requirement in the spec delta has a `**Verified by:**` line naming the test files that pin its scenarios. The test layout mirrors the source layout (one `.test.ts` per `.ts`): - `src/converter/converter.test.ts` — `CurrencyConverter` arithmetic, fallback, triangulation, virtual currency, AbortSignal, Zod boundary, providerName + triangulatedThrough enrichment - `src/rate-cache/rate-cache.test.ts` — TTL eviction, prune, size, direct / inverse / triangulated `getRate`, `getTimestamp` - `src/provider/provider.test.ts` — `InMemoryProvider` + `createStaticProvider` contract, missing-base error, targets filter, AbortSignal honoured - `src/error-codes/error-codes.test.ts` — every `CURRENCY.*` code reachable from a call site, `SuaveplanError` contract preserved, discriminants populated - `src/schemas/schemas.test.ts` — every Zod schema accepts a valid payload and rejects each documented bad shape with `CurrencyError({ code: CURRENCY.INVALID_CONFIG | CURRENCY.INVALID_OPTIONS })` ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/currency` to enumerate. - No dependents — every potential downstream (`@suaveplan/currency-providers`, `@suaveplan/payment`, `@suaveplan/payment-providers`, `@suaveplan/booking`, the `api-plugin-bank` / `api-plugin-payment` / `api-plugin-banking` / `api-plugin-virtual-currency` plugins) is still Pending and will adopt this package when migrated. ## Migration impact None — additive only. No existing genesis package imports `@suaveplan/currency`.