# Capability Spec Delta: currency-provider-ecb (add-currency-provider-ecb) **Package**: `@suaveplan/currency-provider-ecb` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: EcbProvider implements CurrencyProvider with stable identity The package SHALL export `EcbProvider`, a class implementing `CurrencyProvider` from `@suaveplan/currency` with `name = "ecb"`, `supportsFiat = true`, and `supportsCrypto = false`. The constructor SHALL accept an optional `NoKeyConfig` (from `@suaveplan/currency-providers`) carrying `baseUrl` and `fetch` overrides; no API key is required. #### Scenario: identity flags - **WHEN** a caller constructs `new EcbProvider()` with no config - **THEN** `provider.name === "ecb"` AND `provider.supportsFiat === true` AND `provider.supportsCrypto === false` #### Scenario: custom baseUrl override - **WHEN** a caller constructs `new EcbProvider({ baseUrl: "https://proxy/ecb" })` and calls `fetchRates()` with an injected `fetch` - **THEN** the injected fetch is invoked with `"https://proxy/ecb"` **Verified by:** `src/ecb/ecb.test.ts::"exposes the documented identity flags"` AND `src/ecb/ecb.test.ts::"calls the custom baseUrl when provided"` --- ### Requirement: fetchRates returns EUR-based rates by default The package SHALL fetch the ECB daily XML feed through `httpRequest()` from `@suaveplan/currency-providers`, parse the EUR-quoted currency table via the regex `/currency='([A-Z]+)'\s+rate='([\d.]+)'/g`, and return a `RateFetchResult` whose `base` is `"EUR"` when the caller passes no `base` option or explicitly passes `"EUR"`. The returned `rates` map SHALL contain every currency present in the feed, keyed by ISO-4217 code with numeric exchange rates parsed via `Number.parseFloat`. The `timestamp` SHALL be the wall-clock millisecond value at fetch completion. #### Scenario: default base resolves to EUR - **WHEN** `fetchRates()` is called with no options against a feed containing USD=1.0870, GBP=0.8358, JPY=162.48, CHF=0.9443 - **THEN** the result has `base === "EUR"` AND `rates.USD === 1.0870` AND `rates.GBP === 0.8358` AND `rates.JPY === 162.48` AND `rates.CHF === 0.9443` AND `timestamp` is a finite positive number #### Scenario: explicit EUR base - **WHEN** `fetchRates({ base: "EUR" })` is called against the same feed - **THEN** the result equals the default-base result **Verified by:** `src/ecb/ecb.test.ts::"returns EUR-based rates when no base is specified"` AND `src/ecb/ecb.test.ts::"returns EUR-based rates when EUR is explicitly requested"` --- ### Requirement: fetchRates converts to a non-EUR base mathematically When the caller requests a non-EUR `base`, the package SHALL look up that base in the EUR-quoted rate map and synthesise the target view by computing `result.EUR = 1 / baseRate` and `result. = / baseRate` for every other currency. The requested base SHALL be absent from the returned `rates` map (a currency does not quote itself). #### Scenario: USD base derived from EUR feed - **WHEN** `fetchRates({ base: "USD" })` is called against a feed where USD=1.0870 and GBP=0.8358 (EUR-quoted) - **THEN** the result has `base === "USD"` AND `rates.EUR` is within 1e-4 of `1 / 1.0870` AND `rates.GBP` is within 1e-4 of `0.8358 / 1.0870` AND `rates.USD === undefined` **Verified by:** `src/ecb/ecb.test.ts::"converts to a non-EUR base by inverting through the EUR feed"` --- ### Requirement: targets filter restricts the returned rate map When `options.targets` is provided, the package SHALL return only the currencies present in `targets` AND in the resolved rate map. Currencies listed in `targets` that are absent from the feed SHALL be silently dropped UNLESS every requested target is absent, in which case the package SHALL raise `CurrencyProviderError(UNKNOWN_CURRENCY)`. #### Scenario: targets intersect the feed - **WHEN** `fetchRates({ base: "EUR", targets: ["USD", "GBP"] })` is called against a feed containing USD, GBP, JPY, CHF - **THEN** the result `rates` contains exactly two keys, `USD` and `GBP`, AND `rates.JPY === undefined` #### Scenario: all requested targets absent - **WHEN** `fetchRates({ base: "EUR", targets: ["ZZZ", "QQQ"] })` is called against a feed containing only USD, GBP, JPY, CHF - **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` AND whose `providerName === "ecb"` **Verified by:** `src/ecb/ecb.test.ts::"restricts the returned rates to the requested targets"` AND `src/ecb/ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"` --- ### Requirement: HTTP failures surface as typed CurrencyProviderError(HTTP_ERROR) The package SHALL route every HTTP call through `httpRequest()` from `@suaveplan/currency-providers`. When the upstream returns a non-2xx response, the call SHALL reject with `CurrencyProviderError` whose `code` is `CURRENCY_PROVIDER.HTTP_ERROR`, whose `providerName === "ecb"`, and whose `status` matches the HTTP status code. The package SHALL NOT hand-roll a `fetch()` call site or a `!response.ok` block. #### Scenario: 5xx response - **WHEN** the injected `fetch` resolves with `new Response("", { status: 503, statusText: "Down" })` - **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.HTTP_ERROR` AND `status === 503` AND `providerName === "ecb"` **Verified by:** `src/ecb/ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"` --- ### Requirement: PARSE_ERROR raised for an unparseable feed body When the upstream returns 2xx but the body contains zero matches for the currency-rate regex, the package SHALL raise `CurrencyProviderError(PARSE_ERROR)`. The package SHALL NOT cache or return an empty rate map silently. #### Scenario: empty body - **WHEN** the injected `fetch` resolves with `new Response("", { status: 200 })` - **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.PARSE_ERROR` AND `providerName === "ecb"` **Verified by:** `src/ecb/ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"` --- ### Requirement: UNSUPPORTED_BASE raised when the requested base is missing from the feed When the caller requests a non-EUR `base` that is not present in the EUR-quoted feed (e.g. `"XYZ"`), the package SHALL raise `CurrencyProviderError(UNSUPPORTED_BASE)`. #### Scenario: unknown non-EUR base - **WHEN** `fetchRates({ base: "XYZ" })` is called against a feed that does not contain `XYZ` - **THEN** the call rejects with `CurrencyProviderError` whose `code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` AND `providerName === "ecb"` **Verified by:** `src/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"` --- ### Requirement: AbortSignal from RateFetchOptions is forwarded to httpRequest When `options.signal` is set, the package SHALL forward that signal to `httpRequest({ signal })`. The slim-core helper composes it with any timeout signal via `AbortSignal.any`; aborting the caller signal SHALL abort the in-flight upstream fetch. #### Scenario: caller aborts the call - **WHEN** the injected `fetch` returns a Promise that rejects on `signal.abort()` and the caller calls `controller.abort(new Error("cancelled"))` after issuing `fetchRates({ signal: controller.signal })` - **THEN** the call rejects with an Error whose message contains `"cancelled"` **Verified by:** `src/ecb/ecb.test.ts::"forwards the caller AbortSignal to the upstream fetch"` --- ### Requirement: Injectable FetchLike for deterministic tests and proxying The constructor SHALL accept `NoKeyConfig.fetch: FetchLike` (imported from `@suaveplan/types`) and use it for every HTTP call. When `fetch` is not provided, the package SHALL fall back to `globalThis.fetch`. The package SHALL NOT capture or close over `globalThis.fetch` at module-load time — the fallback resolves at call time so test setups that replace `globalThis.fetch` after construction still work. #### Scenario: explicit fetch wins over globalThis.fetch - **WHEN** `new EcbProvider({ fetch: recordedFetch })` is instantiated and `fetchRates()` is called - **THEN** `recordedFetch` is invoked exactly once with the resolved URL AND `globalThis.fetch` is never invoked **Verified by:** `src/ecb/ecb.test.ts::"uses the injected fetch when provided"` --- ### Requirement: Universal-tier purity and zero side effects The package SHALL be importable in browsers, Web Workers, Node.js, and Bun without any environment-specific shim. The package SHALL declare `"sideEffects": false` in `package.json` and SHALL NOT register itself with any registry at module-load time. The package SHALL ship explicit named re-exports in every barrel (no `export *`). #### Scenario: package.json declares sideEffects false - **WHEN** the package.json is read at build time - **THEN** `pkg.sideEffects === false` #### Scenario: barrel uses explicit named exports - **WHEN** the contents of `src/ecb/index.ts` and `src/index.ts` are read - **THEN** neither file contains the substring `export *` (other than `export * as` namespace forms, of which there are none in this package) **Verified by:** `src/ecb/ecb.test.ts::"exposes the documented identity flags"` AND `tests/package-shape.test.ts::"declares sideEffects false and explicit-named barrels"`