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

254 lines
18 KiB
Markdown

# 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`.