4.9 KiB
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
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:
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
FetchLikesubstitution: each test injects a small closure that returns a synthesisedResponse(built with the WHATWGResponseconstructor). Zerovi.mock, zerovi.stubGlobal. - Every test carries
{ timeout: 5000 }— no real network. WHATWGResponseis available in Node 18+, Bun, and browsers, so no polyfills needed. - Coverage gate: 100/100/100/100 (Istanbul). Vitest config mirrors the
shipped
@suaveplan/currencyconfig. - 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.