Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
# 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.

View File

@@ -0,0 +1,183 @@
# 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() });
```

View File

@@ -0,0 +1,201 @@
<!-- markdownlint-disable MD024 -->
# 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.<other> = <eurRate> / 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("<garbage/>", { 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,53 @@
# Tasks — add @suaveplan/currency-provider-ecb
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/currency-providers.md` end-to-end (ECB row)
- [x] 1.2 Read slim-core archive `openspec/archive/2026-05-17-slim-currency-providers/`
- [x] 1.3 Read `openspec/AGENTS.md` §0c, §13, §14
- [x] 1.4 Draft `proposal.md` with `## Source mapping` and `## Parity Checklist`
- [x] 1.5 Draft `design.md` with file layout, algorithm, error matrix
- [x] 1.6 Draft `specs/currency-provider-ecb/spec.md` — every requirement carries `**Verified by:**`
## 2. Implementation
- [x] 2.1 Create `packages/finance/currency-provider-ecb/` skeleton (package.json, tsconfig, vite.config, vitest.config, tests/setup/polyfills.ts)
- [x] 2.2 Implement `src/ecb/ecb.ts` — `EcbProvider` class consuming `httpRequest` from slim core
- [x] 2.3 Wire `FetchLike` from `@suaveplan/types` (NOT re-imported from slim core)
- [x] 2.4 Raise typed `CurrencyProviderError` with `HTTP_ERROR` / `PARSE_ERROR` / `UNSUPPORTED_BASE` / `UNKNOWN_CURRENCY`
- [x] 2.5 Forward `RateFetchOptions.signal` through `httpRequest`
- [x] 2.6 Author `src/ecb/index.ts` + `src/index.ts` explicit named barrels
- [x] 2.7 Add subpath exports to `package.json` (`.`, `./ecb/ecb`, `./package.json`)
## 3. Tests
- [x] 3.1 `src/ecb/ecb.test.ts` — every scenario in the spec delta
- [x] 3.2 Tests import runner primitives from `@suaveplan/testing/runner`
- [x] 3.3 No `vi.mock` / `vi.stubGlobal` — use injected `FetchLike` closure
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `src/ecb/ecb.md` (≥200 words, basic + advanced examples, full API)
- [x] 4.2 `README.md` with Install + Quick start + Modules section linking `src/ecb/ecb.md`
## 5. Pre-archive verification (5-gate triple)
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-ecb` — green
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/currency-provider-ecb` — 100/100/100/100
- [x] 5.3 `bunx turbo build --filter=@suaveplan/currency-provider-ecb` — green
- [x] 5.4 Banned-pattern scan zero output
- [x] 5.5 Barrel-shape scan zero output (no `export *` in feature barrels)
- [x] 5.6 Test-runner import scan zero output
## 6. Root-doc refresh (§13b)
- [x] 6.1 Add row to `STATUS.md` under Completed
- [x] 6.2 Add paragraph to `SUMMARY.md`
- [x] 6.3 Regenerate `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
- [x] 6.4 Regenerate `MISSING.yml` (`bun scripts/generate-missing.ts`) — skip if generator fails
## 7. Archive
- [ ] 7.1 Fold `specs/currency-provider-ecb/spec.md` into `openspec/specs/currency-provider-ecb/spec.md`
- [ ] 7.2 Move `openspec/changes/add-currency-provider-ecb/` → `openspec/archive/2026-05-17-add-currency-provider-ecb/`