20 KiB
Capability Spec Delta: currency-providers (add-currency-providers)
Package: @suaveplan/currency-providers
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
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. 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-providers/src/is run - THEN the command produces zero matches
Verified by: src/providers/ecb/ecb.test.ts::"is universal-tier (no node/bun imports)"
Requirement: Stable CURRENCY_PROVIDER_CODES error registry and CurrencyProviderError class
The package SHALL declare an error-code registry via
defineCodes("CURRENCY_PROVIDER", [...]) containing exactly the codes
HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR, UNSUPPORTED_BASE,
MISSING_CONFIG, UNKNOWN_CURRENCY. Every public error path SHALL throw a
CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>
carrying one of those codes. CurrencyProviderError SHALL expose optional
providerName, status, and upstreamMessage 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 an HTTP 5xx response, a malformed
upstream payload, an upstream-flagged failure (Fixer
success:false, Krakenerror[]non-empty), an ECB base it does not quote, a config without anapiKeywhere one is required, and a virtual currency lookup for a code that has not been registered - THEN each path raises a
CurrencyProviderErrorwhosecodematches the expectedCURRENCY_PROVIDER.*constant
Scenario: SuaveplanError contract preserved
- WHEN a
CurrencyProviderErroris thrown - THEN it is
instanceof SuaveplanError, itscodeis aCurrencyProviderErrorCode, and its optionalproviderName/status/upstreamMessagediscriminants are accessible without type narrowing
Verified by: src/error-codes/error-codes.test.ts::"every CURRENCY_PROVIDER code is reachable from a call site" AND src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract"
Requirement: Injectable FetchLike on every provider
Every provider SHALL accept an optional fetch: FetchLike (from
@suaveplan/types) in its constructor config and SHALL use it for every
HTTP call. When not supplied, the provider SHALL default to
globalThis.fetch. Tests SHALL exercise providers by injecting a real
FetchLike closure that returns a synthesised WHATWG Response — no
package SHALL mock globalThis.fetch via vi.stubGlobal or vi.mock.
Scenario: provider uses injected fetch
- WHEN a provider is constructed with a custom
fetchclosure that records the URL it was called with - THEN
fetchRates(...)calls the injected closure (notglobalThis.fetch), and the recorded URL matches the provider's expected endpoint
Verified by: src/providers/ecb/ecb.test.ts::"uses injected fetch closure" AND src/providers/binance/binance.test.ts::"uses injected fetch closure"
Requirement: EcbProvider — ECB daily XML feed
The package SHALL export EcbProvider implements CurrencyProvider with
name === "ecb", supportsCrypto === false, supportsFiat === true. It
SHALL fetch from
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml (overridable
via config.baseUrl), parse the XML rate envelope, return EUR-based rates
by default, and convert to any other base by dividing through the EUR rate
when a base other than EUR is requested. An HTTP non-2xx response SHALL
raise CURRENCY_PROVIDER.HTTP_ERROR; a request for a base ECB does not
quote SHALL raise CURRENCY_PROVIDER.UNSUPPORTED_BASE. Calls with a
targets array SHALL filter the returned rate map to only those codes.
Scenario: parses EUR-base XML and filters targets
- WHEN
fetchRates({ base: "EUR", targets: ["USD", "GBP"] })is called and the injectedfetchreturns the canonical ECB envelope - THEN the result has
base === "EUR", exactly two rates (USD,GBP) numerically matching the envelope, and a positivetimestamp
Scenario: converts to non-EUR base
- WHEN
fetchRates({ base: "USD" })is called against the same envelope - THEN
result.base === "USD"andresult.rates.EURequals1 / USD_rate
Scenario: HTTP error becomes CURRENCY_PROVIDER.HTTP_ERROR
- WHEN the injected fetch returns a non-2xx Response
- THEN
fetchRates()rejects with aCurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.HTTP_ERROR"
Scenario: unsupported base raises UNSUPPORTED_BASE
- WHEN
fetchRates({ base: "XYZ" })is called against the canonical envelope (XYZ not present) - THEN it rejects with a
CurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"
Verified by: src/providers/ecb/ecb.test.ts::"parses EUR-base XML and filters targets" AND src/providers/ecb/ecb.test.ts::"converts to non-EUR base" AND src/providers/ecb/ecb.test.ts::"raises HTTP_ERROR on non-2xx response" AND src/providers/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE for missing currency"
Requirement: FixerProvider — Fixer.io with API key
The package SHALL export FixerProvider implements CurrencyProvider with
name === "fixer", supportsCrypto === false, supportsFiat === true. It
SHALL require ApiKeyConfig.apiKey (constructor raises
CURRENCY_PROVIDER.MISSING_CONFIG if absent), call
<baseUrl>/latest?access_key=<key>&base=<base>&symbols=<targets> (symbols
only when targets is provided), and return { base, rates, timestamp }
where timestamp is the upstream timestamp * 1000. An upstream
success === false SHALL raise CURRENCY_PROVIDER.UPSTREAM_ERROR carrying
upstreamMessage from the response's error.info.
Scenario: builds correct URL and returns rates
- WHEN
fetchRates({ base: "EUR", targets: ["USD", "GBP"] })is called with a successful payload - THEN the recorded URL contains
access_key=,base=EUR, andsymbols=USD,GBP, and the result is the upstreamrateswithtimestampin ms
Scenario: upstream success:false becomes UPSTREAM_ERROR
- WHEN the upstream returns 200 OK with
{ success: false, error: { info: "invalid key" } } - THEN the call rejects with
code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"andupstreamMessage === "invalid key"
Scenario: missing apiKey raises MISSING_CONFIG
- WHEN
new FixerProvider({ apiKey: "" })is constructed - THEN it throws
CurrencyProviderErrorwithcode === "CURRENCY_PROVIDER.MISSING_CONFIG"
Verified by: src/providers/fixer/fixer.test.ts::"builds URL and returns rates" AND src/providers/fixer/fixer.test.ts::"raises UPSTREAM_ERROR on success:false" AND src/providers/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey absent"
Requirement: OpenExchangeRatesProvider — OXR with API key
The package SHALL export OpenExchangeRatesProvider implements CurrencyProvider
with name === "open-exchange-rates", supportsCrypto === false,
supportsFiat === true. It SHALL require ApiKeyConfig.apiKey, call
<baseUrl>/latest.json?app_id=<key>&base=<base>, filter by targets when
provided, and convert upstream timestamp (seconds) to ms.
Scenario: builds URL with API key and base
- WHEN
fetchRates({ base: "USD", targets: ["EUR"] })is called - THEN the recorded URL contains
app_id=,base=USD, and the result has exactly one rateEURand the upstream timestamp converted to ms
Scenario: raises MISSING_CONFIG when apiKey absent
- WHEN
new OpenExchangeRatesProvider({ apiKey: "" })is constructed - THEN it throws
code === "CURRENCY_PROVIDER.MISSING_CONFIG"
Scenario: HTTP error becomes HTTP_ERROR
- WHEN the injected fetch returns 401 Unauthorized
- THEN the call rejects with
code === "CURRENCY_PROVIDER.HTTP_ERROR"andstatus === 401
Verified by: src/providers/open-exchange-rates/open-exchange-rates.test.ts::"builds URL with API key and filters targets" AND src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises MISSING_CONFIG when apiKey absent" AND src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises HTTP_ERROR on 401"
Requirement: BinanceProvider — Binance spot ticker
The package SHALL export BinanceProvider implements CurrencyProvider with
name === "binance", supportsCrypto === true, supportsFiat === false,
defaulting to USDT base. It SHALL call <baseUrl>/ticker/price, treat each
returned {symbol, price} as a pair ending in the requested base, strip the
suffix to derive the canonical symbol, skip zero-price tickers, and invert
the price (1/price). targets SHALL filter the resulting map.
Scenario: parses tickers and inverts prices
- WHEN
fetchRates({ base: "USDT" })is called against a synthesised ticker list containingBTCUSDT,ETHUSDT,BTCEUR - THEN result contains
BTCandETH(USDT-suffixed) with1 / price, and does not containBTCEUR
Scenario: skips zero-price tickers
- WHEN the ticker list contains a
FAKUSDTentry withprice === "0" - THEN the result does not contain a
FAKkey
Scenario: raises HTTP_ERROR on non-2xx
- WHEN the injected fetch returns 503
- THEN the call rejects with
code === "CURRENCY_PROVIDER.HTTP_ERROR"
Verified by: src/providers/binance/binance.test.ts::"parses tickers with USDT base and inverts prices" AND src/providers/binance/binance.test.ts::"skips zero-price tickers" AND src/providers/binance/binance.test.ts::"raises HTTP_ERROR on non-2xx response"
Requirement: CoinGeckoProvider — simple/price endpoint + getCoinGeckoId
The package SHALL export CoinGeckoProvider implements CurrencyProvider
with name === "coingecko", supportsCrypto === true,
supportsFiat === true, defaulting to USD base. It SHALL call
<baseUrl>/simple/price?ids=<coin-ids>&vs_currencies=<lower-base>, map the
returned CoinGecko ids back to canonical symbols using its built-in
registry, invert the price (1/price), filter by targets, and return
{ base, rates, timestamp }. The package SHALL also export
getCoinGeckoId(symbol: string): string | undefined — a case-insensitive
lookup against the same built-in registry.
Scenario: maps coin ids back to symbols and inverts prices
- WHEN
fetchRates({ base: "USD" })is called against a payload containingbitcoin,ethereum,solana - THEN result contains
BTC,ETH,SOLwith1 / priceeach
Scenario: getCoinGeckoId is case-insensitive
- WHEN
getCoinGeckoId("btc")andgetCoinGeckoId("BTC")are called - THEN both return
"bitcoin"
Scenario: unknown symbol returns undefined
- WHEN
getCoinGeckoId("ZZZNOPE")is called - THEN the result is
undefined
Verified by: src/providers/coingecko/coingecko.test.ts::"maps coin ids back to symbols and inverts prices" AND src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId is case-insensitive" AND src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId returns undefined for unknown symbol"
Requirement: CoinMarketCapProvider — quotes/latest with API key header
The package SHALL export CoinMarketCapProvider implements CurrencyProvider
with name === "coinmarketcap", supportsCrypto === true,
supportsFiat === true. It SHALL require ApiKeyConfig.apiKey
(CURRENCY_PROVIDER.MISSING_CONFIG if absent), call
<baseUrl>/cryptocurrency/quotes/latest?symbol=<symbols>&convert=<base>
with X-CMC_PRO_API_KEY and Accept: application/json request headers,
invert the price (1/price), and return { base, rates, timestamp }.
targets SHALL replace the default symbol list when provided.
Scenario: sends API key header and parses quotes
- WHEN
fetchRates({ base: "USD", targets: ["BTC", "ETH"] })is called and the injected fetch records the request init - THEN the recorded
init.headerscontainsX-CMC_PRO_API_KEYand the result hasBTCandETHwith1 / quote.USD.price
Scenario: missing apiKey raises MISSING_CONFIG
- WHEN
new CoinMarketCapProvider({ apiKey: "" })is constructed - THEN it throws
code === "CURRENCY_PROVIDER.MISSING_CONFIG"
Verified by: src/providers/coinmarketcap/coinmarketcap.test.ts::"sends API key header and parses quotes" AND src/providers/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey absent"
Requirement: CryptoCompareProvider — pricemulti
The package SHALL export CryptoCompareProvider implements CurrencyProvider
with name === "cryptocompare", supportsCrypto === true,
supportsFiat === true, defaulting to USD base. It SHALL call
<baseUrl>/pricemulti?fsyms=<targets-or-defaults>&tsyms=<base>, invert the
price (1/price), and skip entries whose price is <= 0.
Scenario: inverts pricemulti payload and skips zero prices
- WHEN
fetchRates({ base: "USD", targets: ["BTC", "FAKE"] })is called against{ BTC: { USD: 67000 }, FAKE: { USD: 0 } } - THEN result contains
BTCwith1 / 67000and does not containFAKE
Scenario: raises HTTP_ERROR on non-2xx
- WHEN the injected fetch returns 500
- THEN the call rejects with
code === "CURRENCY_PROVIDER.HTTP_ERROR"
Verified by: src/providers/cryptocompare/cryptocompare.test.ts::"inverts pricemulti payload and skips zero prices" AND src/providers/cryptocompare/cryptocompare.test.ts::"raises HTTP_ERROR on non-2xx response"
Requirement: KrakenProvider — Ticker with pair→symbol reverse mapping
The package SHALL export KrakenProvider implements CurrencyProvider with
name === "kraken", supportsCrypto === true, supportsFiat === false,
defaulting to USD base. It SHALL call <baseUrl>/Ticker?pair=<pair-list>,
maintain a built-in Kraken-pair-name → canonical-symbol map, invert the
c[0] price (1/price), and skip zero-price entries. A non-empty
result.error[] SHALL raise CURRENCY_PROVIDER.UPSTREAM_ERROR carrying
the joined error message as upstreamMessage.
Scenario: reverse-maps Kraken pairs and inverts prices
- WHEN
fetchRates({ base: "USD" })is called against a payload containingXXBTZUSD,XETHZUSD,SOLUSD - THEN result contains
BTC,ETH,SOLwith1 / c[0]each
Scenario: upstream error array raises UPSTREAM_ERROR
- WHEN the upstream returns
{ error: ["EGeneral:Too many requests"], result: {} } - THEN the call rejects with
code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"andupstreamMessagecontains"Too many requests"
Verified by: src/providers/kraken/kraken.test.ts::"reverse-maps Kraken pairs and inverts prices" AND src/providers/kraken/kraken.test.ts::"raises UPSTREAM_ERROR on non-empty error array"
Requirement: VirtualCurrencyProvider — operator-defined currencies
The package SHALL export VirtualCurrencyProvider implements CurrencyProvider
with name === "virtual-currency", supportsCrypto === false,
supportsFiat === false, supportsVirtual === true. Construction SHALL
register every configured currency with @suaveplan/money's
registerCurrency. The provider SHALL expose addCurrency(code, config),
updateRate(code, rate), removeCurrency(code), getRate(code),
getCurrencyCodes(). updateRate for an unknown code SHALL raise
CURRENCY_PROVIDER.UNKNOWN_CURRENCY. fetchRates({ base }) SHALL return
all configured rates when base matches the configured base, and SHALL
convert via 1/rate (rate-against-base) when base is itself a virtual
currency. A base that is neither the configured base nor a registered
virtual code SHALL raise CURRENCY_PROVIDER.UNSUPPORTED_BASE.
Scenario: registers currencies and exposes rates
- WHEN the provider is constructed with
{ currencies: { GOLD: {...}, GEMS: {...} } } - THEN
getCurrencyCodes()returns["GOLD", "GEMS"],getRate("GOLD")returns the configured rate, andfetchRates()returns both rates against the configured base
Scenario: addCurrency and updateRate mutate at runtime
- WHEN
addCurrency("SILVER", {...})thenupdateRate("SILVER", 99)are called - THEN
fetchRates()includesSILVER === 99
Scenario: updateRate for unknown code raises UNKNOWN_CURRENCY
- WHEN
updateRate("NOPE", 1)is called on a fresh provider - THEN it throws
code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
Scenario: virtual-base conversion
- WHEN
fetchRates({ base: "GOLD" })is called against{ GOLD: {rate: 100}, GEMS: {rate: 500} }with configured baseEUR - THEN
result.base === "GOLD",result.rates.EUR === 1/100,result.rates.GEMS === 500/100
Scenario: unsupported base raises UNSUPPORTED_BASE
- WHEN
fetchRates({ base: "UNKNOWN" })is called against a provider with noUNKNOWNcurrency - THEN it rejects with
code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"
Verified by: src/providers/virtual/virtual.test.ts::"registers currencies and exposes rates" AND src/providers/virtual/virtual.test.ts::"addCurrency and updateRate mutate at runtime" AND src/providers/virtual/virtual.test.ts::"updateRate for unknown code raises UNKNOWN_CURRENCY" AND src/providers/virtual/virtual.test.ts::"converts when base is a virtual currency" AND src/providers/virtual/virtual.test.ts::"raises UNSUPPORTED_BASE for unknown base"
Requirement: Configuration types are named interfaces
The package SHALL export ApiKeyConfig, NoKeyConfig, HttpFetchOptions
as named TypeScript interfaces (no inline object literals in provider
constructor signatures). Every provider that requires an API key SHALL
type its config as ApiKeyConfig; every provider without one SHALL type
its config as NoKeyConfig. Every provider config SHALL extend with an
optional fetch?: FetchLike.
Scenario: configs are usable from external code
- WHEN a consumer imports
ApiKeyConfigand passes it tonew FixerProvider(config) - THEN TypeScript accepts the call with
apiKey: stringand rejects a call missingapiKey
Verified by: src/providers/fixer/fixer.test.ts::"accepts ApiKeyConfig with apiKey" AND src/providers/ecb/ecb.test.ts::"accepts NoKeyConfig with optional baseUrl"
Requirement: Explicit named barrel exports
The package SHALL re-export every public symbol from src/index.ts using
explicit named re-exports (export { Name } from "...") — no export *
anywhere except (a) within *.select.ts, *.native/index.ts,
*.noble/index.ts impl-variant forwarders (none exist here) or (b)
namespace re-exports (export * as <ns> from). Every feature module
(providers/<name>/index.ts) SHALL likewise use explicit named re-exports.
Scenario: src/index.ts has no export *
- WHEN
grep -nE "^\s*export\s+(type\s+)?\*\s+from" src/index.tsis run - THEN the command produces zero matches
Verified by: tests/dep-graph.test.ts::"src/index.ts has no export * statements" AND tests/dep-graph.test.ts::"feature barrels have no export * statements"
Requirement: Subpath exports per public module
package.json SHALL declare a subpath export for every public module so
consumers can tree-shake. Required subpaths: ./error-codes/error-codes,
./types/configs, and one per provider
(./providers/{ecb,fixer,open-exchange-rates,binance,coingecko,coinmarketcap,cryptocompare,kraken,virtual}/<name>).
Scenario: every feature module has a subpath export
- WHEN
package.jsonexportsmap is enumerated - THEN it contains at least 11 entries (root
.+ the 10 listed subpaths), each pointing to the matching compiled.jsand.d.ts
Verified by: tests/dep-graph.test.ts::"package.json declares subpath exports for every feature module"