254 lines
18 KiB
Markdown
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`.
|