# Capability Spec: currency **Package**: `@suaveplan/currency` **Status**: SHIPPED (2026-05-17 — `add-currency`) > Folded from `openspec/changes/add-currency/specs/currency/spec.md` on > 2026-05-17. Every requirement carries its original `**Verified by:**` line > citing the test that pins each scenario. ## 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` 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`. 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: }` #### 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: })` 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() - `, `providerName` is `""` for `from === to`, `""` when the rate was served from the cache without a provider call, `""` 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: "", 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 === `, `triangulatedThrough === null` #### Scenario: cached subsequent call reports providerName "" - **WHEN** the same conversion is awaited a second time within the TTL window - **THEN** the second result's `providerName === ""` #### 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 === ""` #### 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 === ` 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 === `, 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 "` 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 "` 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>` 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: })` 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: })` is awaited - **THEN** a `CurrencyError` is thrown with `code === "CURRENCY.ABORTED"` #### Scenario: already-aborted signal raises ABORTED on getRate - **WHEN** `getRate("USD", "EUR", { signal: })` 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"`