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

9.3 KiB

Capability Spec Delta: currency-provider-ecb (add-currency-provider-ecb)

Package: @suaveplan/currency-provider-ecb Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: EcbProvider implements CurrencyProvider with stable identity

The package SHALL export EcbProvider, a class implementing CurrencyProvider from @suaveplan/currency with name = "ecb", supportsFiat = true, and supportsCrypto = false. The constructor SHALL accept an optional NoKeyConfig (from @suaveplan/currency-providers) carrying baseUrl and fetch overrides; no API key is required.

Scenario: identity flags

  • WHEN a caller constructs new EcbProvider() with no config
  • THEN provider.name === "ecb" AND provider.supportsFiat === true AND provider.supportsCrypto === false

Scenario: custom baseUrl override

  • WHEN a caller constructs new EcbProvider({ baseUrl: "https://proxy/ecb" }) and calls fetchRates() with an injected fetch
  • THEN the injected fetch is invoked with "https://proxy/ecb"

Verified by: src/ecb/ecb.test.ts::"exposes the documented identity flags" AND src/ecb/ecb.test.ts::"calls the custom baseUrl when provided"


Requirement: fetchRates returns EUR-based rates by default

The package SHALL fetch the ECB daily XML feed through httpRequest() from @suaveplan/currency-providers, parse the EUR-quoted currency table via the regex /currency='([A-Z]+)'\s+rate='([\d.]+)'/g, and return a RateFetchResult whose base is "EUR" when the caller passes no base option or explicitly passes "EUR". The returned rates map SHALL contain every currency present in the feed, keyed by ISO-4217 code with numeric exchange rates parsed via Number.parseFloat. The timestamp SHALL be the wall-clock millisecond value at fetch completion.

Scenario: default base resolves to EUR

  • WHEN fetchRates() is called with no options against a feed containing USD=1.0870, GBP=0.8358, JPY=162.48, CHF=0.9443
  • THEN the result has base === "EUR" AND rates.USD === 1.0870 AND rates.GBP === 0.8358 AND rates.JPY === 162.48 AND rates.CHF === 0.9443 AND timestamp is a finite positive number

Scenario: explicit EUR base

  • WHEN fetchRates({ base: "EUR" }) is called against the same feed
  • THEN the result equals the default-base result

Verified by: src/ecb/ecb.test.ts::"returns EUR-based rates when no base is specified" AND src/ecb/ecb.test.ts::"returns EUR-based rates when EUR is explicitly requested"


Requirement: fetchRates converts to a non-EUR base mathematically

When the caller requests a non-EUR base, the package SHALL look up that base in the EUR-quoted rate map and synthesise the target view by computing result.EUR = 1 / baseRate and result.<other> = <eurRate> / baseRate for every other currency. The requested base SHALL be absent from the returned rates map (a currency does not quote itself).

Scenario: USD base derived from EUR feed

  • WHEN fetchRates({ base: "USD" }) is called against a feed where USD=1.0870 and GBP=0.8358 (EUR-quoted)
  • THEN the result has base === "USD" AND rates.EUR is within 1e-4 of 1 / 1.0870 AND rates.GBP is within 1e-4 of 0.8358 / 1.0870 AND rates.USD === undefined

Verified by: src/ecb/ecb.test.ts::"converts to a non-EUR base by inverting through the EUR feed"


Requirement: targets filter restricts the returned rate map

When options.targets is provided, the package SHALL return only the currencies present in targets AND in the resolved rate map. Currencies listed in targets that are absent from the feed SHALL be silently dropped UNLESS every requested target is absent, in which case the package SHALL raise CurrencyProviderError(UNKNOWN_CURRENCY).

Scenario: targets intersect the feed

  • WHEN fetchRates({ base: "EUR", targets: ["USD", "GBP"] }) is called against a feed containing USD, GBP, JPY, CHF
  • THEN the result rates contains exactly two keys, USD and GBP, AND rates.JPY === undefined

Scenario: all requested targets absent

  • WHEN fetchRates({ base: "EUR", targets: ["ZZZ", "QQQ"] }) is called against a feed containing only USD, GBP, JPY, CHF
  • THEN the call rejects with CurrencyProviderError whose code === CURRENCY_PROVIDER.UNKNOWN_CURRENCY AND whose providerName === "ecb"

Verified by: src/ecb/ecb.test.ts::"restricts the returned rates to the requested targets" AND src/ecb/ecb.test.ts::"raises UNKNOWN_CURRENCY when every requested target is absent"


