Files
suaveplan/openspec/archive/2026-05-17-add-currency-providers/specs/currency-providers/spec.md
2026-05-27 23:29:53 +01:00

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, Kraken error[] non-empty), an ECB base it does not quote, a config without an apiKey where one is required, and a virtual currency lookup for a code that has not been registered
  • THEN each path raises a CurrencyProviderError whose code matches the expected CURRENCY_PROVIDER.* constant

Scenario: SuaveplanError contract preserved

  • WHEN a CurrencyProviderError is thrown
  • THEN it is instanceof SuaveplanError, its code is a CurrencyProviderErrorCode, and its optional providerName / status / upstreamMessage discriminants 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 fetch closure that records the URL it was called with
  • THEN fetchRates(...) calls the injected closure (not globalThis.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 injected fetch returns the canonical ECB envelope
  • THEN the result has base === "EUR", exactly two rates (USD, GBP) numerically matching the envelope, and a positive timestamp

Scenario: converts to non-EUR base

  • WHEN fetchRates({ base: "USD" }) is called against the same envelope
  • THEN result.base === "USD" and result.rates.EUR equals 1 / USD_rate

Scenario: HTTP error becomes CURRENCY_PROVIDER.HTTP_ERROR

  • WHEN the injected fetch returns a non-2xx Response
  • THEN fetchRates() rejects with a CurrencyProviderError whose code === "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 CurrencyProviderError whose code === "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, and symbols=USD,GBP, and the result is the upstream rates with timestamp in 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" and upstreamMessage === "invalid key"

Scenario: missing apiKey raises MISSING_CONFIG

  • WHEN new FixerProvider({ apiKey: "" }) is constructed
  • THEN it throws CurrencyProviderError with code === "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 rate EUR and 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" and status === 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 containing BTCUSDT, ETHUSDT, BTCEUR
  • THEN result contains BTC and ETH (USDT-suffixed) with 1 / price, and does not contain BTCEUR

Scenario: skips zero-price tickers

  • WHEN the ticker list contains a FAKUSDT entry with price === "0"
  • THEN the result does not contain a FAK key

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 containing bitcoin, ethereum, solana
  • THEN result contains BTC, ETH, SOL with 1 / price each

Scenario: getCoinGeckoId is case-insensitive

  • WHEN getCoinGeckoId("btc") and getCoinGeckoId("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.headers contains X-CMC_PRO_API_KEY and the result has BTC and ETH with 1 / 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 BTC with 1 / 67000 and does not contain FAKE

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 containing XXBTZUSD, XETHZUSD, SOLUSD
  • THEN result contains BTC, ETH, SOL with 1 / 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" and upstreamMessage contains "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, and fetchRates() returns both rates against the configured base

Scenario: addCurrency and updateRate mutate at runtime

  • WHEN addCurrency("SILVER", {...}) then updateRate("SILVER", 99) are called
  • THEN fetchRates() includes SILVER === 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 base EUR
  • 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 no UNKNOWN currency
  • 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 ApiKeyConfig and passes it to new FixerProvider(config)
  • THEN TypeScript accepts the call with apiKey: string and rejects a call missing apiKey

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.ts is 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.json exports map is enumerated
  • THEN it contains at least 11 entries (root . + the 10 listed subpaths), each pointing to the matching compiled .js and .d.ts

Verified by: tests/dep-graph.test.ts::"package.json declares subpath exports for every feature module"