# 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 ├── 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 = ""`. 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.