# 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 `""` 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 === ""` **Verified by:** `src/fixer/fixer.test.ts::"maps success:false with no error field to UPSTREAM_ERROR with "` --- ### 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 `""` 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"`