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

117 lines
5.3 KiB
Markdown

# 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.