# 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` 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 `/latest?access_key=&base=&symbols=` (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 `/latest.json?app_id=&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 `/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 `/simple/price?ids=&vs_currencies=`, 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 `/cryptocurrency/quotes/latest?symbol=&convert=` 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 `/pricemulti?fsyms=&tsyms=`, 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 `/Ticker?pair=`, 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 from`). Every feature module (`providers//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}/`). #### 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"`