Files
2026-05-27 23:29:53 +01:00

24 KiB

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

  • 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"