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

113 lines
4.9 KiB
Markdown

# 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/<name>/<name>.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<RateFetchResult>;
}
```
`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<CurrencyProviderErrorCode>`:
| 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.