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.
|
||||
169
openspec/archive/2026-05-17-add-currency-providers/proposal.md
Normal file
169
openspec/archive/2026-05-17-add-currency-providers/proposal.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Add @suaveplan/currency-providers — concrete CurrencyProvider plugins
|
||||
|
||||
**Package**: `@suaveplan/currency-providers`
|
||||
**Status**: PROPOSED
|
||||
**Type**: migration (upstream `../suaveplan/packages/finance/currency-providers`)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/currency-providers/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`STATUS.md` lists `@suaveplan/currency-providers` as Pending. With
|
||||
`@suaveplan/currency` shipped on genesis (2026-05-17), the provider plugins are
|
||||
the next link in the commerce chain: they are the concrete `CurrencyProvider`
|
||||
implementations that real applications wire into `CurrencyConverter`. Every
|
||||
fiat conversion (ECB / Fixer / Open Exchange Rates), every crypto quote
|
||||
(CoinGecko / CoinMarketCap / Binance / Kraken / CryptoCompare), and every
|
||||
operator-defined virtual-currency (game gold, gems, etc.) depends on this
|
||||
package. Without it, `@suaveplan/currency` is an empty engine; with it, the
|
||||
commerce stack has rate data flowing end-to-end.
|
||||
|
||||
The migration preserves the upstream public surface verbatim — same provider
|
||||
class names, same config shapes (`ApiKeyConfig` / `NoKeyConfig`), same
|
||||
`fetchRates` semantics — and lifts the package to the genesis quality bar:
|
||||
every plain `throw new Error(...)` becomes a typed `CurrencyProviderError`
|
||||
carrying a stable `CURRENCY_PROVIDER.*` code; HTTP is performed through an
|
||||
injectable `FetchLike` (default `globalThis.fetch`) so tests are real fakes
|
||||
not mocks; every provider is universal-tier with zero browser/Node APIs;
|
||||
folder layout matches the §10 no-prefix rule (`providers/ecb/ecb.ts`, not
|
||||
`providers/ecb-provider/ecb-provider.ts`); every implementation file has a
|
||||
sibling `.md`; every public module has a subpath export.
|
||||
|
||||
The enterprise audit (`../suaveplan/enterprise/currency-providers.md`) lists
|
||||
nine "feature-rich enhancements" — rate-limited wrappers, historical-rate
|
||||
endpoints, WebSocket streaming, on-chain oracles, aggregator providers,
|
||||
health monitors, multi-base normalization, deterministic replay, SLO
|
||||
tracking. Those land in follow-up changes once their dependency packages
|
||||
(`@suaveplan/retry`, `@suaveplan/rate-limiter`, `@suaveplan/events`,
|
||||
`@suaveplan/cryptocurrency`'s chain registry) are wired through this surface;
|
||||
the migration baseline matches the upstream behaviour bit-for-bit, fills the
|
||||
three test-coverage gaps the audit flagged (Fixer / OXR / CoinMarketCap had
|
||||
no tests upstream), and unblocks every downstream commerce consumer.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `EcbProvider` — ECB daily XML feed, no API key, EUR-base with
|
||||
automatic conversion to any requested base.
|
||||
- ADDED: `FixerProvider` — Fixer.io API, requires `apiKey`, EUR-base on free
|
||||
tier.
|
||||
- ADDED: `OpenExchangeRatesProvider` — OXR API, requires `apiKey`, USD-base
|
||||
on free tier, supports `targets` filtering.
|
||||
- ADDED: `BinanceProvider` — Binance spot ticker, no API key, USDT-base by
|
||||
default, real-time prices.
|
||||
- ADDED: `CoinGeckoProvider` + `getCoinGeckoId` helper — CoinGecko
|
||||
`simple/price` endpoint, no API key, 16-symbol built-in registry.
|
||||
- ADDED: `CoinMarketCapProvider` — CMC `cryptocurrency/quotes/latest`,
|
||||
requires `apiKey`, supports configurable target symbols.
|
||||
- ADDED: `CryptoCompareProvider` — CryptoCompare `pricemulti`, no API key,
|
||||
15-symbol default list.
|
||||
- ADDED: `KrakenProvider` — Kraken `Ticker`, no API key, maps Kraken's
|
||||
non-standard pair names back to canonical symbols.
|
||||
- ADDED: `VirtualCurrencyProvider` — operator-defined game / virtual
|
||||
currencies with runtime `addCurrency` / `updateRate` / `removeCurrency` /
|
||||
`getRate` / `getCurrencyCodes`, anchored against a fiat base, registers
|
||||
every currency with `@suaveplan/money`'s currency registry.
|
||||
- ADDED: `ApiKeyConfig` / `NoKeyConfig` / `HttpFetchOptions` config types
|
||||
(named interfaces, no inline shapes per AGENTS.md §11).
|
||||
- ADDED: `FetchLike` injection on every provider — every `fetchRates`
|
||||
implementation accepts an optional `fetch` in its constructor config,
|
||||
defaulting to `globalThis.fetch`. Tests inject a custom `FetchLike` instead
|
||||
of mocking globals.
|
||||
- ADDED: `CURRENCY_PROVIDER_CODES` registry via
|
||||
`defineCodes("CURRENCY_PROVIDER", [...])` — codes `HTTP_ERROR`, `PARSE_ERROR`,
|
||||
`UPSTREAM_ERROR`, `UNSUPPORTED_BASE`, `MISSING_CONFIG`, `UNKNOWN_CURRENCY`.
|
||||
- ADDED: `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
|
||||
carrying optional `providerName` / `status` / `upstreamMessage` discriminants.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rate-limit-aware `RateLimitedProvider` wrapper — pending
|
||||
`@suaveplan/rate-limiter` (exists upstream, not yet migrated to genesis).
|
||||
- Retry + backoff wrapper — pending `@suaveplan/retry` audit for genesis
|
||||
parity (the upstream is migrated but its integration shape with these
|
||||
providers is a follow-up).
|
||||
- Historical-rate endpoints (`fetchHistoricalRates(date)`) — additive surface
|
||||
that requires a coordinated `CurrencyProvider` interface extension in
|
||||
`@suaveplan/currency`.
|
||||
- WebSocket streaming adapters (Binance / Kraken) — additive surface; spec
|
||||
lives in a separate change.
|
||||
- On-chain oracle adapter (Chainlink / Pyth) — pending
|
||||
`@suaveplan/cryptocurrency` chain registry.
|
||||
- `AggregatorProvider` (volume-weighted multi-source average) — depends on
|
||||
the historical-rate surface and rate-limit wrapper.
|
||||
- Provider health-monitor background job — depends on
|
||||
`@suaveplan/job-scheduler` integration.
|
||||
- OpenTelemetry per-request spans / counters — pending coordinated
|
||||
observability surface in `@suaveplan/currency`.
|
||||
- Stablecoin redirect routing — additive optimisation, no public-surface
|
||||
change.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/currency-providers.md`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| 9 providers (3 fiat + 5 crypto + 1 virtual) | L17-L31 | verbatim |
|
||||
| `ApiKeyConfig` / `NoKeyConfig` / `HttpFetchOptions` | L17-L19, L30 | verbatim |
|
||||
| `getCoinGeckoId` helper | L17, L25 | verbatim |
|
||||
| `VirtualCurrencyProvider` runtime mutators | L29, L70-L86 of upstream `virtual.ts` | verbatim |
|
||||
| Test coverage parity for Fixer / OXR / CoinMarketCap | L50-L51 | upstream had 6/9 covered; genesis ships 9/9 |
|
||||
| Typed error hierarchy (replace plain `Error`) | L52-L53 | enhanced: `CurrencyProviderError` + 6 codes |
|
||||
| Injectable `FetchLike` per provider | L40-L41, L66-L67 | enhanced: enables real-fake tests, removes `vi.stubGlobal` |
|
||||
| Universal-tier purity | L60-L69 | verbatim (genesis cross-cutting baseline) |
|
||||
| Subpath exports per provider | upstream `package.json` `exports` | verbatim |
|
||||
| Sibling `.md` per file | L61-L63 | verbatim (genesis cross-cutting) |
|
||||
| 100% Istanbul coverage | L61, L101 | verbatim (genesis cross-cutting) |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Folder-naming rule §10 (`providers/ecb/ecb.ts`) | Genesis-wide structural rule from `openspec/AGENTS.md §10`; upstream used flat `fiat/ecb.ts` / `crypto/binance.ts` which collides once a 10th provider arrives. |
|
||||
| `@suaveplan/testing/runner` import for tests | Genesis-wide CI gate from `openspec/AGENTS.md §7` / CLAUDE.md rule 11. |
|
||||
| Zero `export *` in feature barrels | Genesis-wide rule §6a / CLAUDE.md rule 10. |
|
||||
| `defineCodes` registry under `@suaveplan/error` | Genesis-wide foundation pattern; upstream threw plain `Error`. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta has a `**Verified by:**` line naming
|
||||
concrete `it(...)` titles in these files:
|
||||
|
||||
- `src/providers/ecb/ecb.test.ts` — ECB XML parsing, base conversion,
|
||||
filtering, error handling, custom `baseUrl`, `FetchLike` injection.
|
||||
- `src/providers/fixer/fixer.test.ts` — Fixer API call, success-payload
|
||||
handling, error-payload handling, params construction, custom base / targets.
|
||||
- `src/providers/open-exchange-rates/open-exchange-rates.test.ts` — OXR
|
||||
endpoint construction, `targets` filtering, timestamp conversion.
|
||||
- `src/providers/binance/binance.test.ts` — symbol-suffix matching,
|
||||
zero-price skip, target filtering, custom `baseUrl`.
|
||||
- `src/providers/coingecko/coingecko.test.ts` — `getCoinGeckoId`
|
||||
case-insensitivity, ID→symbol mapping, non-USD base.
|
||||
- `src/providers/coinmarketcap/coinmarketcap.test.ts` — API-key header,
|
||||
quotes parsing, target list construction.
|
||||
- `src/providers/cryptocompare/cryptocompare.test.ts` — invert-price,
|
||||
zero-price skip, default targets.
|
||||
- `src/providers/kraken/kraken.test.ts` — pair→symbol reverse mapping,
|
||||
Kraken error array, target filtering.
|
||||
- `src/providers/virtual/virtual.test.ts` — `addCurrency` / `updateRate` /
|
||||
`removeCurrency` / `getCurrencyCodes` / `getRate`, virtual-base conversion.
|
||||
- `src/error-codes/error-codes.test.ts` — every `CURRENCY_PROVIDER.*` code
|
||||
reachable; `SuaveplanError` contract preserved.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-providers` to enumerate.
|
||||
This is a brand-new package on genesis, so no current dependents exist:
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-providers`).
|
||||
|
||||
Downstream packages that will consume this once they migrate
|
||||
(`@suaveplan/payment`, `@suaveplan/booking`, `api-plugin-virtual-currency`,
|
||||
etc.) are out of scope here.
|
||||
|
||||
## Migration impact
|
||||
|
||||
New package — additive only. No existing consumers; nothing breaks.
|
||||
@@ -0,0 +1,432 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-providers (add-currency-providers)
|
||||
|
||||
**Package**: `@suaveplan/currency-providers`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL ship as a universal-tier package — no imports from `node:*`,
|
||||
`bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto`
|
||||
in any file under `src/`. The package SHALL run unchanged under Node ≥ 18,
|
||||
Bun, modern browsers, and Web Workers. `package.json` SHALL NOT declare a
|
||||
`"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL
|
||||
declare `"sideEffects": false`.
|
||||
|
||||
#### Scenario: no platform imports in src/
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency-providers/src/` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"is universal-tier (no node/bun imports)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable CURRENCY_PROVIDER_CODES error registry and CurrencyProviderError class
|
||||
|
||||
The package SHALL declare an error-code registry via
|
||||
`defineCodes("CURRENCY_PROVIDER", [...])` containing exactly the codes
|
||||
`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNSUPPORTED_BASE`,
|
||||
`MISSING_CONFIG`, `UNKNOWN_CURRENCY`. Every public error path SHALL throw a
|
||||
`CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
|
||||
carrying one of those codes. `CurrencyProviderError` SHALL expose optional
|
||||
`providerName`, `status`, and `upstreamMessage` discriminants for
|
||||
diagnostics. The package SHALL NOT throw raw `Error` instances from any
|
||||
public method.
|
||||
|
||||
#### Scenario: every code is reachable from a call site
|
||||
|
||||
- **WHEN** the test suite exercises an HTTP 5xx response, a malformed
|
||||
upstream payload, an upstream-flagged failure (Fixer `success:false`,
|
||||
Kraken `error[]` non-empty), an ECB base it does not quote, a
|
||||
config without an `apiKey` where one is required, and a virtual
|
||||
currency lookup for a code that has not been registered
|
||||
- **THEN** each path raises a `CurrencyProviderError` whose `code` matches
|
||||
the expected `CURRENCY_PROVIDER.*` constant
|
||||
|
||||
#### Scenario: SuaveplanError contract preserved
|
||||
|
||||
- **WHEN** a `CurrencyProviderError` is thrown
|
||||
- **THEN** it is `instanceof SuaveplanError`, its `code` is a
|
||||
`CurrencyProviderErrorCode`, and its optional `providerName` / `status`
|
||||
/ `upstreamMessage` discriminants are accessible without type narrowing
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every CURRENCY_PROVIDER code is reachable from a call site"` AND `src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Injectable FetchLike on every provider
|
||||
|
||||
Every provider SHALL accept an optional `fetch: FetchLike` (from
|
||||
`@suaveplan/types`) in its constructor config and SHALL use it for every
|
||||
HTTP call. When not supplied, the provider SHALL default to
|
||||
`globalThis.fetch`. Tests SHALL exercise providers by injecting a real
|
||||
`FetchLike` closure that returns a synthesised WHATWG `Response` — no
|
||||
package SHALL mock `globalThis.fetch` via `vi.stubGlobal` or `vi.mock`.
|
||||
|
||||
#### Scenario: provider uses injected fetch
|
||||
|
||||
- **WHEN** a provider is constructed with a custom `fetch` closure that
|
||||
records the URL it was called with
|
||||
- **THEN** `fetchRates(...)` calls the injected closure (not
|
||||
`globalThis.fetch`), and the recorded URL matches the provider's expected
|
||||
endpoint
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"uses injected fetch closure"` AND `src/providers/binance/binance.test.ts::"uses injected fetch closure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EcbProvider — ECB daily XML feed
|
||||
|
||||
The package SHALL export `EcbProvider implements CurrencyProvider` with
|
||||
`name === "ecb"`, `supportsCrypto === false`, `supportsFiat === true`. It
|
||||
SHALL fetch from
|
||||
`https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml` (overridable
|
||||
via `config.baseUrl`), parse the XML rate envelope, return EUR-based rates
|
||||
by default, and convert to any other base by dividing through the EUR rate
|
||||
when a `base` other than EUR is requested. An HTTP non-2xx response SHALL
|
||||
raise `CURRENCY_PROVIDER.HTTP_ERROR`; a request for a base ECB does not
|
||||
quote SHALL raise `CURRENCY_PROVIDER.UNSUPPORTED_BASE`. Calls with a
|
||||
`targets` array SHALL filter the returned rate map to only those codes.
|
||||
|
||||
#### Scenario: parses EUR-base XML and filters targets
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called
|
||||
and the injected `fetch` returns the canonical ECB envelope
|
||||
- **THEN** the result has `base === "EUR"`, exactly two rates (`USD`,
|
||||
`GBP`) numerically matching the envelope, and a positive `timestamp`
|
||||
|
||||
#### Scenario: converts to non-EUR base
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against the same envelope
|
||||
- **THEN** `result.base === "USD"` and `result.rates.EUR` equals `1 / USD_rate`
|
||||
|
||||
#### Scenario: HTTP error becomes CURRENCY_PROVIDER.HTTP_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns a non-2xx Response
|
||||
- **THEN** `fetchRates()` rejects with a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
#### Scenario: unsupported base raises UNSUPPORTED_BASE
|
||||
|
||||
- **WHEN** `fetchRates({ base: "XYZ" })` is called against the canonical
|
||||
envelope (XYZ not present)
|
||||
- **THEN** it rejects with a `CurrencyProviderError` whose
|
||||
`code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`
|
||||
|
||||
**Verified by:** `src/providers/ecb/ecb.test.ts::"parses EUR-base XML and filters targets"` AND `src/providers/ecb/ecb.test.ts::"converts to non-EUR base"` AND `src/providers/ecb/ecb.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/providers/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE for missing currency"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FixerProvider — Fixer.io with API key
|
||||
|
||||
The package SHALL export `FixerProvider implements CurrencyProvider` with
|
||||
`name === "fixer"`, `supportsCrypto === false`, `supportsFiat === true`. It
|
||||
SHALL require `ApiKeyConfig.apiKey` (constructor raises
|
||||
`CURRENCY_PROVIDER.MISSING_CONFIG` if absent), call
|
||||
`<baseUrl>/latest?access_key=<key>&base=<base>&symbols=<targets>` (symbols
|
||||
only when `targets` is provided), and return `{ base, rates, timestamp }`
|
||||
where `timestamp` is the upstream `timestamp * 1000`. An upstream
|
||||
`success === false` SHALL raise `CURRENCY_PROVIDER.UPSTREAM_ERROR` carrying
|
||||
`upstreamMessage` from the response's `error.info`.
|
||||
|
||||
#### Scenario: builds correct URL and returns rates
|
||||
|
||||
- **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called
|
||||
with a successful payload
|
||||
- **THEN** the recorded URL contains `access_key=`, `base=EUR`, and
|
||||
`symbols=USD,GBP`, and the result is the upstream `rates` with
|
||||
`timestamp` in ms
|
||||
|
||||
#### Scenario: upstream success:false becomes UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the upstream returns 200 OK with `{ success: false, error: { info: "invalid key" } }`
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
and `upstreamMessage === "invalid key"`
|
||||
|
||||
#### Scenario: missing apiKey raises MISSING_CONFIG
|
||||
|
||||
- **WHEN** `new FixerProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `CurrencyProviderError` with
|
||||
`code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
**Verified by:** `src/providers/fixer/fixer.test.ts::"builds URL and returns rates"` AND `src/providers/fixer/fixer.test.ts::"raises UPSTREAM_ERROR on success:false"` AND `src/providers/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenExchangeRatesProvider — OXR with API key
|
||||
|
||||
The package SHALL export `OpenExchangeRatesProvider implements CurrencyProvider`
|
||||
with `name === "open-exchange-rates"`, `supportsCrypto === false`,
|
||||
`supportsFiat === true`. It SHALL require `ApiKeyConfig.apiKey`, call
|
||||
`<baseUrl>/latest.json?app_id=<key>&base=<base>`, filter by `targets` when
|
||||
provided, and convert upstream `timestamp` (seconds) to ms.
|
||||
|
||||
#### Scenario: builds URL with API key and base
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["EUR"] })` is called
|
||||
- **THEN** the recorded URL contains `app_id=`, `base=USD`, and the result
|
||||
has exactly one rate `EUR` and the upstream timestamp converted to ms
|
||||
|
||||
#### Scenario: raises MISSING_CONFIG when apiKey absent
|
||||
|
||||
- **WHEN** `new OpenExchangeRatesProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
#### Scenario: HTTP error becomes HTTP_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns 401 Unauthorized
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
and `status === 401`
|
||||
|
||||
**Verified by:** `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"builds URL with API key and filters targets"` AND `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises MISSING_CONFIG when apiKey absent"` AND `src/providers/open-exchange-rates/open-exchange-rates.test.ts::"raises HTTP_ERROR on 401"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BinanceProvider — Binance spot ticker
|
||||
|
||||
The package SHALL export `BinanceProvider implements CurrencyProvider` with
|
||||
`name === "binance"`, `supportsCrypto === true`, `supportsFiat === false`,
|
||||
defaulting to USDT base. It SHALL call `<baseUrl>/ticker/price`, treat each
|
||||
returned `{symbol, price}` as a pair ending in the requested base, strip the
|
||||
suffix to derive the canonical symbol, skip zero-price tickers, and invert
|
||||
the price (`1/price`). `targets` SHALL filter the resulting map.
|
||||
|
||||
#### Scenario: parses tickers and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USDT" })` is called against a synthesised
|
||||
ticker list containing `BTCUSDT`, `ETHUSDT`, `BTCEUR`
|
||||
- **THEN** result contains `BTC` and `ETH` (USDT-suffixed) with
|
||||
`1 / price`, and does not contain `BTCEUR`
|
||||
|
||||
#### Scenario: skips zero-price tickers
|
||||
|
||||
- **WHEN** the ticker list contains a `FAKUSDT` entry with `price === "0"`
|
||||
- **THEN** the result does not contain a `FAK` key
|
||||
|
||||
#### Scenario: raises HTTP_ERROR on non-2xx
|
||||
|
||||
- **WHEN** the injected fetch returns 503
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
**Verified by:** `src/providers/binance/binance.test.ts::"parses tickers with USDT base and inverts prices"` AND `src/providers/binance/binance.test.ts::"skips zero-price tickers"` AND `src/providers/binance/binance.test.ts::"raises HTTP_ERROR on non-2xx response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CoinGeckoProvider — simple/price endpoint + getCoinGeckoId
|
||||
|
||||
The package SHALL export `CoinGeckoProvider implements CurrencyProvider`
|
||||
with `name === "coingecko"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`, defaulting to USD base. It SHALL call
|
||||
`<baseUrl>/simple/price?ids=<coin-ids>&vs_currencies=<lower-base>`, map the
|
||||
returned CoinGecko ids back to canonical symbols using its built-in
|
||||
registry, invert the price (`1/price`), filter by `targets`, and return
|
||||
`{ base, rates, timestamp }`. The package SHALL also export
|
||||
`getCoinGeckoId(symbol: string): string | undefined` — a case-insensitive
|
||||
lookup against the same built-in registry.
|
||||
|
||||
#### Scenario: maps coin ids back to symbols and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against a payload
|
||||
containing `bitcoin`, `ethereum`, `solana`
|
||||
- **THEN** result contains `BTC`, `ETH`, `SOL` with `1 / price` each
|
||||
|
||||
#### Scenario: getCoinGeckoId is case-insensitive
|
||||
|
||||
- **WHEN** `getCoinGeckoId("btc")` and `getCoinGeckoId("BTC")` are called
|
||||
- **THEN** both return `"bitcoin"`
|
||||
|
||||
#### Scenario: unknown symbol returns undefined
|
||||
|
||||
- **WHEN** `getCoinGeckoId("ZZZNOPE")` is called
|
||||
- **THEN** the result is `undefined`
|
||||
|
||||
**Verified by:** `src/providers/coingecko/coingecko.test.ts::"maps coin ids back to symbols and inverts prices"` AND `src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId is case-insensitive"` AND `src/providers/coingecko/coingecko.test.ts::"getCoinGeckoId returns undefined for unknown symbol"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CoinMarketCapProvider — quotes/latest with API key header
|
||||
|
||||
The package SHALL export `CoinMarketCapProvider implements CurrencyProvider`
|
||||
with `name === "coinmarketcap"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`. It SHALL require `ApiKeyConfig.apiKey`
|
||||
(`CURRENCY_PROVIDER.MISSING_CONFIG` if absent), call
|
||||
`<baseUrl>/cryptocurrency/quotes/latest?symbol=<symbols>&convert=<base>`
|
||||
with `X-CMC_PRO_API_KEY` and `Accept: application/json` request headers,
|
||||
invert the price (`1/price`), and return `{ base, rates, timestamp }`.
|
||||
`targets` SHALL replace the default symbol list when provided.
|
||||
|
||||
#### Scenario: sends API key header and parses quotes
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC", "ETH"] })` is called
|
||||
and the injected fetch records the request init
|
||||
- **THEN** the recorded `init.headers` contains `X-CMC_PRO_API_KEY` and the
|
||||
result has `BTC` and `ETH` with `1 / quote.USD.price`
|
||||
|
||||
#### Scenario: missing apiKey raises MISSING_CONFIG
|
||||
|
||||
- **WHEN** `new CoinMarketCapProvider({ apiKey: "" })` is constructed
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.MISSING_CONFIG"`
|
||||
|
||||
**Verified by:** `src/providers/coinmarketcap/coinmarketcap.test.ts::"sends API key header and parses quotes"` AND `src/providers/coinmarketcap/coinmarketcap.test.ts::"raises MISSING_CONFIG when apiKey absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CryptoCompareProvider — pricemulti
|
||||
|
||||
The package SHALL export `CryptoCompareProvider implements CurrencyProvider`
|
||||
with `name === "cryptocompare"`, `supportsCrypto === true`,
|
||||
`supportsFiat === true`, defaulting to USD base. It SHALL call
|
||||
`<baseUrl>/pricemulti?fsyms=<targets-or-defaults>&tsyms=<base>`, invert the
|
||||
price (`1/price`), and skip entries whose price is `<= 0`.
|
||||
|
||||
#### Scenario: inverts pricemulti payload and skips zero prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", targets: ["BTC", "FAKE"] })` is called
|
||||
against `{ BTC: { USD: 67000 }, FAKE: { USD: 0 } }`
|
||||
- **THEN** result contains `BTC` with `1 / 67000` and does not contain `FAKE`
|
||||
|
||||
#### Scenario: raises HTTP_ERROR on non-2xx
|
||||
|
||||
- **WHEN** the injected fetch returns 500
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`
|
||||
|
||||
**Verified by:** `src/providers/cryptocompare/cryptocompare.test.ts::"inverts pricemulti payload and skips zero prices"` AND `src/providers/cryptocompare/cryptocompare.test.ts::"raises HTTP_ERROR on non-2xx response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: KrakenProvider — Ticker with pair→symbol reverse mapping
|
||||
|
||||
The package SHALL export `KrakenProvider implements CurrencyProvider` with
|
||||
`name === "kraken"`, `supportsCrypto === true`, `supportsFiat === false`,
|
||||
defaulting to USD base. It SHALL call `<baseUrl>/Ticker?pair=<pair-list>`,
|
||||
maintain a built-in Kraken-pair-name → canonical-symbol map, invert the
|
||||
`c[0]` price (`1/price`), and skip zero-price entries. A non-empty
|
||||
`result.error[]` SHALL raise `CURRENCY_PROVIDER.UPSTREAM_ERROR` carrying
|
||||
the joined error message as `upstreamMessage`.
|
||||
|
||||
#### Scenario: reverse-maps Kraken pairs and inverts prices
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD" })` is called against a payload
|
||||
containing `XXBTZUSD`, `XETHZUSD`, `SOLUSD`
|
||||
- **THEN** result contains `BTC`, `ETH`, `SOL` with `1 / c[0]` each
|
||||
|
||||
#### Scenario: upstream error array raises UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the upstream returns `{ error: ["EGeneral:Too many requests"], result: {} }`
|
||||
- **THEN** the call rejects with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`
|
||||
and `upstreamMessage` contains `"Too many requests"`
|
||||
|
||||
**Verified by:** `src/providers/kraken/kraken.test.ts::"reverse-maps Kraken pairs and inverts prices"` AND `src/providers/kraken/kraken.test.ts::"raises UPSTREAM_ERROR on non-empty error array"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: VirtualCurrencyProvider — operator-defined currencies
|
||||
|
||||
The package SHALL export `VirtualCurrencyProvider implements CurrencyProvider`
|
||||
with `name === "virtual-currency"`, `supportsCrypto === false`,
|
||||
`supportsFiat === false`, `supportsVirtual === true`. Construction SHALL
|
||||
register every configured currency with `@suaveplan/money`'s
|
||||
`registerCurrency`. The provider SHALL expose `addCurrency(code, config)`,
|
||||
`updateRate(code, rate)`, `removeCurrency(code)`, `getRate(code)`,
|
||||
`getCurrencyCodes()`. `updateRate` for an unknown code SHALL raise
|
||||
`CURRENCY_PROVIDER.UNKNOWN_CURRENCY`. `fetchRates({ base })` SHALL return
|
||||
all configured rates when `base` matches the configured base, and SHALL
|
||||
convert via `1/rate` (rate-against-base) when `base` is itself a virtual
|
||||
currency. A `base` that is neither the configured base nor a registered
|
||||
virtual code SHALL raise `CURRENCY_PROVIDER.UNSUPPORTED_BASE`.
|
||||
|
||||
#### Scenario: registers currencies and exposes rates
|
||||
|
||||
- **WHEN** the provider is constructed with `{ currencies: { GOLD: {...}, GEMS: {...} } }`
|
||||
- **THEN** `getCurrencyCodes()` returns `["GOLD", "GEMS"]`, `getRate("GOLD")`
|
||||
returns the configured rate, and `fetchRates()` returns both rates against
|
||||
the configured base
|
||||
|
||||
#### Scenario: addCurrency and updateRate mutate at runtime
|
||||
|
||||
- **WHEN** `addCurrency("SILVER", {...})` then `updateRate("SILVER", 99)` are
|
||||
called
|
||||
- **THEN** `fetchRates()` includes `SILVER === 99`
|
||||
|
||||
#### Scenario: updateRate for unknown code raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `updateRate("NOPE", 1)` is called on a fresh provider
|
||||
- **THEN** it throws `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
|
||||
|
||||
#### Scenario: virtual-base conversion
|
||||
|
||||
- **WHEN** `fetchRates({ base: "GOLD" })` is called against
|
||||
`{ GOLD: {rate: 100}, GEMS: {rate: 500} }` with configured base `EUR`
|
||||
- **THEN** `result.base === "GOLD"`, `result.rates.EUR === 1/100`,
|
||||
`result.rates.GEMS === 500/100`
|
||||
|
||||
#### Scenario: unsupported base raises UNSUPPORTED_BASE
|
||||
|
||||
- **WHEN** `fetchRates({ base: "UNKNOWN" })` is called against a provider with
|
||||
no `UNKNOWN` currency
|
||||
- **THEN** it rejects with `code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`
|
||||
|
||||
**Verified by:** `src/providers/virtual/virtual.test.ts::"registers currencies and exposes rates"` AND `src/providers/virtual/virtual.test.ts::"addCurrency and updateRate mutate at runtime"` AND `src/providers/virtual/virtual.test.ts::"updateRate for unknown code raises UNKNOWN_CURRENCY"` AND `src/providers/virtual/virtual.test.ts::"converts when base is a virtual currency"` AND `src/providers/virtual/virtual.test.ts::"raises UNSUPPORTED_BASE for unknown base"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configuration types are named interfaces
|
||||
|
||||
The package SHALL export `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`
|
||||
as named TypeScript interfaces (no inline object literals in provider
|
||||
constructor signatures). Every provider that requires an API key SHALL
|
||||
type its config as `ApiKeyConfig`; every provider without one SHALL type
|
||||
its config as `NoKeyConfig`. Every provider config SHALL extend with an
|
||||
optional `fetch?: FetchLike`.
|
||||
|
||||
#### Scenario: configs are usable from external code
|
||||
|
||||
- **WHEN** a consumer imports `ApiKeyConfig` and passes it to
|
||||
`new FixerProvider(config)`
|
||||
- **THEN** TypeScript accepts the call with `apiKey: string` and rejects a
|
||||
call missing `apiKey`
|
||||
|
||||
**Verified by:** `src/providers/fixer/fixer.test.ts::"accepts ApiKeyConfig with apiKey"` AND `src/providers/ecb/ecb.test.ts::"accepts NoKeyConfig with optional baseUrl"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Explicit named barrel exports
|
||||
|
||||
The package SHALL re-export every public symbol from `src/index.ts` using
|
||||
explicit named re-exports (`export { Name } from "..."`) — no `export *`
|
||||
anywhere except (a) within `*.select.ts`, `*.native/index.ts`,
|
||||
`*.noble/index.ts` impl-variant forwarders (none exist here) or (b)
|
||||
namespace re-exports (`export * as <ns> from`). Every feature module
|
||||
(`providers/<name>/index.ts`) SHALL likewise use explicit named re-exports.
|
||||
|
||||
#### Scenario: src/index.ts has no `export *`
|
||||
|
||||
- **WHEN** `grep -nE "^\s*export\s+(type\s+)?\*\s+from" src/index.ts` is run
|
||||
- **THEN** the command produces zero matches
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"src/index.ts has no export * statements"` AND `tests/dep-graph.test.ts::"feature barrels have no export * statements"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per public module
|
||||
|
||||
`package.json` SHALL declare a subpath export for every public module so
|
||||
consumers can tree-shake. Required subpaths: `./error-codes/error-codes`,
|
||||
`./types/configs`, and one per provider
|
||||
(`./providers/{ecb,fixer,open-exchange-rates,binance,coingecko,coinmarketcap,cryptocompare,kraken,virtual}/<name>`).
|
||||
|
||||
#### Scenario: every feature module has a subpath export
|
||||
|
||||
- **WHEN** `package.json` `exports` map is enumerated
|
||||
- **THEN** it contains at least 11 entries (root `.` + the 10 listed
|
||||
subpaths), each pointing to the matching compiled `.js` and `.d.ts`
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"package.json declares subpath exports for every feature module"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
68
openspec/archive/2026-05-17-add-currency-providers/tasks.md
Normal file
68
openspec/archive/2026-05-17-add-currency-providers/tasks.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Tasks — add @suaveplan/currency-providers
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/currency-providers.md` end-to-end
|
||||
- [x] 1.2 `openspec/specs/currency-providers/spec.md` does not exist — confirmed
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites line range or is in "no source line" table
|
||||
- [x] 1.6 Draft `specs/currency-providers/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/finance/currency-providers/` (`package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `tests/setup/polyfills.ts`, `README.md`)
|
||||
- [x] 2.2 `src/types/configs.ts` — `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions` (named interfaces, no inline shapes)
|
||||
- [x] 2.3 `src/error-codes/error-codes.ts` — `CURRENCY_PROVIDER_CODES` registry + `CurrencyProviderError` discriminated class
|
||||
- [x] 2.4 `src/providers/ecb/ecb.ts` — ECB XML provider with injectable `FetchLike`
|
||||
- [x] 2.5 `src/providers/fixer/fixer.ts` — Fixer.io provider with API key
|
||||
- [x] 2.6 `src/providers/open-exchange-rates/open-exchange-rates.ts`
|
||||
- [x] 2.7 `src/providers/binance/binance.ts`
|
||||
- [x] 2.8 `src/providers/coingecko/coingecko.ts` + `getCoinGeckoId`
|
||||
- [x] 2.9 `src/providers/coinmarketcap/coinmarketcap.ts`
|
||||
- [x] 2.10 `src/providers/cryptocompare/cryptocompare.ts`
|
||||
- [x] 2.11 `src/providers/kraken/kraken.ts`
|
||||
- [x] 2.12 `src/providers/virtual/virtual.ts` — virtual-currency provider with runtime mutators
|
||||
- [x] 2.13 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 2.14 Subpath exports declared in `package.json` for every public module
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Per-provider `<name>.test.ts` covering every scenario, with real `FetchLike` injection (no `vi.mock` / `vi.stubGlobal`)
|
||||
- [x] 3.2 `src/error-codes/error-codes.test.ts` — every `CURRENCY_PROVIDER.*` code reachable; SuaveplanError contract preserved
|
||||
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 3.4 All test files import runner primitives from `@suaveplan/testing/runner`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/currency-providers` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/currency-providers` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/currency-providers` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/currency-providers` — green
|
||||
|
||||
## 6. Pre-archive checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 6.1 Generics threaded — every public type uses named interfaces
|
||||
- [x] 6.2 Zod schemas N/A — package uses no Zod at boundary (configs are plain interfaces)
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
|
||||
- [x] 6.4 No strict-mode behaviors to test on/off — providers have no strict-mode setting
|
||||
- [x] 6.5 OTel N/A — deferred per `## Out of scope`
|
||||
- [x] 6.6 No lifecycle dedup claimed (deferred coalescing — see `## Out of scope`)
|
||||
- [x] 6.7 No retroactive tick
|
||||
- [x] 6.8 No `export *` in any feature-module barrel
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/currency-providers/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-currency-providers/` → `openspec/archive/2026-05-17-add-currency-providers/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`
|
||||
Reference in New Issue
Block a user