# 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-` 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` — 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() }); ```