Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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%.

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

View 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 -->

View 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`