# Design — @suaveplan/currency-provider-ecb ## File layout ```text packages/finance/currency-provider-ecb/ package.json README.md tsconfig.json tsconfig.lib.json vite.config.ts vitest.config.ts tests/setup/polyfills.ts src/ index.ts # explicit named barrel — exports EcbProvider ecb/ index.ts # explicit named barrel ecb.ts # EcbProvider class — ~200 lines ecb.test.ts # vitest tests, runner-neutral imports ecb.md # sibling docs ``` ## Module contract `EcbProvider` consumes the slim core directly: ```ts import { httpRequest, CurrencyProviderError, CURRENCY_PROVIDER_CODES } from "@suaveplan/currency-providers"; import type { NoKeyConfig } from "@suaveplan/currency-providers"; import type { CurrencyProvider, RateFetchOptions, RateFetchResult, } from "@suaveplan/currency"; import type { FetchLike } from "@suaveplan/types"; ``` `FetchLike` is imported from `@suaveplan/types` (not re-exported from the slim core) per the user instruction and slim-core convention — the slim core itself imports `FetchLike` from `@suaveplan/types`, so doing the same here keeps the dep graph shallow and avoids a re-export hop. ## Algorithm 1. Resolve `baseUrl` → default `"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"`. 2. Resolve `fetch` → default `globalThis.fetch`. 3. Call `httpRequest({ url, fetch, providerName: "ecb", signal })` — non-2xx raises `CurrencyProviderError(HTTP_ERROR)` automatically. 4. Read response body via `response.text()`. 5. Parse via the same regex upstream uses: `/currency='([A-Z]+)'\s+rate='([\d.]+)'/g`. - If the parsed map is empty, raise `PARSE_ERROR` (upstream silently returns an empty map; this sibling treats that as a parse failure to protect downstream caches). 6. Determine requested base (`options?.base ?? "EUR"`). 7. If requested base is `"EUR"`, run `filterTargets()` and return. 8. Otherwise, look up the requested base in the EUR-quoted map. If absent, raise `UNSUPPORTED_BASE` (replaces upstream's plain `Error("ECB does not provide rate for X")`). 9. Convert: `converted.EUR = 1 / baseRate`, and for every other code `code !== requestedBase`, `converted[code] = rate / baseRate`. Run `filterTargets()` and return. 10. `filterTargets(result, targets)` — if `targets` is provided, intersect. When the resulting `rates` map is empty AND `targets` was non-empty AND at least one target was requested, raise `UNKNOWN_CURRENCY` so callers discover the misconfiguration instead of getting an empty success. ## Error matrix | Code | When raised | Test | |---|---|---| | `HTTP_ERROR` | non-2xx upstream response | `ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"` | | `PARSE_ERROR` | response body contains zero matching currency tags | `ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"` | | `UNSUPPORTED_BASE` | requested non-EUR base is absent from the EUR-quoted feed | `ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"` | | `UNKNOWN_CURRENCY` | every entry in `options.targets` is absent from the resolved rate map | `ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"` | | `UPSTREAM_ERROR` | reserved — raised when the upstream is reachable, returns 2xx, but the body indicates a logical error (kept in the registry for future use; not exercised by the ECB feed which has no such error envelope). Code stays declared in the slim core as committed there; this package does not delete it. | — | Rule §2c (never delete spec elements to satisfy a gate) applies — the ECB feed format is stable and does not surface logical errors inside a 2xx, so `UPSTREAM_ERROR` is not raised by this sibling. It remains in the slim core's registry for other siblings. ## Test strategy - **No `vi.mock` / `vi.stubGlobal`.** Tests construct a `FetchLike` closure via a `recordingFetch(handler)` helper, then inject it through `new EcbProvider({ fetch })`. This matches the slim core's own `http.test.ts` and obeys CLAUDE rule 2 ("no mocks — real services only"). - **Fixture XML** mirrors the upstream test fixture verbatim (USD, GBP, JPY, CHF rates from 2026-02-27) so the parse + conversion paths are pinned to known-good numerical outputs. - **AbortSignal test** uses a real `AbortController` and a fetch closure that resolves on signal-abort, mirroring the slim core's `http.test.ts` pattern. - Coverage target: 100/100/100/100 Istanbul (every branch — EUR base / non-EUR base / target filter / each error path / custom baseUrl / injected fetch / AbortSignal forward). ## Subpath exports | Export | Purpose | |---|---| | `.` | barrel — re-exports `EcbProvider` | | `./ecb/ecb` | direct deep import for tree-shaking-strict consumers | | `./package.json` | tooling | Subpath count matches the single feature module (`ecb`) — no inflation. ## Universal-tier purity - No `node:` imports. - No browser-only globals (`window`, `document`). - `globalThis.fetch`, `Response`, `URL` are universally available (Node 18+, Bun, browsers, Web Workers). - `Number.parseFloat`, `Object.entries`, `RegExp` are ECMAScript intrinsics. No polyfills required; `tests/setup/polyfills.ts` is an empty marker matching the slim-core pattern.