# Capability Spec Delta: currency-provider-open-exchange-rates (add-currency-provider-open-exchange-rates) **Package**: `@suaveplan/currency-provider-open-exchange-rates` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: OXR-001 — Implements CurrencyProvider with stable identity The package SHALL export `OpenExchangeRatesProvider`, a class that satisfies the `CurrencyProvider` contract from `@suaveplan/currency` with the static identity fields `name === "open-exchange-rates"`, `supportsFiat === true`, `supportsCrypto === false`, and `supportsVirtual` left unset (no virtual support). #### Scenario: identity fields are set after construction - **WHEN** a caller constructs `new OpenExchangeRatesProvider({ apiKey: "k" })` - **THEN** `.name === "open-exchange-rates"`, `.supportsFiat === true`, `.supportsCrypto === false` **Verified by:** `src/provider/provider.test.ts::"exposes stable identity fields"` --- ### Requirement: OXR-002 — Routes /latest.json through slim-core httpRequest The package SHALL fetch from `${baseUrl}/latest.json?app_id=&base=` via the `httpRequest()` helper exported by `@suaveplan/currency-providers`, passing through any caller `signal` from `RateFetchOptions`. #### Scenario: builds the canonical URL and forwards signal - **WHEN** `fetchRates({ base: "USD", signal })` is called with an injected `FetchLike` - **THEN** the injected fetch is invoked exactly once with URL `https://openexchangerates.org/api/latest.json?app_id=&base=USD` and an `init.signal` derived from the caller signal **Verified by:** `src/provider/provider.test.ts::"calls /latest.json with app_id and base, forwarding caller signal"` --- ### Requirement: OXR-003 — Maps upstream payload to RateFetchResult with ms timestamp The package SHALL parse the OXR JSON payload `{ base, rates, timestamp }` and return a `RateFetchResult` where `result.base === payload.base`, `result.rates === payload.rates` (or its filtered subset per OXR-004), and `result.timestamp === payload.timestamp * 1000` (OXR returns Unix seconds; genesis exposes ms). #### Scenario: timestamp is converted to milliseconds - **WHEN** OXR returns `{ base: "USD", rates: { EUR: 0.91 }, timestamp: 1_700_000_000 }` - **THEN** the returned `RateFetchResult` has `timestamp === 1_700_000_000_000` and `rates.EUR === 0.91` **Verified by:** `src/provider/provider.test.ts::"converts OXR Unix-seconds timestamp to milliseconds"` --- ### Requirement: OXR-004 — Filters rates when options.targets is supplied When `RateFetchOptions.targets` is supplied, the package SHALL return only the rate entries whose currency code is in `targets`; codes absent from the upstream payload raise `UNKNOWN_CURRENCY` (see OXR-009). #### Scenario: only requested targets are returned - **WHEN** `fetchRates({ targets: ["EUR", "GBP"] })` is called and upstream returns `{ EUR: 0.91, GBP: 0.79, JPY: 150 }` - **THEN** the returned `rates` map has exactly the keys `EUR` and `GBP` **Verified by:** `src/provider/provider.test.ts::"filters rates down to options.targets"` --- ### Requirement: OXR-005 — Rejects non-USD base on free tier with UNSUPPORTED_BASE The package SHALL accept the free-tier constraint (USD base only) by default. When `RateFetchOptions.base` is supplied AND it is not `"USD"` AND `config.allowNonUsdBase` is not set to `true`, the package SHALL raise `CurrencyProviderError(UNSUPPORTED_BASE)` before issuing the request. Paid plans enable any base by passing `allowNonUsdBase: true` to the constructor. #### Scenario: free-tier rejects non-USD base - **WHEN** a provider constructed with `{ apiKey: "k" }` (allowNonUsdBase omitted) is called as `fetchRates({ base: "EUR" })` - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`, `providerName === "open-exchange-rates"`, and no network request is issued #### Scenario: paid-tier accepts non-USD base - **WHEN** a provider constructed with `{ apiKey: "k", allowNonUsdBase: true }` is called as `fetchRates({ base: "EUR" })` - **THEN** the injected fetch is invoked with `base=EUR` in the query string **Verified by:** `src/provider/provider.test.ts::"raises UNSUPPORTED_BASE when free-tier caller requests non-USD base"` AND `src/provider/provider.test.ts::"honours allowNonUsdBase=true and forwards base to /latest.json"` --- ### Requirement: OXR-006 — Rejects empty apiKey with MISSING_CONFIG The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` at construction time when `config.apiKey` is the empty string. A non-empty string is the only valid input. #### Scenario: empty apiKey is rejected synchronously - **WHEN** a caller invokes `new OpenExchangeRatesProvider({ apiKey: "" })` - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and `providerName === "open-exchange-rates"` **Verified by:** `src/provider/provider.test.ts::"raises MISSING_CONFIG when apiKey is empty"` --- ### Requirement: OXR-007 — Raises PARSE_ERROR on malformed upstream payload The package SHALL validate the upstream payload shape; if `base`, `rates`, or `timestamp` is missing or of the wrong runtime type, it SHALL raise `CurrencyProviderError(PARSE_ERROR)` with `providerName` populated. #### Scenario: payload missing required field - **WHEN** the injected fetch returns `Response(JSON.stringify({ base: "USD" }), 200)` (no `rates`, no `timestamp`) - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.PARSE_ERROR"` **Verified by:** `src/provider/provider.test.ts::"raises PARSE_ERROR when upstream payload is missing required fields"` --- ### Requirement: OXR-008 — Raises UPSTREAM_ERROR on 2xx error payload The package SHALL detect the OXR error-payload shape (`{ error: true, status, message, description? }`) returned with a 2xx HTTP status and raise `CurrencyProviderError(UPSTREAM_ERROR)` with `upstreamMessage` set to the upstream `message` field and `status` set to the upstream `status` field. #### Scenario: 2xx error payload surfaces UPSTREAM_ERROR - **WHEN** the injected fetch returns `Response(JSON.stringify({ error: true, status: 401, message: "invalid_app_id", description: "..." }), 200)` - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, `upstreamMessage === "invalid_app_id"`, `status === 401` **Verified by:** `src/provider/provider.test.ts::"raises UPSTREAM_ERROR when OXR returns 2xx error payload"` --- ### Requirement: OXR-009 — Raises UNKNOWN_CURRENCY when target absent from payload When `RateFetchOptions.targets` includes a code that is NOT present in the upstream `rates` map, the package SHALL raise `CurrencyProviderError(UNKNOWN_CURRENCY)` rather than silently dropping the target. The error message names the missing code. #### Scenario: missing target raises UNKNOWN_CURRENCY - **WHEN** `fetchRates({ targets: ["EUR", "ZZZ"] })` is called and upstream returns `{ EUR: 0.91 }` - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and message containing `"ZZZ"` **Verified by:** `src/provider/provider.test.ts::"raises UNKNOWN_CURRENCY when options.targets contains a code absent from upstream payload"` --- ### Requirement: OXR-010 — Surfaces HTTP_ERROR for non-2xx via slim core The package SHALL let `httpRequest()` surface `CurrencyProviderError(HTTP_ERROR)` for non-2xx upstream responses. The provider does not re-wrap the error; `providerName` is supplied to `httpRequest()` so the raised error carries it. #### Scenario: non-2xx surfaces HTTP_ERROR with providerName - **WHEN** the injected fetch returns `Response("", 503)` - **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, `providerName === "open-exchange-rates"` **Verified by:** `src/provider/provider.test.ts::"surfaces HTTP_ERROR with providerName on non-2xx upstream response"` --- ### Requirement: OXR-011 — Universal-tier purity and dependency invariants The package SHALL be universal-tier — no Node-only or DOM-only globals are referenced. Runtime dependencies are limited to `@suaveplan/currency`, `@suaveplan/currency-providers`, `@suaveplan/error`, `@suaveplan/types`, and `tslib`. The `package.json` declares `"sideEffects": false`. #### Scenario: dep-graph invariant holds - **WHEN** `tests/dep-graph.test.ts` is executed - **THEN** the parsed `package.json` `dependencies` map contains exactly the four `@suaveplan/*` packages above plus `tslib`, no DOM-only globals are imported from `src/`, and `sideEffects === false` **Verified by:** `tests/dep-graph.test.ts::"declares the expected runtime dependencies"` AND `tests/dep-graph.test.ts::"declares sideEffects: false"`