Initial Commit
This commit is contained in:
183
openspec/archive/2026-05-17-add-currency/design.md
Normal file
183
openspec/archive/2026-05-17-add-currency/design.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Design — @suaveplan/currency
|
||||
|
||||
## Architecture
|
||||
|
||||
`@suaveplan/currency` is a universal-tier orchestrator. It owns no rate data; it
|
||||
owns the contract every rate source implements (`CurrencyProvider`), the
|
||||
in-memory TTL cache that flattens repeated calls into a single provider hit
|
||||
(`RateCache`), and the converter that combines them with cross-rate
|
||||
triangulation and a fallback chain (`CurrencyConverter`). Every public boundary
|
||||
is Zod-validated; every failure path raises a typed `CurrencyError`. The package
|
||||
runs unchanged under Node, Bun, browsers, and Web Workers.
|
||||
|
||||
```text
|
||||
┌──────────────────────────┐
|
||||
│ CurrencyConverter │
|
||||
│ │
|
||||
convert(money) ───▶│ validate options (Zod) │
|
||||
│ walk fallback chain │
|
||||
│ fetch → cache → seed │
|
||||
│ triangulate via cache │
|
||||
│ enrich result │
|
||||
│ honour AbortSignal │
|
||||
└──┬────────────────┬──────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌──────────────────┐
|
||||
│ RateCache │ │ CurrencyProvider │
|
||||
│ (TTL) │ │ (primary + │
|
||||
│ prune / │ │ fallbacks) │
|
||||
│ triangulate│ └────────┬─────────┘
|
||||
└─────────────┘ │
|
||||
├─ InMemoryProvider
|
||||
├─ createStaticProvider(...)
|
||||
└─ (future external providers)
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
Every feature lives at `src/<feature>/<feature>.ts` per the no-prefix rule;
|
||||
every feature has a sibling `<feature>.md`, `<feature>.test.ts`, and an
|
||||
`index.ts` barrel using explicit named re-exports.
|
||||
|
||||
```text
|
||||
packages/finance/currency/
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── tsconfig.json
|
||||
├── tsconfig.lib.json
|
||||
├── vite.config.ts
|
||||
├── vitest.config.ts
|
||||
├── tests/
|
||||
│ └── setup/
|
||||
│ └── polyfills.ts
|
||||
└── src/
|
||||
├── index.ts # root barrel — namespace + named re-exports
|
||||
├── types/
|
||||
│ ├── index.ts
|
||||
│ ├── common.ts # ExchangeRate, RateFetchOptions, RateFetchResult,
|
||||
│ │ # CurrencyProvider, VirtualCurrencyConfig,
|
||||
│ │ # ConverterConfig, ConvertOptions, ConversionResult
|
||||
│ └── common.md
|
||||
├── error-codes/
|
||||
│ ├── index.ts
|
||||
│ ├── error-codes.ts # CURRENCY_CODES + CurrencyError
|
||||
│ ├── error-codes.md
|
||||
│ └── error-codes.test.ts
|
||||
├── schemas/
|
||||
│ ├── index.ts
|
||||
│ ├── schemas.ts # Zod schemas + parse helpers raising CurrencyError
|
||||
│ ├── schemas.md
|
||||
│ └── schemas.test.ts
|
||||
├── rate-cache/
|
||||
│ ├── index.ts
|
||||
│ ├── rate-cache.ts # RateCache class
|
||||
│ ├── rate-cache.md
|
||||
│ └── rate-cache.test.ts
|
||||
├── provider/
|
||||
│ ├── index.ts
|
||||
│ ├── provider.ts # InMemoryProvider + createStaticProvider
|
||||
│ ├── provider.md
|
||||
│ └── provider.test.ts
|
||||
└── converter/
|
||||
├── index.ts
|
||||
├── converter.ts # CurrencyConverter class
|
||||
├── converter.md
|
||||
└── converter.test.ts
|
||||
```
|
||||
|
||||
## Public surface
|
||||
|
||||
Re-exported from the package root (`@suaveplan/currency`):
|
||||
|
||||
- Classes: `CurrencyConverter`, `RateCache`, `InMemoryProvider`, `CurrencyError`.
|
||||
- Factories: `createStaticProvider`.
|
||||
- Constants: `CURRENCY_CODES`.
|
||||
- Schemas: `ConverterConfigSchema`, `ConvertOptionsSchema`,
|
||||
`VirtualCurrencyConfigSchema`, `RateFetchOptionsSchema`.
|
||||
- Types: `ConversionResult`, `ConverterConfig`, `ConvertOptions`,
|
||||
`CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`,
|
||||
`VirtualCurrencyConfig`, `CurrencyErrorCode`, `CurrencyErrorOptions`.
|
||||
|
||||
Every public module is also reachable via a subpath export
|
||||
(`@suaveplan/currency/converter/converter` etc.) so consumers can tree-shake.
|
||||
|
||||
## Behavioural notes
|
||||
|
||||
### Triangulation
|
||||
|
||||
The migration preserves the upstream single-hop-via-cache triangulation. When
|
||||
`getRate(from, to)` is called and the cache has neither a direct nor an inverse
|
||||
entry, `RateCache.getRate` walks every cached base entry looking for one whose
|
||||
`rates` map contains both `from` and `to`. If found, the converter records the
|
||||
intermediary base in `ConversionResult.triangulatedThrough = [base]`. If not
|
||||
found, the converter fetches fresh data from the primary provider against
|
||||
`from`; if that fails, it falls back to the chain; if still nothing produces a
|
||||
direct rate, it raises `CurrencyError({ code: CURRENCY.RATE_UNAVAILABLE })`.
|
||||
|
||||
Multi-hop BFS is explicitly out of scope (see proposal `## Out of scope`). The
|
||||
`triangulatedThrough` field returns `string[]` to leave room for the future
|
||||
multi-hop change without breaking the type.
|
||||
|
||||
### Fallback chain & cancellation
|
||||
|
||||
Providers are tried in order: primary then `fallbackProviders`. The first one
|
||||
whose `fetchRates` resolves wins; its result is set in the cache and virtual
|
||||
rates are re-seeded. If `options.signal` is supplied and aborts between
|
||||
provider attempts, the chain stops immediately and the converter raises
|
||||
`CurrencyError({ code: CURRENCY.ABORTED })`. If every provider fails the chain
|
||||
raises `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE })` whose `cause`
|
||||
carries the last underlying error and whose `providerName` discriminant is the
|
||||
last attempted provider.
|
||||
|
||||
### Virtual currency seeding
|
||||
|
||||
`registerVirtualCurrencies` is called from the constructor when
|
||||
`config.virtualCurrencies` is provided. Each entry is registered through
|
||||
`@suaveplan/money`'s `registerCurrency` (so `Money.fromMajor("100", "GOLD")`
|
||||
works for callers), and its rate against `defaultBase` is added to the
|
||||
`virtualRates` private map. `seedVirtualRates` writes those rates into the
|
||||
cache entry keyed by `defaultBase`. After every successful provider fetch the
|
||||
seeding runs again so virtual rates survive provider refreshes.
|
||||
`setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` mutate the
|
||||
private map and call `seedVirtualRates`; the two are aliases — the audit
|
||||
mentions both names.
|
||||
|
||||
### `ConversionResult.providerName`
|
||||
|
||||
- `"<cache>"` — the rate was served from the cache (no provider call).
|
||||
- `"<virtual>"` — the rate was seeded by the virtual-currency configuration.
|
||||
- `"<identity>"` — `from === to` short-circuit.
|
||||
- otherwise — the `name` of the provider whose `fetchRates` produced the entry
|
||||
the cache resolved against.
|
||||
|
||||
### Zod boundary
|
||||
|
||||
`ConverterConfigSchema` validates the constructor argument. `ConvertOptionsSchema`,
|
||||
`RateFetchOptionsSchema`, and `VirtualCurrencyConfigSchema` validate every other
|
||||
public entry point. Failures raise `CurrencyError` with the appropriate code
|
||||
(`INVALID_CONFIG` for the constructor; `INVALID_OPTIONS` for everything else)
|
||||
and the Zod issue list on `error.context.issues`.
|
||||
|
||||
### Strict typing
|
||||
|
||||
`CurrencyProvider` and every option bundle is a top-level named interface (per
|
||||
AGENTS.md §11 — no inline shapes). `CurrencyConverter` is generic-free; future
|
||||
markup-policy work can graft generics on without breaking the current surface.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@suaveplan/money` (`workspace:^`) — currency metadata, `MoneyLike`,
|
||||
`minorUnitMultiplier`, `registerCurrency`, `getCurrencyOrThrow`.
|
||||
- `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`.
|
||||
- `zod` (`catalog:forms`) — boundary validation.
|
||||
- `tslib` (`catalog:build`).
|
||||
|
||||
## Test plan
|
||||
|
||||
- Every requirement in the spec delta has a `**Verified by:**` line naming
|
||||
concrete `it(...)` titles in the test files above.
|
||||
- Real implementations only — no mocks. AbortSignal is exercised via
|
||||
`AbortController` + `queueMicrotask` to land a real abort between fallback
|
||||
attempts.
|
||||
- Coverage gate: `provider: "istanbul"`, all four axes at 100%.
|
||||
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`.
|
||||
419
openspec/archive/2026-05-17-add-currency/specs/currency/spec.md
Normal file
419
openspec/archive/2026-05-17-add-currency/specs/currency/spec.md
Normal file
@@ -0,0 +1,419 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency (add-currency)
|
||||
|
||||
**Package**: `@suaveplan/currency`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`,
|
||||
`bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in
|
||||
any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun,
|
||||
modern browsers, and Web Workers. The `package.json` SHALL NOT declare a
|
||||
`"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL
|
||||
declare `"sideEffects": false`.
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency/src/` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"has no node/bun platform imports (tier purity)"` AND `src/rate-cache/rate-cache.test.ts::"has no node/bun platform imports (tier purity)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable CURRENCY_CODES error registry and CurrencyError class
|
||||
|
||||
The package SHALL declare an error-code registry via `defineCodes("CURRENCY", [...])`
|
||||
containing exactly the codes `INVALID_CONFIG`, `INVALID_OPTIONS`,
|
||||
`PROVIDER_UNAVAILABLE`, `UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`,
|
||||
`TRIANGULATION_IMPOSSIBLE`, `ABORTED`. Every public error path SHALL throw a
|
||||
`CurrencyError extends SuaveplanError<CurrencyErrorCode>` carrying one of those
|
||||
codes. `CurrencyError` SHALL expose optional `from`, `to`, and `providerName`
|
||||
discriminants for diagnostics. The package SHALL NOT throw raw `Error`
|
||||
instances from any public method.
|
||||
|
||||
#### Scenario: every code is reachable from a call site
|
||||
|
||||
- **WHEN** the test suite exercises invalid configuration, invalid call-site
|
||||
options, an all-providers-failing fallback chain, conversion for an
|
||||
unregistered currency, a missing rate after fetch, a triangulation that
|
||||
cannot be satisfied, and an aborted call
|
||||
- **THEN** each path raises a `CurrencyError` whose `code` matches the expected
|
||||
`CURRENCY.*` constant
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `CurrencyError` is thrown with a `cause`
|
||||
- **THEN** the resulting error is `instanceof SuaveplanError`, exposes the
|
||||
original on `error.cause`, and the wrapped code remains stable
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every CURRENCY.* code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"CurrencyError preserves cause and SuaveplanError contract"` AND `src/error-codes/error-codes.test.ts::"CurrencyError carries from, to, and providerName discriminants"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pluggable CurrencyProvider interface
|
||||
|
||||
The package SHALL declare a `CurrencyProvider` interface with `readonly name: string`,
|
||||
`readonly supportsCrypto: boolean`, `readonly supportsFiat: boolean`,
|
||||
optional `readonly supportsVirtual?: boolean`, and `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`.
|
||||
The package SHALL ship an `InMemoryProvider` reference implementation (with
|
||||
`setRates(base, rates)`, `setRate(from, to, rate)`) and a
|
||||
`createStaticProvider(name, rateMap, options?)` factory returning a frozen
|
||||
`CurrencyProvider`. Both reference providers SHALL honour
|
||||
`RateFetchOptions.base`, `RateFetchOptions.targets`, and
|
||||
`RateFetchOptions.signal` (raising `CurrencyError({ code: CURRENCY.ABORTED })`
|
||||
when the signal has fired). When no rates are configured for the requested
|
||||
base, both reference providers SHALL raise
|
||||
`CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })`.
|
||||
|
||||
#### Scenario: InMemoryProvider returns set rates
|
||||
|
||||
- **WHEN** `provider.setRates("USD", { EUR: 0.92, GBP: 0.79 })` is called and `provider.fetchRates({ base: "USD" })` is awaited
|
||||
- **THEN** the result is `{ base: "USD", rates: { EUR: 0.92, GBP: 0.79 }, timestamp: <number> }`
|
||||
|
||||
#### Scenario: InMemoryProvider targets filter
|
||||
|
||||
- **WHEN** `provider.fetchRates({ base: "USD", targets: ["EUR"] })` is called against a provider that has both EUR and GBP rates
|
||||
- **THEN** the result's `rates` map contains only `EUR`
|
||||
|
||||
#### Scenario: missing base raises PROVIDER_UNAVAILABLE
|
||||
|
||||
- **WHEN** `provider.fetchRates({ base: "ZZZ" })` is called against a provider with no `ZZZ` rates
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.PROVIDER_UNAVAILABLE"` and `providerName === provider.name`
|
||||
|
||||
#### Scenario: aborted fetch raises ABORTED
|
||||
|
||||
- **WHEN** `provider.fetchRates({ base: "USD", signal })` is called with an already-aborted `AbortSignal`
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
|
||||
|
||||
#### Scenario: createStaticProvider produces the same contract
|
||||
|
||||
- **WHEN** `createStaticProvider("static", { USD: { EUR: 0.92 } }).fetchRates({ base: "USD" })` is called
|
||||
- **THEN** the resolved result has `base === "USD"` and `rates.EUR === 0.92`, and the provider exposes `name === "static"`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"InMemoryProvider returns set rates"` AND `src/provider/provider.test.ts::"InMemoryProvider filters by targets"` AND `src/provider/provider.test.ts::"InMemoryProvider raises PROVIDER_UNAVAILABLE for missing base"` AND `src/provider/provider.test.ts::"InMemoryProvider raises ABORTED when signal already aborted"` AND `src/provider/provider.test.ts::"createStaticProvider returns the same contract"` AND `src/provider/provider.test.ts::"createStaticProvider raises PROVIDER_UNAVAILABLE for missing base"` AND `src/provider/provider.test.ts::"createStaticProvider raises ABORTED when signal already aborted"` AND `src/provider/provider.test.ts::"InMemoryProvider setRate adds a single pair"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RateCache TTL eviction and triangulated lookup
|
||||
|
||||
The `RateCache` class SHALL store rate fetch results keyed by base currency
|
||||
with a configurable TTL (default `3_600_000` ms). `get(base)` and `has(base)`
|
||||
SHALL return undefined / false when the entry is expired and SHALL delete the
|
||||
expired entry as a side effect. `getRate(from, to)` SHALL resolve to a number
|
||||
when (a) `from === to` (returns `1`), (b) the cache has a direct entry for
|
||||
`from` with a `to` rate, (c) the cache has an inverse entry for `to` with a
|
||||
`from` rate (returns `1 / rate`), or (d) any cached non-expired base entry
|
||||
contains both `from` and `to` (returns `toRate / fromRate`). Otherwise it
|
||||
SHALL return `undefined`. `prune()` SHALL remove every expired entry and
|
||||
return the count removed. `size` SHALL return the underlying Map size
|
||||
(including expired entries). `getTimestamp(base)` SHALL return the underlying
|
||||
`result.timestamp` when the entry is fresh and `undefined` otherwise.
|
||||
|
||||
#### Scenario: fresh entry round-trip
|
||||
|
||||
- **WHEN** `set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: <now> })` followed by `get("USD")` is called
|
||||
- **THEN** the cached entry is returned with the same `base` / `rates` / `timestamp`
|
||||
|
||||
#### Scenario: expired entry returns undefined and is deleted
|
||||
|
||||
- **WHEN** an entry is stored with TTL 0 and `get(base)` is called after a microtask
|
||||
- **THEN** `get` returns `undefined` and `size` drops by 1
|
||||
|
||||
#### Scenario: direct rate lookup
|
||||
|
||||
- **WHEN** `getRate("USD", "EUR")` is called against a cache with a `USD` base entry containing `EUR: 0.92`
|
||||
- **THEN** the result is `0.92`
|
||||
|
||||
#### Scenario: inverse rate lookup
|
||||
|
||||
- **WHEN** `getRate("EUR", "USD")` is called against a cache that only has a `USD` base entry containing `EUR: 0.5`
|
||||
- **THEN** the result is `2` (1 / 0.5)
|
||||
|
||||
#### Scenario: triangulated lookup via intermediate base
|
||||
|
||||
- **WHEN** `getRate("EUR", "JPY")` is called against a cache whose only entry is `USD` base with `{ EUR: 0.5, JPY: 100 }`
|
||||
- **THEN** the result is `200` (100 / 0.5)
|
||||
|
||||
#### Scenario: identity rate
|
||||
|
||||
- **WHEN** `getRate("USD", "USD")` is called
|
||||
- **THEN** the result is `1`
|
||||
|
||||
#### Scenario: no path returns undefined
|
||||
|
||||
- **WHEN** `getRate("EUR", "JPY")` is called against an empty cache
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
#### Scenario: prune removes expired entries
|
||||
|
||||
- **WHEN** the cache has 3 entries, 2 of which have a past `expiresAt`, and `prune()` is called
|
||||
- **THEN** `prune` returns `2` and `size` is `1`
|
||||
|
||||
#### Scenario: getTimestamp returns fresh timestamp
|
||||
|
||||
- **WHEN** an entry is stored with `timestamp: 1234` and `getTimestamp(base)` is called before expiry
|
||||
- **THEN** the result is `1234`; after expiry, the result is `undefined`
|
||||
|
||||
#### Scenario: clear empties the cache
|
||||
|
||||
- **WHEN** `clear()` is called after storing one or more entries
|
||||
- **THEN** `size` is `0` and every subsequent `get(base)` returns `undefined`
|
||||
|
||||
**Verified by:** `src/rate-cache/rate-cache.test.ts::"stores and retrieves a fresh entry"` AND `src/rate-cache/rate-cache.test.ts::"deletes expired entries on get"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns direct rate"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns inverse rate"` AND `src/rate-cache/rate-cache.test.ts::"getRate triangulates via cached base"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns 1 for identity pair"` AND `src/rate-cache/rate-cache.test.ts::"getRate returns undefined when no path exists"` AND `src/rate-cache/rate-cache.test.ts::"prune removes expired entries and returns count"` AND `src/rate-cache/rate-cache.test.ts::"getTimestamp returns fresh timestamp and undefined when expired"` AND `src/rate-cache/rate-cache.test.ts::"clear empties the cache"` AND `src/rate-cache/rate-cache.test.ts::"has returns true for fresh, false for expired"` AND `src/rate-cache/rate-cache.test.ts::"getRate skips expired entries when triangulating"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CurrencyConverter convert / convertAmount / getRate
|
||||
|
||||
The `CurrencyConverter` class SHALL expose `convert(money, to, options?)`,
|
||||
`convertAmount(amount, from, to, options?)`, and `getRate(from, to, options?)`.
|
||||
`convert` SHALL return a `ConversionResult` whose `amount: bigint` is the
|
||||
target currency's minor-unit amount (`Math.round` of `majorAmount * rate *
|
||||
minorUnitMultiplier(toExponent)`), `rate` is the multiplicative factor used,
|
||||
`inverseRate` is `1 / rate`, `from` / `to` echo the call, `rateAgeMs` is
|
||||
`Date.now() - <cache timestamp for from-base or now>`, `providerName` is
|
||||
`"<identity>"` for `from === to`, `"<cache>"` when the rate was served from
|
||||
the cache without a provider call, `"<virtual>"` when the rate came from the
|
||||
virtual-currency seeding, and otherwise the `name` of the provider whose
|
||||
fetch produced the cached entry, and `triangulatedThrough: string[] | null`
|
||||
is the intermediate base currency when the cache triangulated, otherwise
|
||||
`null`. `getRate` SHALL return `1` for identity calls; for non-identity calls
|
||||
it SHALL first consult the cache (unless `options.fresh === true`), then walk
|
||||
the fallback chain. `convertAmount` SHALL return `amount * rate`.
|
||||
|
||||
#### Scenario: identity conversion short-circuits
|
||||
|
||||
- **WHEN** `convert(Money.fromMinor(100n, "USD"), "USD")` is called
|
||||
- **THEN** the result is `{ amount: 100n, rate: 1, inverseRate: 1, from: "USD", to: "USD", rateAgeMs: 0, providerName: "<identity>", triangulatedThrough: null }`
|
||||
|
||||
#### Scenario: direct conversion populates providerName
|
||||
|
||||
- **WHEN** the converter has a primary provider with `USD → EUR = 0.92` and `convert(Money.fromMajor("100", "USD"), "EUR")` is awaited
|
||||
- **THEN** the result has `amount === 9200n`, `rate === 0.92`, `providerName === <primary provider name>`, `triangulatedThrough === null`
|
||||
|
||||
#### Scenario: cached subsequent call reports providerName "<cache>"
|
||||
|
||||
- **WHEN** the same conversion is awaited a second time within the TTL window
|
||||
- **THEN** the second result's `providerName === "<cache>"`
|
||||
|
||||
#### Scenario: triangulated conversion reports the intermediary
|
||||
|
||||
- **WHEN** the cache is seeded with `USD → EUR = 0.5` and `USD → JPY = 100` and `convert(Money.fromMajor("10", "EUR"), "JPY")` is awaited
|
||||
- **THEN** the result has `rate === 200`, `triangulatedThrough === ["USD"]`, `providerName === "<cache>"`
|
||||
|
||||
#### Scenario: fallback chain takes over when primary fails
|
||||
|
||||
- **WHEN** the primary provider throws and the fallback provider has `USD → EUR = 0.92` and `convert(...)` is awaited
|
||||
- **THEN** the result's `providerName === <fallback provider name>` and the conversion succeeds
|
||||
|
||||
#### Scenario: every provider failing raises PROVIDER_UNAVAILABLE
|
||||
|
||||
- **WHEN** every provider in the chain throws on `fetchRates`
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.PROVIDER_UNAVAILABLE"`, `from === <source currency>`, and the underlying cause attached
|
||||
|
||||
#### Scenario: getRate fresh bypasses the cache
|
||||
|
||||
- **WHEN** `getRate("USD", "EUR", { fresh: true })` is called after a successful prior conversion
|
||||
- **THEN** the provider's `fetchRates` is invoked again (provider hit count increments)
|
||||
|
||||
#### Scenario: convertAmount returns numeric product
|
||||
|
||||
- **WHEN** `convertAmount(250, "USD", "EUR")` is awaited against `USD → EUR = 0.92`
|
||||
- **THEN** the result is `230` (250 * 0.92)
|
||||
|
||||
#### Scenario: zero-exponent target rounds correctly
|
||||
|
||||
- **WHEN** `convert(Money.fromMajor("10", "USD"), "JPY")` is awaited against `USD → JPY = 149.5`
|
||||
- **THEN** the result has `amount === 1495n` (JPY exponent 0; major * rate rounded)
|
||||
|
||||
#### Scenario: missing rate after fetch raises RATE_UNAVAILABLE
|
||||
|
||||
- **WHEN** the converter is configured with a provider whose `fetchRates` returns rates that do not include the requested target and `getRate("USD", "ZZZ")` is awaited
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.RATE_UNAVAILABLE"`, `from === "USD"`, `to === "ZZZ"`
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"identity conversion short-circuits with providerName <identity>"` AND `src/converter/converter.test.ts::"direct conversion populates providerName from the primary"` AND `src/converter/converter.test.ts::"second conversion within TTL reports providerName <cache>"` AND `src/converter/converter.test.ts::"triangulated conversion reports the intermediary base"` AND `src/converter/converter.test.ts::"fallback chain takes over when primary throws"` AND `src/converter/converter.test.ts::"all providers failing raises PROVIDER_UNAVAILABLE with cause"` AND `src/converter/converter.test.ts::"getRate fresh bypasses the cache and re-fetches"` AND `src/converter/converter.test.ts::"convertAmount returns numeric product"` AND `src/converter/converter.test.ts::"zero-exponent target rounds correctly"` AND `src/converter/converter.test.ts::"missing rate after fetch raises RATE_UNAVAILABLE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Virtual currency seeding and runtime mutation
|
||||
|
||||
When `ConverterConfig.virtualCurrencies` is supplied the constructor SHALL
|
||||
register each entry through `@suaveplan/money`'s `registerCurrency` and seed
|
||||
its fixed rate (against `defaultBase`) into the cache. After every successful
|
||||
provider fetch the seeded virtual rates SHALL be re-merged into the cache
|
||||
entry for `defaultBase` so they survive refreshes. The converter SHALL expose
|
||||
`setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` (aliases)
|
||||
that update the in-memory virtual rate map and re-seed the cache; and
|
||||
`getVirtualRates(): Readonly<Record<string, number>>` returning a defensive
|
||||
copy.
|
||||
|
||||
#### Scenario: virtual currency is available immediately
|
||||
|
||||
- **WHEN** `new CurrencyConverter({ provider, virtualCurrencies: { GOLD: { rate: 100, name: "Gold", symbol: "🪙" } } })` is constructed and `convertAmount(1, "USD", "GOLD")` is awaited (with `defaultBase: "USD"`)
|
||||
- **THEN** the result is `100` and no provider call is required
|
||||
|
||||
#### Scenario: virtual rates survive provider refresh
|
||||
|
||||
- **WHEN** the converter completes a `getRate("USD", "EUR", { fresh: true })` after `GOLD` was seeded, then `convertAmount(1, "USD", "GOLD")` is awaited
|
||||
- **THEN** the result is still `100` (re-seeding fired after the fresh fetch)
|
||||
|
||||
#### Scenario: setVirtualRate updates a virtual rate
|
||||
|
||||
- **WHEN** `setVirtualRate("GOLD", 150)` is called and `convertAmount(1, "USD", "GOLD")` is re-awaited
|
||||
- **THEN** the result is `150`
|
||||
|
||||
#### Scenario: updateVirtualRate is an alias
|
||||
|
||||
- **WHEN** `updateVirtualRate("GOLD", 200)` is called and `convertAmount(1, "USD", "GOLD")` is re-awaited
|
||||
- **THEN** the result is `200`
|
||||
|
||||
#### Scenario: getVirtualRates returns a defensive copy
|
||||
|
||||
- **WHEN** `getVirtualRates()` is mutated by the caller
|
||||
- **THEN** subsequent calls to `getVirtualRates()` return the original (unmodified) value
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"virtual currency is available immediately after construction"` AND `src/converter/converter.test.ts::"virtual rates survive a fresh provider refresh"` AND `src/converter/converter.test.ts::"setVirtualRate updates a virtual rate at runtime"` AND `src/converter/converter.test.ts::"updateVirtualRate is an alias for setVirtualRate"` AND `src/converter/converter.test.ts::"getVirtualRates returns a defensive copy"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal cancellation through the fallback chain
|
||||
|
||||
The `CurrencyConverter` SHALL honour `options.signal: AbortSignal` on every
|
||||
public method (`convert`, `convertAmount`, `getRate`). When the signal is
|
||||
already aborted at call time, the method SHALL raise
|
||||
`CurrencyError({ code: CURRENCY.ABORTED })` without consulting any provider.
|
||||
When the signal aborts between fallback attempts, the chain SHALL stop and
|
||||
raise `CurrencyError({ code: CURRENCY.ABORTED })`. The same contract applies
|
||||
to the reference providers' `fetchRates`.
|
||||
|
||||
#### Scenario: already-aborted signal raises ABORTED on convert
|
||||
|
||||
- **WHEN** `convert(money, "EUR", { signal: <aborted> })` is awaited
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"` and no provider call occurs
|
||||
|
||||
#### Scenario: signal aborted between fallback attempts
|
||||
|
||||
- **WHEN** the primary provider throws, an `AbortController.abort()` is scheduled via `queueMicrotask`, and `convert(...)` is awaited
|
||||
- **THEN** the chain stops, the fallback provider is never called, and a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
|
||||
|
||||
#### Scenario: already-aborted signal raises ABORTED on convertAmount
|
||||
|
||||
- **WHEN** `convertAmount(1, "USD", "EUR", { signal: <aborted> })` is awaited
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
|
||||
|
||||
#### Scenario: already-aborted signal raises ABORTED on getRate
|
||||
|
||||
- **WHEN** `getRate("USD", "EUR", { signal: <aborted> })` is awaited
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"`
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on convert"` AND `src/converter/converter.test.ts::"signal aborted between fallback attempts stops the chain"` AND `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on convertAmount"` AND `src/converter/converter.test.ts::"already-aborted signal raises ABORTED on getRate"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod-validated public boundary
|
||||
|
||||
The package SHALL export `ConverterConfigSchema`, `ConvertOptionsSchema`,
|
||||
`VirtualCurrencyConfigSchema`, and `RateFetchOptionsSchema` Zod schemas
|
||||
defining the accepted shape of each public option bundle. The
|
||||
`CurrencyConverter` constructor SHALL validate its argument against
|
||||
`ConverterConfigSchema` and raise
|
||||
`CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure, with
|
||||
`error.context.issues` carrying the Zod issue array. `convert`,
|
||||
`convertAmount`, and `getRate` SHALL each validate their `options` argument
|
||||
(when supplied) against `ConvertOptionsSchema` and raise
|
||||
`CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure.
|
||||
|
||||
#### Scenario: invalid config rejected
|
||||
|
||||
- **WHEN** `new CurrencyConverter({ provider: null as never })` is constructed
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_CONFIG"` and `context.issues` is a non-empty array
|
||||
|
||||
#### Scenario: invalid options rejected
|
||||
|
||||
- **WHEN** `converter.convert(money, "EUR", { rounding: "nope" as never })` is awaited
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_OPTIONS"`
|
||||
|
||||
#### Scenario: invalid virtual currency rejected
|
||||
|
||||
- **WHEN** `new CurrencyConverter({ provider, virtualCurrencies: { GOLD: { rate: -1, name: "Gold", symbol: "🪙" } } })` is constructed
|
||||
- **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.INVALID_CONFIG"`
|
||||
|
||||
#### Scenario: valid options accepted
|
||||
|
||||
- **WHEN** a well-formed `ConverterConfig` and well-formed `ConvertOptions` are passed
|
||||
- **THEN** no validation error is raised and the call proceeds
|
||||
|
||||
#### Scenario: empty options accepted
|
||||
|
||||
- **WHEN** `convert(money, "EUR")` is called with no options
|
||||
- **THEN** no validation error is raised
|
||||
|
||||
#### Scenario: parse helpers raise the same errors
|
||||
|
||||
- **WHEN** `parseConverterConfig({ provider: null as never })` and `parseConvertOptions({ rounding: "nope" as never })` are called
|
||||
- **THEN** they raise `CurrencyError` with `INVALID_CONFIG` / `INVALID_OPTIONS` respectively
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"ConverterConfigSchema rejects null provider"` AND `src/schemas/schemas.test.ts::"ConvertOptionsSchema rejects unknown rounding"` AND `src/schemas/schemas.test.ts::"VirtualCurrencyConfigSchema rejects non-positive rate"` AND `src/schemas/schemas.test.ts::"ConverterConfigSchema accepts a well-formed config"` AND `src/schemas/schemas.test.ts::"ConvertOptionsSchema accepts an empty options object"` AND `src/schemas/schemas.test.ts::"RateFetchOptionsSchema accepts base, targets, and signal"` AND `src/schemas/schemas.test.ts::"parseConverterConfig raises INVALID_CONFIG"` AND `src/schemas/schemas.test.ts::"parseConvertOptions raises INVALID_OPTIONS"` AND `src/converter/converter.test.ts::"constructor raises INVALID_CONFIG when provider is null"` AND `src/converter/converter.test.ts::"convert raises INVALID_OPTIONS when options is malformed"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CurrencyConverter setRate / clearCache / getCache management surface
|
||||
|
||||
The class SHALL expose `setRate(from, to, rate)` to insert a rate directly
|
||||
into the underlying cache (merging into an existing entry when present),
|
||||
`clearCache()` to empty the cache, and `getCache()` to return the underlying
|
||||
`RateCache` for inspection.
|
||||
|
||||
#### Scenario: setRate inserts a new pair
|
||||
|
||||
- **WHEN** `converter.setRate("USD", "EUR", 0.92)` is called and `getRate("USD", "EUR")` is awaited
|
||||
- **THEN** the result is `0.92`
|
||||
|
||||
#### Scenario: setRate merges with an existing entry
|
||||
|
||||
- **WHEN** `setRate("USD", "EUR", 0.92)` and then `setRate("USD", "GBP", 0.79)` are called
|
||||
- **THEN** the cache entry for `USD` contains both `EUR: 0.92` and `GBP: 0.79`
|
||||
|
||||
#### Scenario: clearCache empties the cache
|
||||
|
||||
- **WHEN** `clearCache()` is called after a successful conversion
|
||||
- **THEN** subsequent calls re-hit the provider
|
||||
|
||||
#### Scenario: getCache returns the inspectable instance
|
||||
|
||||
- **WHEN** `getCache()` is called
|
||||
- **THEN** the result is an instance of `RateCache` and reflects the current `size`
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"setRate inserts a new pair into the cache"` AND `src/converter/converter.test.ts::"setRate merges with an existing base entry"` AND `src/converter/converter.test.ts::"clearCache empties the cache and re-hits the provider"` AND `src/converter/converter.test.ts::"getCache returns the inspectable RateCache instance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sub-path exports per public module
|
||||
|
||||
The `package.json` SHALL expose six sub-path exports in addition to the root
|
||||
`"."` export: `./converter/converter`, `./rate-cache/rate-cache`,
|
||||
`./provider/provider`, `./error-codes/error-codes`, `./schemas/schemas`, and
|
||||
`./types/common`. Every sub-path SHALL resolve to a `.d.ts` and a `.js` file
|
||||
under `dist/`. Every feature-module `index.ts` barrel SHALL use explicit
|
||||
named re-exports (no `export *`).
|
||||
|
||||
#### Scenario: sub-path exports declared
|
||||
|
||||
- **WHEN** the test reads `package.json`'s `exports` keys
|
||||
- **THEN** the list includes `./converter/converter`, `./rate-cache/rate-cache`, `./provider/provider`, `./error-codes/error-codes`, `./schemas/schemas`, `./types/common`
|
||||
|
||||
#### Scenario: no `export *` in feature barrels
|
||||
|
||||
- **WHEN** `grep` is run for `export\s+\*\s+from` inside `src/**/index.ts` files (excluding the root `src/index.ts`)
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"sub-path exports are declared in package.json"` AND `src/converter/converter.test.ts::"feature barrels use explicit named re-exports"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
67
openspec/archive/2026-05-17-add-currency/tasks.md
Normal file
67
openspec/archive/2026-05-17-add-currency/tasks.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Tasks — add @suaveplan/currency
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/currency.md` end-to-end
|
||||
- [x] 1.2 `openspec/specs/currency/spec.md` does not exist — confirmed
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range
|
||||
- [x] 1.6 Draft `specs/currency/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/finance/currency/` (`package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `tests/setup/polyfills.ts`, `README.md`)
|
||||
- [x] 2.2 `src/types/common.ts` — `CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`, `VirtualCurrencyConfig`, `ConverterConfig`, `ConvertOptions`, `ConversionResult` (all named interfaces; no inline shapes)
|
||||
- [x] 2.3 `src/error-codes/error-codes.ts` — `CURRENCY_CODES` registry + `CurrencyError extends SuaveplanError<CurrencyErrorCode>` with optional `from` / `to` / `providerName` discriminants
|
||||
- [x] 2.4 `src/schemas/schemas.ts` — Zod schemas + `parseConverterConfig` / `parseConvertOptions` / `parseVirtualCurrencyConfig` / `parseRateFetchOptions` helpers that throw `CurrencyError`
|
||||
- [x] 2.5 `src/rate-cache/rate-cache.ts` — `RateCache` class with TTL eviction, direct / inverse / triangulated lookup
|
||||
- [x] 2.6 `src/provider/provider.ts` — `InMemoryProvider` class + `createStaticProvider` factory honouring AbortSignal
|
||||
- [x] 2.7 `src/converter/converter.ts` — `CurrencyConverter` class with fallback chain, triangulation, virtual currency, AbortSignal, Zod-validated boundary, enriched `ConversionResult`
|
||||
- [x] 2.8 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.9 Sub-path exports declared in `package.json` for every public module
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/converter/converter.test.ts` — every requirement scenario
|
||||
- [x] 3.2 `src/rate-cache/rate-cache.test.ts` — every requirement scenario
|
||||
- [x] 3.3 `src/provider/provider.test.ts` — every requirement scenario
|
||||
- [x] 3.4 `src/error-codes/error-codes.test.ts` — every `CURRENCY.*` code reachable from a call site; SuaveplanError contract preserved
|
||||
- [x] 3.5 `src/schemas/schemas.test.ts` — every Zod schema accepts a valid payload and rejects each documented bad shape
|
||||
- [x] 3.6 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 3.7 All test files import runner primitives from `@suaveplan/testing/runner`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 Update `MANIFEST.md` / `STATUS.md` / `SUMMARY.md` / `MISSING.yml` on archive
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info on touched files
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/currency` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/currency` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/currency` — exit 0 (if script applies)
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/currency` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist (skill SKILL.md step 7)
|
||||
|
||||
- [x] 6.1 Generics threaded; literal-type assertions on every literal-type claim
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (`CurrencyConverter` constructor + `convert` / `convertAmount` / `getRate` entry)
|
||||
- [x] 6.3 Every `CURRENCY.*` code thrown by at least one production path
|
||||
- [x] 6.4 Strict-mode behaviours (AbortSignal cancellation; identity short-circuit) tested
|
||||
- [x] 6.5 No OTel instruments shipped this change (deferred — see proposal `## Out of scope`)
|
||||
- [x] 6.6 Lifecycle dedup tested (virtual-currency reseed after every provider fetch)
|
||||
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
- [x] 6.8 No `export *` in any feature-module barrel
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports in test files
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/currency/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-currency/` → `openspec/archive/2026-05-17-add-currency/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`
|
||||
Reference in New Issue
Block a user