Initial Commit
This commit is contained in:
112
openspec/archive/2026-05-17-add-currency-providers/design.md
Normal file
112
openspec/archive/2026-05-17-add-currency-providers/design.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user