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

18 KiB

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 <feature>/<feature>.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<CurrencyErrorCode> 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 ("<cache>" when the result was served from cache, "<virtual>" 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 <feature>/<feature>.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 <pkg>/ (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 <feature>.test.ts per <feature>.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.