Requirement: HTTP failures surface as typed CurrencyProviderError(HTTP_ERROR)

The package SHALL route every HTTP call through httpRequest() from @suaveplan/currency-providers. When the upstream returns a non-2xx response, the call SHALL reject with CurrencyProviderError whose code is CURRENCY_PROVIDER.HTTP_ERROR, whose providerName === "ecb", and whose status matches the HTTP status code. The package SHALL NOT hand-roll a fetch() call site or a !response.ok block.

Scenario: 5xx response

  • WHEN the injected fetch resolves with new Response("", { status: 503, statusText: "Down" })
  • THEN the call rejects with CurrencyProviderError whose code === CURRENCY_PROVIDER.HTTP_ERROR AND status === 503 AND providerName === "ecb"

Verified by: src/ecb/ecb.test.ts::"raises HTTP_ERROR on a 5xx upstream response"


Requirement: PARSE_ERROR raised for an unparseable feed body

When the upstream returns 2xx but the body contains zero matches for the currency-rate regex, the package SHALL raise CurrencyProviderError(PARSE_ERROR). The package SHALL NOT cache or return an empty rate map silently.

Scenario: empty body

  • WHEN the injected fetch resolves with new Response("<garbage/>", { status: 200 })
  • THEN the call rejects with CurrencyProviderError whose code === CURRENCY_PROVIDER.PARSE_ERROR AND providerName === "ecb"

Verified by: src/ecb/ecb.test.ts::"raises PARSE_ERROR when the upstream payload has no currency tags"


Requirement: UNSUPPORTED_BASE raised when the requested base is missing from the feed

When the caller requests a non-EUR base that is not present in the EUR-quoted feed (e.g. "XYZ"), the package SHALL raise CurrencyProviderError(UNSUPPORTED_BASE).

Scenario: unknown non-EUR base

  • WHEN fetchRates({ base: "XYZ" }) is called against a feed that does not contain XYZ
  • THEN the call rejects with CurrencyProviderError whose code === CURRENCY_PROVIDER.UNSUPPORTED_BASE AND providerName === "ecb"

Verified by: src/ecb/ecb.test.ts::"raises UNSUPPORTED_BASE when the requested base is missing from the feed"


Requirement: AbortSignal from RateFetchOptions is forwarded to httpRequest

When options.signal is set, the package SHALL forward that signal to httpRequest({ signal }). The slim-core helper composes it with any timeout signal via AbortSignal.any; aborting the caller signal SHALL abort the in-flight upstream fetch.

Scenario: caller aborts the call

  • WHEN the injected fetch returns a Promise that rejects on signal.abort() and the caller calls controller.abort(new Error("cancelled")) after issuing fetchRates({ signal: controller.signal })
  • THEN the call rejects with an Error whose message contains "cancelled"

Verified by: src/ecb/ecb.test.ts::"forwards the caller AbortSignal to the upstream fetch"


Requirement: Injectable FetchLike for deterministic tests and proxying

The constructor SHALL accept NoKeyConfig.fetch: FetchLike (imported from @suaveplan/types) and use it for every HTTP call. When fetch is not provided, the package SHALL fall back to globalThis.fetch. The package SHALL NOT capture or close over globalThis.fetch at module-load time — the fallback resolves at call time so test setups that replace globalThis.fetch after construction still work.

Scenario: explicit fetch wins over globalThis.fetch

  • WHEN new EcbProvider({ fetch: recordedFetch }) is instantiated and fetchRates() is called
  • THEN recordedFetch is invoked exactly once with the resolved URL AND globalThis.fetch is never invoked

Verified by: src/ecb/ecb.test.ts::"uses the injected fetch when provided"


Requirement: Universal-tier purity and zero side effects

The package SHALL be importable in browsers, Web Workers, Node.js, and Bun without any environment-specific shim. The package SHALL declare "sideEffects": false in package.json and SHALL NOT register itself with any registry at module-load time. The package SHALL ship explicit named re-exports in every barrel (no export *).

Scenario: package.json declares sideEffects false

  • WHEN the package.json is read at build time
  • THEN pkg.sideEffects === false

Scenario: barrel uses explicit named exports

  • WHEN the contents of src/ecb/index.ts and src/index.ts are read
  • THEN neither file contains the substring export * (other than export * as namespace forms, of which there are none in this package)

Verified by: src/ecb/ecb.test.ts::"exposes the documented identity flags" AND tests/package-shape.test.ts::"declares sideEffects false and explicit-named barrels"