Initial Commit
This commit is contained in:
253
openspec/archive/2026-05-17-add-currency/proposal.md
Normal file
253
openspec/archive/2026-05-17-add-currency/proposal.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user