24 KiB
Capability Spec: currency
Package: @suaveplan/currency
Status: SHIPPED (2026-05-17 — add-currency)
Folded from
openspec/changes/add-currency/specs/currency/spec.mdon 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
CurrencyErrorwhosecodematches the expectedCURRENCY.*constant
Scenario: SuaveplanError contract preserved
- WHEN a
CurrencyErroris thrown with acause - THEN the resulting error is
instanceof SuaveplanError, exposes the original onerror.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 andprovider.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
ratesmap contains onlyEUR
Scenario: missing base raises PROVIDER_UNAVAILABLE
- WHEN
provider.fetchRates({ base: "ZZZ" })is called against a provider with noZZZrates - THEN a
CurrencyErroris thrown withcode === "CURRENCY.PROVIDER_UNAVAILABLE"andproviderName === provider.name
Scenario: aborted fetch raises ABORTED
- WHEN
provider.fetchRates({ base: "USD", signal })is called with an already-abortedAbortSignal - THEN a
CurrencyErroris thrown withcode === "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"andrates.EUR === 0.92, and the provider exposesname === "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 byget("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
getreturnsundefinedandsizedrops by 1
Scenario: direct rate lookup
- WHEN
getRate("USD", "EUR")is called against a cache with aUSDbase entry containingEUR: 0.92 - THEN the result is
0.92
Scenario: inverse rate lookup
- WHEN
getRate("EUR", "USD")is called against a cache that only has aUSDbase entry containingEUR: 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 isUSDbase 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, andprune()is called - THEN
prunereturns2andsizeis1
Scenario: getTimestamp returns fresh timestamp
- WHEN an entry is stored with
timestamp: 1234andgetTimestamp(base)is called before expiry - THEN the result is
1234; after expiry, the result isundefined
Scenario: clear empties the cache
- WHEN
clear()is called after storing one or more entries - THEN
sizeis0and every subsequentget(base)returnsundefined
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.92andconvert(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.5andUSD → JPY = 100andconvert(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.92andconvert(...)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
CurrencyErroris thrown withcode === "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
fetchRatesis invoked again (provider hit count increments)
Scenario: convertAmount returns numeric product
- WHEN
convertAmount(250, "USD", "EUR")is awaited againstUSD → 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 againstUSD → 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
fetchRatesreturns rates that do not include the requested target andgetRate("USD", "ZZZ")is awaited - THEN a
CurrencyErroris thrown withcode === "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 andconvertAmount(1, "USD", "GOLD")is awaited (withdefaultBase: "USD") - THEN the result is
100and no provider call is required
Scenario: virtual rates survive provider refresh
- WHEN the converter completes a
getRate("USD", "EUR", { fresh: true })afterGOLDwas seeded, thenconvertAmount(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 andconvertAmount(1, "USD", "GOLD")is re-awaited - THEN the result is
150
Scenario: updateVirtualRate is an alias
- WHEN
updateVirtualRate("GOLD", 200)is called andconvertAmount(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
CurrencyErroris thrown withcode === "CURRENCY.ABORTED"and no provider call occurs
Scenario: signal aborted between fallback attempts
- WHEN the primary provider throws, an
AbortController.abort()is scheduled viaqueueMicrotask, andconvert(...)is awaited - THEN the chain stops, the fallback provider is never called, and a
CurrencyErroris thrown withcode === "CURRENCY.ABORTED"
Scenario: already-aborted signal raises ABORTED on convertAmount
- WHEN
convertAmount(1, "USD", "EUR", { signal: <aborted> })is awaited - THEN a
CurrencyErroris thrown withcode === "CURRENCY.ABORTED"
Scenario: already-aborted signal raises ABORTED on getRate
- WHEN
getRate("USD", "EUR", { signal: <aborted> })is awaited - THEN a
CurrencyErroris thrown withcode === "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
CurrencyErroris thrown withcode === "CURRENCY.INVALID_CONFIG"andcontext.issuesis a non-empty array
Scenario: invalid options rejected
- WHEN
converter.convert(money, "EUR", { rounding: "nope" as never })is awaited - THEN a
CurrencyErroris thrown withcode === "CURRENCY.INVALID_OPTIONS"
Scenario: invalid virtual currency rejected
- WHEN
new CurrencyConverter({ provider, virtualCurrencies: { GOLD: { rate: -1, name: "Gold", symbol: "🪙" } } })is constructed - THEN a
CurrencyErroris thrown withcode === "CURRENCY.INVALID_CONFIG"
Scenario: valid options accepted
- WHEN a well-formed
ConverterConfigand well-formedConvertOptionsare 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 })andparseConvertOptions({ rounding: "nope" as never })are called - THEN they raise
CurrencyErrorwithINVALID_CONFIG/INVALID_OPTIONSrespectively
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 andgetRate("USD", "EUR")is awaited - THEN the result is
0.92
Scenario: setRate merges with an existing entry
- WHEN
setRate("USD", "EUR", 0.92)and thensetRate("USD", "GBP", 0.79)are called - THEN the cache entry for
USDcontains bothEUR: 0.92andGBP: 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
RateCacheand reflects the currentsize
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'sexportskeys - 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
grepis run forexport\s+\*\s+frominsidesrc/**/index.tsfiles (excluding the rootsrc/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"