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:
CurrencyConverterclass — pluggable primaryprovider+fallbackProviderschain, TTL-bounded internalRateCache, automatic cross-rate triangulation, inverse-rate calculation from cached data, force-fresh rate fetching, virtual-currency seeding on construction with post-constructionsetVirtualRate/updateVirtualRatemutators andgetVirtualRatesinspector. 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:
RateCacheclass —set(base, result),get(base),has(base),getRate(from, to)with direct / inverse / triangulated lookup,getTimestamp(base),clear(),prune(),size. - ADDED:
InMemoryProviderclass —setRates(base, rates),setRate(from, to, rate),fetchRates(options?)honouringRateFetchOptions.baseandtargets. - ADDED:
createStaticProvider(name, rateMap, options?)factory — returns aCurrencyProvideranswering from a static rate map. - ADDED:
CURRENCY_CODESregistry viadefineCodes("CURRENCY", [...])— 7 codes:INVALID_CONFIG,INVALID_OPTIONS,PROVIDER_UNAVAILABLE,UNSUPPORTED_CURRENCY,RATE_UNAVAILABLE,TRIANGULATION_IMPOSSIBLE,ABORTED. - ADDED:
CurrencyError extends SuaveplanError<CurrencyErrorCode>carrying optionalfrom?/to?/providerName?discriminants. - ADDED: Zod schemas —
ConverterConfigSchema,ConvertOptionsSchema,VirtualCurrencyConfigSchema,RateFetchOptionsSchema. Validated at theCurrencyConverterconstructor and at everyconvert/convertAmount/getRateentry; failures raiseCurrencyError({ code: CURRENCY.INVALID_CONFIG })/CurrencyError({ code: CURRENCY.INVALID_OPTIONS }). - ADDED:
AbortSignalsupport onconvert/convertAmount/getRate— honoured between fallback attempts so a slow chain can be cancelled; aborted calls raiseCurrencyError({ code: CURRENCY.ABORTED }). - ADDED:
ConversionResultis enriched — sameamount/rate/inverseRate/from/to/rateAgeMsas upstream, plus newproviderName: string("<cache>"when the result was served from cache,"<virtual>"for virtual rates, otherwise the provider'sname) andtriangulatedThrough: string[] | null(the chain of intermediate currencies, ornullfor 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
defaultBaseand via any cached base (the latter viaRateCache.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 (triangulatedThroughbecomes 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
CacheAdapterinterface with@suaveplan/cacheand@suaveplan/storage-browserbackings. Both dependency packages are still in thePendinglist. Reason: adding theCacheAdapterinterface 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-memoryRateCacheis 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/eventsis 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/paymentor 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; anobs/companion that wires@suaveplan/loggerand 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.roundon the converted minor amount; the audit asks for configurable per-pair rounding that defers toapplyRoundingfrom@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.mdtouching currency — onlyenterprise-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—CurrencyConverterarithmetic, fallback, triangulation, virtual currency, AbortSignal, Zod boundary, providerName + triangulatedThrough enrichmentsrc/rate-cache/rate-cache.test.ts— TTL eviction, prune, size, direct / inverse / triangulatedgetRate,getTimestampsrc/provider/provider.test.ts—InMemoryProvider+createStaticProvidercontract, missing-base error, targets filter, AbortSignal honouredsrc/error-codes/error-codes.test.ts— everyCURRENCY.*code reachable from a call site,SuaveplanErrorcontract preserved, discriminants populatedsrc/schemas/schemas.test.ts— every Zod schema accepts a valid payload and rejects each documented bad shape withCurrencyError({ 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, theapi-plugin-bank/api-plugin-payment/api-plugin-banking/api-plugin-virtual-currencyplugins) is still Pending and will adopt this package when migrated.
Migration impact
None — additive only. No existing genesis package imports @suaveplan/currency.