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,166 @@
# Design: @suaveplan/currency-provider-fixer
> Non-normative. The normative requirements live in
> `./specs/currency-provider-fixer/spec.md`. Cross-cutting architecture for
> the foundation-with-sibling-plugins family lives once, in
> `openspec/archive/2026-05-17-slim-currency-providers/design.md` — this
> document references it rather than duplicating it.
## Shape
```text
@suaveplan/currency-provider-fixer (this package)
└── FixerProvider — class implementing CurrencyProvider from @suaveplan/currency
├── construct: ApiKeyConfig from @suaveplan/currency-providers/types/configs
│ ├── apiKey: string (non-empty — raises MISSING_CONFIG otherwise)
│ ├── baseUrl?: string (default "https://data.fixer.io/api")
│ └── fetch?: FetchLike (default globalThis.fetch — from @suaveplan/types)
└── fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>
├── httpRequest({ ... }) from @suaveplan/currency-providers/http/http
│ non-2xx → CurrencyProviderError(HTTP_ERROR, status, providerName)
├── JSON.parse the response → typed envelope check
│ malformed → CurrencyProviderError(PARSE_ERROR)
└── upstream envelope dispatch:
{ success: true, base, rates, timestamp } → return result
{ success: false, error: { code: 105 } } → UNSUPPORTED_BASE
{ success: false, error: { code: 201|202 } } → UNKNOWN_CURRENCY
{ success: false, error: { code: any } } → UPSTREAM_ERROR
```
The package is a leaf — no `@suaveplan/*` runtime dep outside the slim core
and the `CurrencyProvider` contract that the slim core itself depends on.
## File layout
```text
src/
├── fixer/
│ ├── fixer.ts # FixerProvider class + upstream envelope types
│ ├── fixer.md # sibling docs (Purpose / Features / Usage / API / Notes)
│ ├── fixer.test.ts # behavior tests with recording FetchLike
│ └── index.ts # explicit named barrel
└── index.ts # package root barrel — explicit named exports
```
Single feature module — the package's whole contract is one class. The
folder structure mirrors every other sibling-package shape and keeps the
subpath-export count equal to the feature-module count (1 subpath → 1
feature). The slim-core `index.ts` is re-imported, never re-exported.
## Upstream envelope
Fixer.io always returns HTTP 200 — error states are signalled in the JSON
body via `{ success: false, error: { code, type, info } }`. The provider
must therefore:
1. Let `httpRequest` raise `HTTP_ERROR` on the rare genuine non-2xx (network
layer, gateway, CDN-level outage).
2. Otherwise `await response.json()` inside a `try` / `catch` and translate
any parse failure to `PARSE_ERROR`.
3. Narrow the parsed value: it must be an object with a boolean `success`
field; anything else is `PARSE_ERROR` again (defends against an unexpected
HTML redirect or an HTTP/2-pushed error page slipping through as 200).
4. On `success: true`, project to `{ base, rates, timestamp: data.timestamp * 1000 }`.
5. On `success: false`, branch on `error.code` per the table above. `error`
may be missing on malformed responses — treat absent `error` as
`UPSTREAM_ERROR` with `upstreamMessage = "<unknown>"`.
The `data.timestamp` field is Unix seconds per Fixer.io documentation; the
universal `RateFetchResult.timestamp` is milliseconds — multiply by 1000.
## Why upstream `error.code` and not `error.type`
Fixer.io's `error.type` is a kebab/snake-case string (`base_currency_access_restricted`,
`invalid_base_currency`, …) and is documented as stable across the v1 API.
`error.code` is the integer the dashboard uses (`105`, `201`, `202`, …). Both
are stable; the integer is one comparison and avoids string allocation. We
match on the integer and put the `error.info` human-readable message into
`upstreamMessage` so consumers debugging in production still see the upstream
narrative.
## Why validate `apiKey` at construction
Two reasons:
1. **Fast failure.** A test suite or boot wiring that misses the env var
should fail before any network call lands; throwing `MISSING_CONFIG` at
`new FixerProvider({ apiKey: "" })` surfaces the misconfiguration with a
discriminated error code that consumer fallbacks can match on.
2. **No wasted upstream call.** Fixer.io's free tier counts every request
against the monthly quota — including ones that fail authentication. A
construction-time guard means a missing key never burns quota.
The upstream skeleton allowed an empty `apiKey` because the constructor used
`config.apiKey` directly; this package preserves the exact same constructor
signature but adds the guard.
## Why `httpRequest` over a bare `fetch`
The slim core's `httpRequest` already:
- composes the caller's `AbortSignal` (from `RateFetchOptions.signal`) with
a timeout signal,
- raises `CurrencyProviderError(HTTP_ERROR)` with `providerName` /
`status` populated,
- forwards `init.headers` so callers can attach diagnostic headers.
Recreating any of that in this package would duplicate slim-core behaviour
and risk drift. Every sibling provider in the follow-up wave uses the same
helper.
## Tier purity
Universal-tier. Only `URLSearchParams`, `JSON.parse`, `Number.parseInt`,
`Object.freeze`, and `globalThis.fetch` (via `FetchLike` injection); no
`node:` / `bun:` / DOM imports. Runs unchanged under Node 18+, Bun, modern
browsers, and Web Workers.
## Test strategy
`src/fixer/fixer.test.ts` uses a recording `FetchLike` closure (no
`vi.mock`, no `vi.stubGlobal`) and exercises every requirement:
- Identity: `name`, `supportsFiat`, `supportsCrypto` flags.
- Construction: empty `apiKey` raises `MISSING_CONFIG`.
- Query construction: `access_key`, default `base=EUR`, custom `base`,
`symbols` list when `targets` provided.
- Default and custom `baseUrl`.
- Custom `fetch` is honoured.
- `success: true` path returns the canonical `RateFetchResult` with
`timestamp` in ms.
- `success: false` with `error.code === 105` → `UNSUPPORTED_BASE`.
- `success: false` with `error.code === 201` → `UNKNOWN_CURRENCY`.
- `success: false` with `error.code === 202` → `UNKNOWN_CURRENCY`.
- `success: false` with `error.code === 999` → `UPSTREAM_ERROR`.
- `success: false` with missing `error` → `UPSTREAM_ERROR`.
- Malformed JSON → `PARSE_ERROR`.
- Non-object JSON (`null`, `"string"`, `42`) → `PARSE_ERROR`.
- Non-2xx HTTP → `HTTP_ERROR` (via slim-core `httpRequest`).
- Caller `AbortSignal` is forwarded.
All tests import from `@suaveplan/testing/runner`. Every `it(...)` carries
`{ timeout: 5000 }`.
## Coverage / docs
100/100/100/100 istanbul thresholds set from day one in
`vitest.config.ts`. Every implementation `.ts` has a sibling `.md` with the
six mandatory sections. `docs:check` runs as part of the pre-publish gate.
## Migration narrative
Consumers that previously imported `FixerProvider` from
`@suaveplan/currency-providers` (i.e. before the 2026-05-17 slim) should
rewrite the import:
```ts
// Before
import { FixerProvider } from "@suaveplan/currency-providers";
// After
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
```
The constructor signature is identical (`new FixerProvider({ apiKey })`),
so no other call-site change is required. The slim-core's
`CurrencyProviderRegistry` accepts the new instance unchanged.

