Initial Commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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<RateFetchResult>
|
||||
├── 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 = "<unknown>"`.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Add @suaveplan/currency-provider-fixer — Fixer.io fiat-rates sibling provider
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-fixer`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new package (genesis-original sibling of slimmed `@suaveplan/currency-providers` core)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/currency-provider-fixer/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-17 slim refactor of `@suaveplan/currency-providers`
|
||||
(`openspec/archive/2026-05-17-slim-currency-providers/`) split the nine
|
||||
concrete providers out of the umbrella package and committed to landing each
|
||||
as a dedicated `@suaveplan/currency-provider-<name>` sibling consuming the
|
||||
slim core (registry / factory / `httpRequest` / `composeAbortSignal` / configs
|
||||
/ `CurrencyProviderError`). This change lands the **Fixer.io** sibling — the
|
||||
first API-keyed fiat-rates provider in the follow-up wave.
|
||||
|
||||
Fixer.io is the most widely deployed third-party fiat-rates API after ECB
|
||||
(Reuters / payment-aggregator backbone for years); shipping it early gives
|
||||
downstream commerce / accounting consumers an API-keyed alternative to the
|
||||
key-free ECB feed (`@suaveplan/currency-provider-ecb`, landing alongside)
|
||||
that supports 170+ currencies and is gateway-friendly.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- **New package** `@suaveplan/currency-provider-fixer` (universal-tier,
|
||||
`0.1.0`, `sideEffects: false`).
|
||||
- **`FixerProvider` class** implementing the `CurrencyProvider` contract from
|
||||
`@suaveplan/currency` (`name = "fixer"`, `supportsFiat = true`,
|
||||
`supportsCrypto = false`). Constructor takes `ApiKeyConfig` (from the slim
|
||||
core's `./types/configs`). Optional `baseUrl` override defaults to
|
||||
`https://data.fixer.io/api`. Optional injectable `FetchLike` (from
|
||||
`@suaveplan/types`) defaults to `globalThis.fetch`.
|
||||
- **`fetchRates(options?)`** delegates HTTP to the slim core's `httpRequest`
|
||||
helper (caller-signal composition, non-2xx → typed `HTTP_ERROR`, response
|
||||
pass-through), then parses the upstream JSON envelope into the universal
|
||||
`RateFetchResult` shape (`{ base, rates, timestamp }`).
|
||||
- **Typed error mapping** from upstream `{ success: false, error: { code,
|
||||
type, info } }` envelopes to the slim core's `CURRENCY_PROVIDER_*` codes:
|
||||
- empty `apiKey` at construction → `MISSING_CONFIG`
|
||||
- non-2xx HTTP response → `HTTP_ERROR` (raised by `httpRequest`)
|
||||
- malformed / non-object JSON → `PARSE_ERROR`
|
||||
- upstream code `105` (`base_currency_access_restricted`) →
|
||||
`UNSUPPORTED_BASE`
|
||||
- upstream code `201` (`invalid_base_currency`) or `202`
|
||||
(`invalid_currency_codes`) → `UNKNOWN_CURRENCY`
|
||||
- any other `success: false` envelope → `UPSTREAM_ERROR` carrying
|
||||
`upstreamMessage`
|
||||
- **No auto-register.** The package exports the class; callers wire it via
|
||||
`new FixerProvider({ apiKey })` or
|
||||
`registry.register(new FixerProvider({ apiKey }))` per the slim-core
|
||||
design. There is no top-level side effect.
|
||||
|
||||
### KEPT (unchanged from slim core)
|
||||
|
||||
- The `CurrencyProvider` contract, `RateFetchOptions`, `RateFetchResult`
|
||||
remain owned by `@suaveplan/currency`.
|
||||
- `CURRENCY_PROVIDER_CODES`, `CurrencyProviderError`, `httpRequest`,
|
||||
`composeAbortSignal`, and `ApiKeyConfig` remain owned by
|
||||
`@suaveplan/currency-providers`.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This package is a migration of the upstream `FixerProvider` previously
|
||||
bundled in `@suaveplan/currency-providers`. The source was carved out by the
|
||||
2026-05-17 slim refactor and is reconstituted here against the slim-core
|
||||
contracts.
|
||||
|
||||
| Component | Source |
|
||||
| --- | --- |
|
||||
| `FixerProvider` class skeleton | `../suaveplan/packages/finance/currency-providers/src/fiat/fixer.ts` L1–L67 |
|
||||
| README quick-start example | upstream `fixer.md` L17–L37 |
|
||||
| Free-tier EUR-only constraint | upstream `fixer.md` L52–L54 + Fixer.io API docs error code `105` |
|
||||
| Upstream JSON envelope shape (`success` / `error.code` / `error.info` / `base` / `rates` / `timestamp`) | upstream `fixer.ts` L47–L53 + Fixer.io public docs |
|
||||
| HTTP / abort-signal / non-2xx mapping | `@suaveplan/currency-providers/http/http` (slim core) |
|
||||
| Error code surface | `@suaveplan/currency-providers/error-codes/error-codes` (slim core) |
|
||||
|
||||
The genesis-native upgrades over the upstream skeleton:
|
||||
|
||||
- Replaces the bare `throw new Error(...)` in upstream with
|
||||
`CurrencyProviderError` carrying stable codes from the slim core's registry
|
||||
(upstream `fixer.ts` L42 / L57 → `MISSING_CONFIG` / `PARSE_ERROR` /
|
||||
`UNSUPPORTED_BASE` / `UNKNOWN_CURRENCY` / `UPSTREAM_ERROR`).
|
||||
- Routes HTTP through the slim core's shared `httpRequest` so caller-supplied
|
||||
`AbortSignal` from `RateFetchOptions.signal` is honoured (upstream version
|
||||
did not).
|
||||
- Validates `apiKey` at construction (upstream silently accepted an empty
|
||||
string and let the upstream API reject it on the wire).
|
||||
- Adds the `FetchLike` injection seam from `@suaveplan/types` (upstream used
|
||||
the bare `globalThis.fetch`).
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `name`/`supportsFiat`/`supportsCrypto` identity flags | ✅ | ✅ | |
|
||||
| `ApiKeyConfig` constructor (apiKey + optional baseUrl) | ✅ | ✅ | Inherited from slim core. |
|
||||
| Default URL `https://data.fixer.io/api` | ✅ | ✅ | |
|
||||
| `fetchRates({ base, targets })` query construction | ✅ | ✅ | `access_key`, `base`, `symbols`. |
|
||||
| Default base `EUR` when none supplied | ✅ | ✅ | |
|
||||
| `success: false` envelope detection | ✅ | ⬆️ | Now mapped to typed codes via upstream `error.code` discriminant instead of a bare `throw new Error()`. |
|
||||
| Non-2xx → typed error | ✅ | ⬆️ | Routed through slim core's `httpRequest`; raises `HTTP_ERROR` with populated `status` + `providerName`. |
|
||||
| Timestamp seconds → ms conversion | ✅ | ✅ | `data.timestamp * 1000`. |
|
||||
| Symbol filter via `targets` | ✅ | ✅ | |
|
||||
| `apiKey` non-empty validation at construction | 🚫 | ⬆️ | New: raises `MISSING_CONFIG` instead of letting the API reject on the wire. |
|
||||
| `AbortSignal` propagation via `RateFetchOptions.signal` | 🚫 | ⬆️ | New: caller signal composed by slim-core `httpRequest`. |
|
||||
| `FetchLike` injection seam | 🚫 | ⬆️ | New: optional `config.fetch` honoured for testability and proxying. |
|
||||
|
||||
No upstream capability is dropped. Every 🚫 row is an ADDITION on top of upstream.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/currency-provider-fixer` to enumerate.
|
||||
|
||||
- No current dependents (package is new). The slim core
|
||||
`@suaveplan/currency-providers` is depended on by this package, not the
|
||||
reverse — no consumer rewrite is required.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Other sibling providers (ECB, OXR, Binance, CoinGecko, CMC, CryptoCompare,
|
||||
Kraken, Virtual) — each ships under its own OpenSpec change per AGENTS.md
|
||||
§0c.
|
||||
- Historical rates (`/{date}` endpoint), time-series (`/timeseries`),
|
||||
fluctuation (`/fluctuation`), conversion (`/convert`) endpoints — the
|
||||
`CurrencyProvider` contract today exposes only `fetchRates`; the additional
|
||||
endpoints will land once the contract is extended (carried over from the
|
||||
2026-05-17 archive's `## Out of scope`).
|
||||
- Rate-limit / retry / aggregator wrappers — same deferral as the slim core.
|
||||
@@ -0,0 +1,299 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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
|
||||
`"<unknown>"` 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 === "<unknown>"`
|
||||
|
||||
**Verified by:** `src/fixer/fixer.test.ts::"maps success:false with no error field to UPSTREAM_ERROR with <unknown>"`
|
||||
|
||||
---
|
||||
|
||||
### 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 `"<html>"` 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"`
|
||||
@@ -0,0 +1,78 @@
|
||||
# Tasks — add-currency-provider-fixer
|
||||
|
||||
## 1. Package scaffold
|
||||
|
||||
- [x] `packages/finance/currency-provider-fixer/package.json` — `0.1.0`,
|
||||
`sideEffects: false`, deps on `@suaveplan/currency` /
|
||||
`@suaveplan/currency-providers` / `@suaveplan/types` / `tslib`, devDeps via
|
||||
catalog refs.
|
||||
- [x] `tsconfig.json` + `tsconfig.lib.json` extending the repo base.
|
||||
- [x] `vite.config.ts` with `preserveModules` and externals matching deps.
|
||||
- [x] `vitest.config.ts` with `pool: "forks"`, `setupFiles` polyfill stub,
|
||||
`provider: "istanbul"`, thresholds `100/100/100/100`.
|
||||
- [x] `tests/setup/polyfills.ts` empty universal-tier stub.
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] `src/fixer/fixer.ts` — `FixerProvider implements CurrencyProvider`:
|
||||
- constructor takes `ApiKeyConfig`, raises `MISSING_CONFIG` on empty
|
||||
`apiKey`.
|
||||
- resolves `baseUrl` (default `https://data.fixer.io/api`) and `fetch`
|
||||
(default `globalThis.fetch`).
|
||||
- `fetchRates(options?)` builds `URLSearchParams` (`access_key`, `base`,
|
||||
optional `symbols`), calls slim-core `httpRequest`, parses JSON,
|
||||
discriminates envelope, returns canonical `RateFetchResult` or raises
|
||||
typed error.
|
||||
- [x] `src/fixer/index.ts` — explicit named barrel for the feature module
|
||||
(`FixerProvider`).
|
||||
- [x] `src/index.ts` — explicit named barrel re-exporting `FixerProvider`.
|
||||
|
||||
## 3. Docs
|
||||
|
||||
- [x] `src/fixer/fixer.md` — Purpose / Features / Usage Basic / Usage
|
||||
Advanced / API Reference / Implementation Notes (≥200 words excluding
|
||||
code blocks).
|
||||
- [x] `README.md` at package root with Install / Quick start / Features /
|
||||
Modules / Status.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [x] `src/fixer/fixer.test.ts` covers every requirement in
|
||||
`specs/currency-provider-fixer/spec.md`. All tests import from
|
||||
`@suaveplan/testing/runner`; every `it(...)` has `{ timeout: 5000 }`. No
|
||||
mocks — every fetch is a recording `FetchLike` closure.
|
||||
|
||||
## 5. Pre-ship gates (§13)
|
||||
|
||||
- [x] Spec reconciliation (step 0): every `### Requirement` and every
|
||||
`**Verified by:**` matches a test that exists in `src/fixer/fixer.test.ts`.
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-fixer` green.
|
||||
- [x] `bun run check` from package dir green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/currency-provider-fixer`
|
||||
green at 100/100/100/100.
|
||||
- [x] `bunx turbo build --filter=@suaveplan/currency-provider-fixer` green.
|
||||
- [x] Banned-pattern grep zero (TODO / FIXME / @deprecated / ts-ignore /
|
||||
ts-expect-error / .skip / .only / `console.log`).
|
||||
- [x] Barrel-shape grep zero (no `export *` in `<feature>/index.ts`).
|
||||
- [x] Test-runner-import grep zero (no direct `from "vitest"` / `from "bun:test"`).
|
||||
- [x] Missing `.md` grep zero.
|
||||
- [x] `README.md` present.
|
||||
- [x] No bare versions in `package.json` (every dep uses `catalog:` or
|
||||
`workspace:^`).
|
||||
- [x] Subpath-export count matches feature count (1).
|
||||
- [x] Security grep zero (no hardcoded keys / tokens / passwords).
|
||||
|
||||
## 6. Root-doc refresh (§13b)
|
||||
|
||||
- [x] `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`.
|
||||
- [x] Update `STATUS.md` (add `currency-provider-fixer` row under
|
||||
Completed).
|
||||
- [x] Update `SUMMARY.md` (add per-package paragraph).
|
||||
- [x] `bunx markdownlint-cli2 --fix` across touched files.
|
||||
|
||||
## 7. Archive (§14)
|
||||
|
||||
- [x] Fold `specs/currency-provider-fixer/spec.md` into
|
||||
`openspec/specs/currency-provider-fixer/spec.md`.
|
||||
- [x] Move `openspec/changes/add-currency-provider-fixer/` to
|
||||
`openspec/archive/2026-05-17-add-currency-provider-fixer/`.
|
||||
Reference in New Issue
Block a user