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

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 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.