View File

@@ -0,0 +1,135 @@
# Add @suaveplan/currency-provider-fixer — Fixer.io fiat-rates sibling provider
**Package**: `@suaveplan/currency-provider-fixer`
**Status**: PROPOSED
**Type**: new package (genesis-original sibling of slimmed `@suaveplan/currency-providers` core)
- **Design**: ./design.md
- **Spec delta**: ./specs/currency-provider-fixer/spec.md
## Why
The 2026-05-17 slim refactor of `@suaveplan/currency-providers`
(`openspec/archive/2026-05-17-slim-currency-providers/`) split the nine
concrete providers out of the umbrella package and committed to landing each
as a dedicated `@suaveplan/currency-provider-<name>` sibling consuming the
slim core (registry / factory / `httpRequest` / `composeAbortSignal` / configs
/ `CurrencyProviderError`). This change lands the **Fixer.io** sibling — the
first API-keyed fiat-rates provider in the follow-up wave.
Fixer.io is the most widely deployed third-party fiat-rates API after ECB
(Reuters / payment-aggregator backbone for years); shipping it early gives
downstream commerce / accounting consumers an API-keyed alternative to the
key-free ECB feed (`@suaveplan/currency-provider-ecb`, landing alongside)
that supports 170+ currencies and is gateway-friendly.
## What changes
### ADDED
- **New package** `@suaveplan/currency-provider-fixer` (universal-tier,
`0.1.0`, `sideEffects: false`).
- **`FixerProvider` class** implementing the `CurrencyProvider` contract from
`@suaveplan/currency` (`name = "fixer"`, `supportsFiat = true`,
`supportsCrypto = false`). Constructor takes `ApiKeyConfig` (from the slim
core's `./types/configs`). Optional `baseUrl` override defaults to
`https://data.fixer.io/api`. Optional injectable `FetchLike` (from
`@suaveplan/types`) defaults to `globalThis.fetch`.
- **`fetchRates(options?)`** delegates HTTP to the slim core's `httpRequest`
helper (caller-signal composition, non-2xx → typed `HTTP_ERROR`, response
pass-through), then parses the upstream JSON envelope into the universal
`RateFetchResult` shape (`{ base, rates, timestamp }`).
- **Typed error mapping** from upstream `{ success: false, error: { code,
type, info } }` envelopes to the slim core's `CURRENCY_PROVIDER_*` codes:
- empty `apiKey` at construction → `MISSING_CONFIG`
- non-2xx HTTP response → `HTTP_ERROR` (raised by `httpRequest`)
- malformed / non-object JSON → `PARSE_ERROR`
- upstream code `105` (`base_currency_access_restricted`) →
`UNSUPPORTED_BASE`
- upstream code `201` (`invalid_base_currency`) or `202`
(`invalid_currency_codes`) → `UNKNOWN_CURRENCY`
- any other `success: false` envelope → `UPSTREAM_ERROR` carrying
`upstreamMessage`
- **No auto-register.** The package exports the class; callers wire it via
`new FixerProvider({ apiKey })` or
`registry.register(new FixerProvider({ apiKey }))` per the slim-core
design. There is no top-level side effect.
### KEPT (unchanged from slim core)
- The `CurrencyProvider` contract, `RateFetchOptions`, `RateFetchResult`
remain owned by `@suaveplan/currency`.
- `CURRENCY_PROVIDER_CODES`, `CurrencyProviderError`, `httpRequest`,
`composeAbortSignal`, and `ApiKeyConfig` remain owned by
`@suaveplan/currency-providers`.
## Source mapping
This package is a migration of the upstream `FixerProvider` previously
bundled in `@suaveplan/currency-providers`. The source was carved out by the
2026-05-17 slim refactor and is reconstituted here against the slim-core
contracts.
| Component | Source |
| --- | --- |
| `FixerProvider` class skeleton | `../suaveplan/packages/finance/currency-providers/src/fiat/fixer.ts` L1–L67 |
| README quick-start example | upstream `fixer.md` L17–L37 |
| Free-tier EUR-only constraint | upstream `fixer.md` L52–L54 + Fixer.io API docs error code `105` |
| Upstream JSON envelope shape (`success` / `error.code` / `error.info` / `base` / `rates` / `timestamp`) | upstream `fixer.ts` L47–L53 + Fixer.io public docs |
| HTTP / abort-signal / non-2xx mapping | `@suaveplan/currency-providers/http/http` (slim core) |
| Error code surface | `@suaveplan/currency-providers/error-codes/error-codes` (slim core) |
The genesis-native upgrades over the upstream skeleton:
- Replaces the bare `throw new Error(...)` in upstream with
`CurrencyProviderError` carrying stable codes from the slim core's registry
(upstream `fixer.ts` L42 / L57 → `MISSING_CONFIG` / `PARSE_ERROR` /
`UNSUPPORTED_BASE` / `UNKNOWN_CURRENCY` / `UPSTREAM_ERROR`).
- Routes HTTP through the slim core's shared `httpRequest` so caller-supplied
`AbortSignal` from `RateFetchOptions.signal` is honoured (upstream version
did not).
- Validates `apiKey` at construction (upstream silently accepted an empty
string and let the upstream API reject it on the wire).
- Adds the `FetchLike` injection seam from `@suaveplan/types` (upstream used
the bare `globalThis.fetch`).
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
| --- | --- | --- | --- |
| `name`/`supportsFiat`/`supportsCrypto` identity flags | ✅ | ✅ | |
| `ApiKeyConfig` constructor (apiKey + optional baseUrl) | ✅ | ✅ | Inherited from slim core. |
| Default URL `https://data.fixer.io/api` | ✅ | ✅ | |
| `fetchRates({ base, targets })` query construction | ✅ | ✅ | `access_key`, `base`, `symbols`. |
| Default base `EUR` when none supplied | ✅ | ✅ | |
| `success: false` envelope detection | ✅ | ⬆️ | Now mapped to typed codes via upstream `error.code` discriminant instead of a bare `throw new Error()`. |
| Non-2xx → typed error | ✅ | ⬆️ | Routed through slim core's `httpRequest`; raises `HTTP_ERROR` with populated `status` + `providerName`. |
| Timestamp seconds → ms conversion | ✅ | ✅ | `data.timestamp * 1000`. |
| Symbol filter via `targets` | ✅ | ✅ | |
| `apiKey` non-empty validation at construction | 🚫 | ⬆️ | New: raises `MISSING_CONFIG` instead of letting the API reject on the wire. |
| `AbortSignal` propagation via `RateFetchOptions.signal` | 🚫 | ⬆️ | New: caller signal composed by slim-core `httpRequest`. |
| `FetchLike` injection seam | 🚫 | ⬆️ | New: optional `config.fetch` honoured for testability and proxying. |
No upstream capability is dropped. Every 🚫 row is an ADDITION on top of upstream.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-fixer` to enumerate.
- No current dependents (package is new). The slim core
`@suaveplan/currency-providers` is depended on by this package, not the
reverse — no consumer rewrite is required.
## Out of scope
- Other sibling providers (ECB, OXR, Binance, CoinGecko, CMC, CryptoCompare,
Kraken, Virtual) — each ships under its own OpenSpec change per AGENTS.md
§0c.
- Historical rates (`/{date}` endpoint), time-series (`/timeseries`),
fluctuation (`/fluctuation`), conversion (`/convert`) endpoints — the
`CurrencyProvider` contract today exposes only `fetchRates`; the additional
endpoints will land once the contract is extended (carried over from the
2026-05-17 archive's `## Out of scope`).
- Rate-limit / retry / aggregator wrappers — same deferral as the slim core.

View File

@@ -0,0 +1,299 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: currency-provider-fixer (currency-provider-fixer)
**Package**: `@suaveplan/currency-provider-fixer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> No exceptions.
## ADDED Requirements
### Requirement: Provider identity flags
The package SHALL export a `FixerProvider` class that implements the
`CurrencyProvider` contract from `@suaveplan/currency` and exposes the stable
identity flags `name = "fixer"`, `supportsFiat = true`, and
`supportsCrypto = false`.
#### Scenario: identity flags are stable
- **WHEN** a caller constructs `new FixerProvider({ apiKey: "x" })`
- **THEN** `provider.name === "fixer"`, `provider.supportsFiat === true`,
and `provider.supportsCrypto === false`
**Verified by:** `src/fixer/fixer.test.ts::"exposes the stable identity flags name/supportsFiat/supportsCrypto"`
---
### Requirement: API key required at construction
The constructor SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.MISSING_CONFIG` when `config.apiKey` is the empty string,
before any network request is issued.
#### Scenario: empty api key is rejected at construction
- **WHEN** a caller invokes `new FixerProvider({ apiKey: "" })`
- **THEN** a `CurrencyProviderError` is thrown synchronously with
`code === CURRENCY_PROVIDER.MISSING_CONFIG` and `providerName === "fixer"`
**Verified by:** `src/fixer/fixer.test.ts::"raises MISSING_CONFIG when apiKey is empty at construction"`
---
### Requirement: Default base URL
The provider SHALL default the request base URL to
`https://data.fixer.io/api` and SHALL allow the caller to override it via
`config.baseUrl`.
#### Scenario: default base URL used when none provided
- **WHEN** `new FixerProvider({ apiKey: "k", fetch }).fetchRates()` is called
- **THEN** the recorded `fetch` URL starts with `https://data.fixer.io/api/latest?`
**Verified by:** `src/fixer/fixer.test.ts::"defaults the request base URL to data.fixer.io when no override is provided"`
#### Scenario: custom base URL is honoured
- **WHEN** `new FixerProvider({ apiKey: "k", baseUrl: "https://proxy/api", fetch }).fetchRates()` is called
- **THEN** the recorded `fetch` URL starts with `https://proxy/api/latest?`
**Verified by:** `src/fixer/fixer.test.ts::"honours a custom baseUrl override"`
---
### Requirement: Query construction
`fetchRates(options?)` SHALL build the request query with `access_key` set
to the configured api key, `base` defaulting to `EUR` when
`options.base` is unset, and `symbols` set to a comma-joined list when
`options.targets` is a non-empty array. When `options.targets` is omitted or
empty, no `symbols` parameter SHALL be added.
#### Scenario: default base EUR and access_key are forwarded
- **WHEN** `fetchRates()` is called without options
- **THEN** the URL query contains `access_key=k` and `base=EUR` and does
not contain `symbols`
**Verified by:** `src/fixer/fixer.test.ts::"defaults base to EUR and forwards access_key, omits symbols when targets is unset"`
#### Scenario: targets list is comma-joined into symbols
- **WHEN** `fetchRates({ base: "USD", targets: ["EUR","GBP"] })` is called
- **THEN** the URL query contains `base=USD` and `symbols=EUR%2CGBP`
**Verified by:** `src/fixer/fixer.test.ts::"forwards a custom base and comma-joins targets into the symbols param"`
---
### Requirement: Injectable FetchLike
The provider SHALL honour the optional `config.fetch` (`FetchLike` from
`@suaveplan/types`) and use it for every outbound request. When omitted, the
provider SHALL fall back to `globalThis.fetch`.
#### Scenario: custom fetch is invoked
- **WHEN** a recording `FetchLike` is passed via `config.fetch` and
`fetchRates()` is called
- **THEN** the recorded fetch closure receives exactly one call for that
invocation
**Verified by:** `src/fixer/fixer.test.ts::"invokes the injected FetchLike instead of globalThis.fetch"`
---
### Requirement: Successful envelope projection
The provider SHALL resolve `fetchRates` with `{ base, rates, timestamp:
data.timestamp * 1000 }` when the upstream responds with
`{ success: true, base, rates, timestamp }`. The upstream `timestamp` is
Unix seconds; the returned `timestamp` SHALL be milliseconds since epoch.
#### Scenario: successful envelope is returned with millisecond timestamp
- **WHEN** the upstream returns `{ success: true, base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000 }` with HTTP 200
- **THEN** `fetchRates` resolves to `{ base: "EUR", rates: { GBP: 0.85 }, timestamp: 1_700_000_000_000 }`
**Verified by:** `src/fixer/fixer.test.ts::"returns the canonical RateFetchResult with the timestamp converted from seconds to milliseconds"`
---
### Requirement: UNSUPPORTED_BASE mapping
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UNSUPPORTED_BASE`, `providerName === "fixer"`, and the
upstream `error.info` carried on `upstreamMessage` whenever the upstream
responds with `{ success: false, error: { code: 105 } }`
(`base_currency_access_restricted` — free tier rejecting a non-EUR base).
#### Scenario: free-tier base restriction maps to UNSUPPORTED_BASE
- **WHEN** the upstream returns `{ success: false, error: { code: 105, type: "base_currency_access_restricted", info: "Access Restricted" } }`
- **THEN** `fetchRates({ base: "USD" })` rejects with
`code === CURRENCY_PROVIDER.UNSUPPORTED_BASE` and
`upstreamMessage === "Access Restricted"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 105 to UNSUPPORTED_BASE"`
---
### Requirement: UNKNOWN_CURRENCY mapping
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and propagate the upstream
`error.info` on `upstreamMessage` whenever the upstream responds with
`{ success: false, error: { code: 201 } }` (`invalid_base_currency`) or
`{ ..., error: { code: 202 } }` (`invalid_currency_codes`).
#### Scenario: invalid base currency maps to UNKNOWN_CURRENCY
- **WHEN** the upstream returns `{ success: false, error: { code: 201, info: "invalid base" } }`
- **THEN** `fetchRates({ base: "ZZZ" })` rejects with
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
`upstreamMessage === "invalid base"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 201 to UNKNOWN_CURRENCY"`
#### Scenario: invalid symbol codes maps to UNKNOWN_CURRENCY
- **WHEN** the upstream returns `{ success: false, error: { code: 202, info: "invalid symbols" } }`
- **THEN** `fetchRates({ targets: ["ZZZ"] })` rejects with
`code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY` and
`upstreamMessage === "invalid symbols"`
**Verified by:** `src/fixer/fixer.test.ts::"maps upstream error.code 202 to UNKNOWN_CURRENCY"`
---
### Requirement: UPSTREAM_ERROR fallback
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.UPSTREAM_ERROR`, `providerName === "fixer"`, and
`upstreamMessage` populated with the upstream `error.info` (or the string
`"<unknown>"` when absent) whenever the upstream responds with
`{ success: false }` and any code that is not one of the discriminated
`105` / `201` / `202` codes — including when the `error` field is missing
entirely.
#### Scenario: unknown upstream error code maps to UPSTREAM_ERROR
- **WHEN** the upstream returns `{ success: false, error: { code: 999, info: "rate limit exceeded" } }`
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
`upstreamMessage === "rate limit exceeded"`
**Verified by:** `src/fixer/fixer.test.ts::"maps any other success:false code to UPSTREAM_ERROR with upstreamMessage"`
#### Scenario: missing error field still maps to UPSTREAM_ERROR
- **WHEN** the upstream returns `{ success: false }` with no `error` field
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.UPSTREAM_ERROR` and
`upstreamMessage === "<unknown>"`
**Verified by:** `src/fixer/fixer.test.ts::"maps success:false with no error field to UPSTREAM_ERROR with <unknown>"`
---
### Requirement: PARSE_ERROR on malformed JSON
The provider SHALL raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.PARSE_ERROR` and `providerName === "fixer"` whenever
the upstream body cannot be parsed as JSON, when the parsed value is not
a non-null object (for example `null`, a string, a number, or an array),
or when a `success: true` envelope is missing any required field.
#### Scenario: invalid JSON body raises PARSE_ERROR
- **WHEN** the upstream returns the literal body `"<html>"` with HTTP 200
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when the response body is not valid JSON"`
#### Scenario: non-object JSON raises PARSE_ERROR
- **WHEN** the upstream returns the literal body `"null"` with HTTP 200
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when the parsed JSON is null or a non-object"`
#### Scenario: success:true envelope with missing required fields raises PARSE_ERROR
- **WHEN** the upstream returns `{ success: true }` with `base` / `rates` /
`timestamp` absent
- **THEN** `fetchRates()` rejects with `code === CURRENCY_PROVIDER.PARSE_ERROR`
**Verified by:** `src/fixer/fixer.test.ts::"raises PARSE_ERROR when a success envelope is missing required fields"`
---
### Requirement: HTTP_ERROR via slim-core httpRequest
The provider SHALL delegate HTTP transport to the slim core's `httpRequest`
helper so that non-2xx responses raise `CurrencyProviderError` with code
`CURRENCY_PROVIDER.HTTP_ERROR`, `providerName === "fixer"`, and `status`
populated to the upstream HTTP status code.
#### Scenario: 5xx upstream raises HTTP_ERROR
- **WHEN** the upstream returns HTTP 503 with body `"Service Unavailable"`
- **THEN** `fetchRates()` rejects with
`code === CURRENCY_PROVIDER.HTTP_ERROR`, `status === 503`, and
`providerName === "fixer"`
**Verified by:** `src/fixer/fixer.test.ts::"raises HTTP_ERROR with status and providerName on a non-2xx response"`
---
### Requirement: Caller AbortSignal propagation
When `options.signal` is supplied to `fetchRates`, the provider SHALL
forward it through `httpRequest` so the underlying fetch is cancelled when
the caller aborts.
#### Scenario: aborting the caller signal rejects the fetchRates promise
- **WHEN** `fetchRates({ signal })` is called with a signal whose controller
is aborted before the upstream resolves
- **THEN** the returned promise rejects with the caller's abort reason
**Verified by:** `src/fixer/fixer.test.ts::"forwards the caller AbortSignal to the underlying fetch"`
---
### Requirement: No auto-register top-level side effect
The package SHALL NOT register an instance of `FixerProvider` with any
global registry at import time. Importing the package SHALL be free of
side effects and SHALL leave `"sideEffects": false` honoured for
tree-shaking.
#### Scenario: import has no observable side effect
- **WHEN** the package is imported via `await import("@suaveplan/currency-provider-fixer")`
- **THEN** no `CurrencyProviderRegistry` instance is constructed or
mutated by the module, and the only public surface is the `FixerProvider`
class
**Verified by:** `src/fixer/fixer.test.ts::"has no side effect at import time and exposes only FixerProvider"`
---
### Requirement: Universal-tier purity
The package SHALL be universal-tier — it SHALL NOT import any
`node:` / `bun:` / DOM-only module, and SHALL run unchanged under Node 18+,
Bun, modern browsers, and Web Workers.
#### Scenario: no banned imports in source
- **WHEN** the source tree under `src/` is scanned
- **THEN** zero matches are found for `from "node:`, `from "bun:`,
`from "fs"`, `from "path"`, or any DOM-only global
**Verified by:** `src/fixer/fixer.test.ts::"imports only universal-tier modules — no node:, bun:, fs, or DOM-only globals"`

View File

@@ -0,0 +1,78 @@
# Tasks — add-currency-provider-fixer
## 1. Package scaffold
- [x] `packages/finance/currency-provider-fixer/package.json` — `0.1.0`,
`sideEffects: false`, deps on `@suaveplan/currency` /
`@suaveplan/currency-providers` / `@suaveplan/types` / `tslib`, devDeps via
catalog refs.
- [x] `tsconfig.json` + `tsconfig.lib.json` extending the repo base.
- [x] `vite.config.ts` with `preserveModules` and externals matching deps.
- [x] `vitest.config.ts` with `pool: "forks"`, `setupFiles` polyfill stub,
`provider: "istanbul"`, thresholds `100/100/100/100`.
- [x] `tests/setup/polyfills.ts` empty universal-tier stub.
## 2. Implementation
- [x] `src/fixer/fixer.ts` — `FixerProvider implements CurrencyProvider`:
- constructor takes `ApiKeyConfig`, raises `MISSING_CONFIG` on empty
`apiKey`.
- resolves `baseUrl` (default `https://data.fixer.io/api`) and `fetch`
(default `globalThis.fetch`).
- `fetchRates(options?)` builds `URLSearchParams` (`access_key`, `base`,
optional `symbols`), calls slim-core `httpRequest`, parses JSON,
discriminates envelope, returns canonical `RateFetchResult` or raises
typed error.
- [x] `src/fixer/index.ts` — explicit named barrel for the feature module
(`FixerProvider`).
- [x] `src/index.ts` — explicit named barrel re-exporting `FixerProvider`.
## 3. Docs
- [x] `src/fixer/fixer.md` — Purpose / Features / Usage Basic / Usage
Advanced / API Reference / Implementation Notes (≥200 words excluding
code blocks).
- [x] `README.md` at package root with Install / Quick start / Features /
Modules / Status.
## 4. Tests
- [x] `src/fixer/fixer.test.ts` covers every requirement in
`specs/currency-provider-fixer/spec.md`. All tests import from
`@suaveplan/testing/runner`; every `it(...)` has `{ timeout: 5000 }`. No
mocks — every fetch is a recording `FetchLike` closure.
## 5. Pre-ship gates (§13)
- [x] Spec reconciliation (step 0): every `### Requirement` and every
`**Verified by:**` matches a test that exists in `src/fixer/fixer.test.ts`.
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-fixer` green.
- [x] `bun run check` from package dir green.
- [x] `bunx turbo test:coverage --filter=@suaveplan/currency-provider-fixer`
green at 100/100/100/100.
- [x] `bunx turbo build --filter=@suaveplan/currency-provider-fixer` green.
- [x] Banned-pattern grep zero (TODO / FIXME / @deprecated / ts-ignore /
ts-expect-error / .skip / .only / `console.log`).
- [x] Barrel-shape grep zero (no `export *` in `<feature>/index.ts`).
- [x] Test-runner-import grep zero (no direct `from "vitest"` / `from "bun:test"`).
- [x] Missing `.md` grep zero.
- [x] `README.md` present.
- [x] No bare versions in `package.json` (every dep uses `catalog:` or
`workspace:^`).
- [x] Subpath-export count matches feature count (1).
- [x] Security grep zero (no hardcoded keys / tokens / passwords).
## 6. Root-doc refresh (§13b)
- [x] `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`.
- [x] Update `STATUS.md` (add `currency-provider-fixer` row under
Completed).
- [x] Update `SUMMARY.md` (add per-package paragraph).
- [x] `bunx markdownlint-cli2 --fix` across touched files.
## 7. Archive (§14)
- [x] Fold `specs/currency-provider-fixer/spec.md` into
`openspec/specs/currency-provider-fixer/spec.md`.
- [x] Move `openspec/changes/add-currency-provider-fixer/` to
`openspec/archive/2026-05-17-add-currency-provider-fixer/`.