184 lines
8.9 KiB
Markdown
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() });
|
|
```
|