Files
2026-05-27 23:29:53 +01:00

184 lines
8.9 KiB
Markdown

# Add @suaveplan/currency-provider-ecb sibling provider package
**Package**: `@suaveplan/currency-provider-ecb`
**Status**: PROPOSED
**Type**: new-package
- **Design**: ./design.md
- **Spec delta**: ./specs/currency-provider-ecb/spec.md
## Why
The 2026-05-17 `slim-currency-providers` change extracted the nine concrete
provider classes from `@suaveplan/currency-providers` and committed to landing
each as its own `@suaveplan/currency-provider-<name>` sibling package. This
change ships the first such sibling: the European Central Bank (ECB) provider.
ECB is the lowest-friction fiat provider in the catalogue — it is free, requires
no API key, publishes a stable daily XML feed, and covers ~30 major fiat
currencies. Shipping it first lets downstream consumers (commerce stack, price
display, conversion helpers) wire a working `CurrencyConverter` without
provisioning credentials, and proves the sibling shape end-to-end against the
slimmed core.
## What changes
### ADDED
- New package `@suaveplan/currency-provider-ecb` at
`packages/finance/currency-provider-ecb/`.
- `EcbProvider` class implementing `CurrencyProvider` from `@suaveplan/currency`.
- Constructor: `new EcbProvider(config?: NoKeyConfig)` — optional `baseUrl`
override and optional injectable `fetch`.
- Static identity: `name = "ecb"`, `supportsFiat = true`,
`supportsCrypto = false`.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` — fetches
the ECB daily XML feed through `httpRequest()` from the slim core, parses
the EUR-based rate table, and either returns the raw EUR view or
mathematically converts to a caller-requested non-EUR base. Honours
`options.targets` filtering and `options.signal` cancellation.
- Single subpath export `./ecb/ecb` (plus the root `.` barrel) exposing the
`EcbProvider` class.
- 100/100/100/100 Istanbul coverage gate.
### KEPT (consumed unchanged from `@suaveplan/currency-providers`)
- `httpRequest()` for every HTTP call (no hand-rolled fetch+error block).
- `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES` registry for every error
path (`HTTP_ERROR` via `httpRequest`, plus `PARSE_ERROR`,
`UPSTREAM_ERROR`, `UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY` raised directly).
- `NoKeyConfig` shared config type (ECB takes no API key).
- `FetchLike` injection seam, imported from `@suaveplan/types`.
### NOT INCLUDED (out of scope, deferred to follow-up changes)
- Auto-registration with any `CurrencyProviderRegistry`. The package exposes
the class only; consumers wire `registry.register(new EcbProvider())`.
- Rate-limit / retry / OTel / historical-rate / WebSocket / aggregator
surfaces — same deferral as the slim-core archive's `## Out of scope`.
## Out of scope
- The eight other sibling provider packages (`fixer`, `open-exchange-rates`,
`binance`, `coingecko`, `coinmarketcap`, `cryptocompare`, `kraken`,
`virtual`) — each lands under its own OpenSpec change per AGENTS.md §0c
"one change per package".
- Modifying the `CurrencyProvider` contract in `@suaveplan/currency` — the
contract is consumed as-is.
- Caching, request coalescing, observability — same wave as the slim-core
deferrals; will land as composable wrappers consuming any sibling.
## Source mapping
This package is derived from the upstream `@suaveplan/currency-providers`
bundle (`../suaveplan/packages/finance/currency-providers/`). The slim-core
2026-05-17 archive committed the ECB module to ship as its own sibling.
Enterprise audit doc:
`../suaveplan/enterprise/currency-providers.md` L8-L37 (current-state) /
L38-L80 (gaps to close) — the audit's `EcbProvider` row is the source of
truth for the contract.
| Migrated module | Upstream source |
|---|---|
| `EcbProvider` class | `../suaveplan/packages/finance/currency-providers/src/fiat/ecb.ts` L1-L92 |
| `EcbProvider` tests (pattern, not 1:1) | `../suaveplan/packages/finance/currency-providers/src/fiat/ecb.test.ts` L1-L123 |
| `EcbProvider` docs (pattern) | `../suaveplan/packages/finance/currency-providers/src/fiat/ecb.md` L1-L55 |
Slim-core archive precedent:
`openspec/archive/2026-05-17-slim-currency-providers/proposal.md` L84-L99
(source-mapping table committing each provider to a sibling package);
L128 ("`@suaveplan/currency-provider-ecb` — [x] consumes registry / factory /
httpRequest / configs / error").
### Differences from upstream
Every difference is a hard upgrade required by the slim-core contract:
- **Typed errors only.** Upstream `throw new Error("ECB API error: ...")` →
`CurrencyProviderError` with stable code (`HTTP_ERROR` from `httpRequest`,
`UNSUPPORTED_BASE` / `UNKNOWN_CURRENCY` / `PARSE_ERROR` raised directly).
- **Shared `httpRequest` helper.** Upstream calls `globalThis.fetch` directly
with a hand-rolled `!response.ok` block; this sibling uses the slim core's
`httpRequest()` so abort-signal composition, timeout derivation, and the
typed HTTP error path are uniform across every sibling.
- **`FetchLike` injection.** Upstream is hard-bound to `globalThis.fetch`;
this sibling accepts `NoKeyConfig.fetch` so tests use a recording closure
(no `vi.mock` / `vi.stubGlobal`) and production passes nothing.
- **AbortSignal propagation.** `RateFetchOptions.signal` is forwarded through
`httpRequest`; upstream ignores it.
- **Empty XML protection.** Upstream silently returns an empty rate map if the
XML has no matches; this sibling raises `PARSE_ERROR` so consumers do not
cache an empty result.
- **Runner-agnostic tests.** Tests import from `@suaveplan/testing/runner`
per AGENTS.md §7; upstream imports directly from `vitest`.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `EcbProvider` class with `name` / `supportsFiat` / `supportsCrypto` | ✅ | ✅ | identity preserved verbatim |
| `fetchRates({ base, targets })` returning `RateFetchResult` | ✅ | ✅ | options forwarded unchanged |
| EUR base default | ✅ | ✅ | matches `options?.base ?? "EUR"` upstream |
| Non-EUR base via mathematical conversion | ✅ | ✅ | identical algorithm, behaviour bit-for-bit |
| `targets` filtering | ✅ | ✅ | identical helper |
| Custom `baseUrl` override | ✅ | ✅ | via `NoKeyConfig.baseUrl` |
| HTTP error → throw | ✅ | ⬆️ | now typed `CurrencyProviderError(HTTP_ERROR)` instead of plain `Error` |
| Missing base in feed → throw | ✅ | ⬆️ | now typed `CurrencyProviderError(UNSUPPORTED_BASE)` |
| XML parse via regex | ✅ | ✅ | identical regex; same zero-dep approach |
| `AbortSignal` propagation | 🚫 | ⬆️ | added — forwarded through `httpRequest` |
| Injectable `fetch` for tests | 🚫 | ⬆️ | added — `NoKeyConfig.fetch`, no `vi.mock` |
| Empty/unparseable XML guard | 🚫 | ⬆️ | added — raises `PARSE_ERROR` |
| `UNKNOWN_CURRENCY` for empty target intersection | 🚫 | ⬆️ | added — raised when every requested target is absent |
| Auto-registry side effect on import | 🚫 | 🚫 | Scoped out: package exposes class only; consumer calls `registry.register(new EcbProvider())`. Same shape committed by slim-core archive (`design.md` "no module-level side effects"). |
| Retry / rate-limit / OTel | 🚫 | 🚫 | Scoped out: same deferral as slim-core archive; will land as composable wrappers consuming any sibling. |
| Historical rate endpoint | 🚫 | 🚫 | Scoped out: ECB feed is daily-latest only; historical archive lives at a different URL and is a separate feature (`fetchHistoricalRates`) tracked against `enterprise/currency-providers.md` §4. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-ecb` to enumerate.
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-provider-ecb`).
The package is brand new; the commerce stack downstream is still pending
migration and will pick this up as a `workspace:^` dependency in its own
change.
### Foundation-package check
This is **not** a foundation package. The slim core `@suaveplan/currency-providers`
is consumed unchanged; `@suaveplan/currency` is consumed unchanged. No
behaviour-change propagation per §13a is required.
## Migration
N/A — this is a new package. The "before" path (importing `EcbProvider` from
`@suaveplan/currency-providers`) was removed by the 2026-05-17 slim-core
change; this package restores the import surface at the sibling location
committed by that change.
### After
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
});
```
Direct instantiation without the registry works identically:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({ provider: new EcbProvider() });
```