# Design — @suaveplan/currency-providers Non-normative design notes. The spec delta in `./specs/currency-providers/spec.md` is the contract; this file explains *why* the contract is shaped this way. ## Folder layout ```text packages/finance/currency-providers/ src/ types/ configs.ts # ApiKeyConfig / NoKeyConfig / HttpFetchOptions configs.md error-codes/ error-codes.ts # CURRENCY_PROVIDER_CODES + CurrencyProviderError error-codes.test.ts error-codes.md providers/ ecb/{ecb.ts, ecb.test.ts, ecb.md, index.ts} fixer/{fixer.ts, fixer.test.ts, fixer.md, index.ts} open-exchange-rates/{open-exchange-rates.ts, .test.ts, .md, index.ts} binance/{binance.ts, binance.test.ts, binance.md, index.ts} coingecko/{coingecko.ts, coingecko.test.ts, coingecko.md, index.ts} coinmarketcap/{coinmarketcap.ts, .test.ts, .md, index.ts} cryptocompare/{cryptocompare.ts, .test.ts, .md, index.ts} kraken/{kraken.ts, kraken.test.ts, kraken.md, index.ts} virtual/{virtual.ts, virtual.test.ts, virtual.md, index.ts} index.ts # explicit named re-exports (no `export *`) ``` The `providers//.ts` shape satisfies §10 (no `-provider` suffix inside `providers/`) and the §11 path-naming rule (no parent-folder repetition). ## Public surface Each provider class implements `CurrencyProvider` from `@suaveplan/currency`: ```ts class EcbProvider implements CurrencyProvider { readonly name = "ecb"; readonly supportsCrypto = false; readonly supportsFiat = true; constructor(config?: NoKeyConfig & { fetch?: FetchLike }); fetchRates(options?: RateFetchOptions): Promise; } ``` `apiKey`-keyed providers (`Fixer`, `OXR`, `CoinMarketCap`) require `ApiKeyConfig`; the rest accept `NoKeyConfig` (both optional `baseUrl`). Every provider takes an optional `fetch: FetchLike` — defaults to `globalThis.fetch`. This is the key genesis change from upstream: tests inject a real fake `FetchLike` instead of `vi.stubGlobal('fetch', ...)`, so the test suite is hermetic and CLAUDE.md rule 2 ("NEVER use mocks") is honoured. `VirtualCurrencyProvider` is the only provider with mutating methods (`addCurrency`, `updateRate`, `removeCurrency`, `getRate`, `getCurrencyCodes`) because virtual currencies are operator-controlled, not fetched. Construction registers every currency with `@suaveplan/money`'s `registerCurrency` so downstream `Money` arithmetic resolves them. ## Error model Every HTTP / parse / config failure raises `CurrencyProviderError extends SuaveplanError`: | Code | When | |---|---| | `CURRENCY_PROVIDER.HTTP_ERROR` | HTTP response non-2xx | | `CURRENCY_PROVIDER.PARSE_ERROR` | Upstream payload missing required field | | `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Upstream returned a structured error (Fixer `success:false`, Kraken `error: []` non-empty) | | `CURRENCY_PROVIDER.UNSUPPORTED_BASE` | ECB asked for a base it doesn't quote | | `CURRENCY_PROVIDER.MISSING_CONFIG` | `apiKey` is required but missing | | `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | `VirtualCurrencyProvider` asked to update/get a code it doesn't have | Discriminants (`providerName`, `status`, `upstreamMessage`) are stored on the error so the fallback chain in `CurrencyConverter` can decide skip-vs-fail based on the typed information. ## Test strategy - Real `FetchLike` substitution: each test injects a small closure that returns a synthesised `Response` (built with the WHATWG `Response` constructor). Zero `vi.mock`, zero `vi.stubGlobal`. - Every test carries `{ timeout: 5000 }` — no real network. WHATWG `Response` is available in Node 18+, Bun, and browsers, so no polyfills needed. - Coverage gate: 100/100/100/100 (Istanbul). Vitest config mirrors the shipped `@suaveplan/currency` config. - All tests import runner primitives from `@suaveplan/testing/runner`. ## Universal-tier purity No imports from `node:*`, `bun:*`, `fs`, `path`, `stream`, `net`, `http`, or `crypto`. The package uses only `globalThis.fetch`, `URLSearchParams`, `Response`, `Number.parseFloat`, `Date.now`, `Map`, `Object.entries`, and regex. All available in every supported runtime (Node 18+, Bun, modern browsers, Web Workers). `package.json` declares `"sideEffects": false` and omits the `browser` field and any `engines.node` / `engines.bun` pin. ## Deferred enhancements See `proposal.md` `## Out of scope` for the full list. Each requires a dependency that isn't yet wired through the upstream `CurrencyProvider` interface — landing them in this change would either need (a) a coordinated breaking change to `@suaveplan/currency` (rate-limit metadata, historical endpoints), (b) a new dep that isn't migrated to genesis yet (rate-limiter, cryptocurrency oracle), or (c) a new public surface (streaming adapters, aggregator) that warrants its own spec delta. None are blockers for any current downstream consumer.