Files
2026-05-27 23:29:53 +01:00

8.8 KiB

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=<apiKey>&base=<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=<key>&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"