# Capability Spec: currency-provider-virtual **Package**: `@suaveplan/currency-provider-virtual` **Status**: SHIPPED (2026-05-17 — `add-currency-provider-virtual`) > Folded from > `openspec/changes/add-currency-provider-virtual/specs/currency-provider-virtual/spec.md` > on 2026-05-17. Every requirement carries its original > `**Verified by:**` line citing the test that pins each scenario. ## Requirements ### Requirement: VirtualCurrencyProvider implements CurrencyProvider with virtual support The package SHALL export a `VirtualCurrencyProvider` class that implements the `CurrencyProvider` contract from `@suaveplan/currency`. The provider SHALL expose `name === "virtual-currency"`, `supportsCrypto === false`, `supportsFiat === false`, and `supportsVirtual === true`. The provider SHALL NOT consume `FetchLike`, SHALL NOT issue HTTP requests, and SHALL NOT depend on `@suaveplan/money` — it is a pure in-memory rate source. #### Scenario: surfaces the expected identity flags - **WHEN** a `VirtualCurrencyProvider` is constructed - **THEN** `provider.name === "virtual-currency"`, `provider.supportsCrypto === false`, `provider.supportsFiat === false`, `provider.supportsVirtual === true` #### Scenario: package has no FetchLike consumption - **WHEN** `grep -rn "FetchLike\|globalThis.fetch\|fetch(" src/` is run - **THEN** the output contains zero matches (no HTTP code paths exist) **Verified by:** `src/provider/provider.test.ts::"surfaces the expected identity flags"` AND `src/provider/provider.test.ts::"has no FetchLike or fetch references in production source"` --- ### Requirement: Programmable rate-table API The provider SHALL expose a programmable rate table via `setRate(from, to, rate)`, `removeRate(from, to): boolean`, `clear(): void`, `hasRate(from, to): boolean`, `getRate(from, to): number | undefined`, and `listCurrencies(): readonly string[]`. `setRate` SHALL be upsert — calling it twice for the same `(from, to)` pair replaces the previous rate without throwing. `removeRate` SHALL return `true` when a pair was removed and `false` otherwise, and SHALL NOT throw on unknown pairs. `clear` SHALL empty the entire rate table. `listCurrencies` SHALL return a sorted snapshot (lexicographic) of every currency that appears as a `from` in the table. #### Scenario: setRate upserts rates - **WHEN** `setRate("USD", "GOLD", 100)` is called and then `setRate("USD", "GOLD", 200)` is called - **THEN** `getRate("USD", "GOLD") === 200` (the second call replaced the first without throwing) #### Scenario: removeRate is idempotent - **WHEN** `setRate("USD", "GOLD", 100)` is followed by two calls to `removeRate("USD", "GOLD")` - **THEN** the first call returns `true`, the second returns `false`, and `hasRate("USD", "GOLD") === false` #### Scenario: clear empties the table - **WHEN** several rates are set and `clear()` is called - **THEN** `listCurrencies()` returns `[]` and `hasRate(...)` returns `false` for every previously-known pair #### Scenario: hasRate and getRate report pair state - **WHEN** `setRate("USD", "GOLD", 100)` is called - **THEN** `hasRate("USD", "GOLD") === true`, `hasRate("USD", "GEMS") === false`, `getRate("USD", "GOLD") === 100`, `getRate("USD", "MISSING") === undefined` #### Scenario: listCurrencies returns a sorted snapshot - **WHEN** rates are set with `from` values `"USD"`, `"GOLD"`, `"EUR"` in any order - **THEN** `listCurrencies()` returns `["EUR", "GOLD", "USD"]` (lexicographic order, regardless of insertion order) **Verified by:** `src/provider/provider.test.ts::"setRate upserts an existing pair without throwing"` AND `src/provider/provider.test.ts::"removeRate returns true on first call and false on the second"` AND `src/provider/provider.test.ts::"clear empties the rate table"` AND `src/provider/provider.test.ts::"hasRate and getRate report pair state correctly"` AND `src/provider/provider.test.ts::"listCurrencies returns a lexicographic snapshot"` --- ### Requirement: fetchRates returns the rates for the requested base The provider SHALL implement `fetchRates(options?: RateFetchOptions): Promise`. The returned `result.base` SHALL be `options?.base` when provided, otherwise the provider's configured `defaultBase`. The returned `result.rates` SHALL contain exactly the rates stored under that base in the rate table, optionally filtered by `options?.targets` (an inclusion list — only matching targets appear). `result.timestamp` SHALL be the value returned by the injected `Clock` at the moment of the call. The provider SHALL NOT triangulate — it returns only the rates explicitly stored under the requested base. #### Scenario: returns all rates for the default base - **WHEN** the provider is constructed with `defaultBase: "USD"` and rates `setRate("USD", "GOLD", 100)`, `setRate("USD", "GEMS", 500)`, and `fetchRates()` is called - **THEN** the promise resolves to a result with `base: "USD"`, `rates: { GOLD: 100, GEMS: 500 }` #### Scenario: filters by targets - **WHEN** the provider holds rates for `GOLD`, `GEMS`, and `SILVER` under `"USD"`, and `fetchRates({ base: "USD", targets: ["GOLD"] })` is called - **THEN** the resolved `rates` contains exactly `{ GOLD: 100 }` (the other targets are absent) #### Scenario: honours an explicit non-default base - **WHEN** `fetchRates({ base: "GOLD" })` is called against a provider that has rates set with `setRate("GOLD", "GEMS", 5)` and `setRate("GOLD", "USD", 0.01)` - **THEN** the resolved `result.base === "GOLD"` and the resolved `rates` contains exactly `{ GEMS: 5, USD: 0.01 }` #### Scenario: returns an empty rate map when the base has no targets - **WHEN** `setRate("USD", "GOLD", 100)` is called and `fetchRates({ base: "USD", targets: ["MISSING"] })` is called - **THEN** the resolved `rates` is `{}` (no `targets` matched, the call does not throw — `MISSING` simply isn't in the inclusion list) **Verified by:** `src/provider/provider.test.ts::"returns all rates for the default base"` AND `src/provider/provider.test.ts::"filters returned rates by the targets inclusion list"` AND `src/provider/provider.test.ts::"honours an explicit non-default base"` AND `src/provider/provider.test.ts::"returns an empty rate map when the targets filter excludes every stored target"` --- ### Requirement: UNKNOWN_CURRENCY raised for missing base The provider SHALL throw `CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when `fetchRates({ base })` is called for a `base` that is not present in the rate table (and the table is therefore unable to answer for that base). The thrown error SHALL carry `providerName === "virtual-currency"`. The provider SHALL NOT throw a raw `Error`, and SHALL NOT triangulate to satisfy an unknown base — the registry can be empty, and an unknown base is a definitive "no answer" path. #### Scenario: throws CurrencyProviderError(UNKNOWN_CURRENCY) when base is missing - **WHEN** a fresh `VirtualCurrencyProvider` (empty table) has `fetchRates({ base: "NOPE" })` called on it - **THEN** the promise rejects with a `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and whose `providerName === "virtual-currency"` #### Scenario: throws UNKNOWN_CURRENCY when the default base is empty - **WHEN** the provider was constructed with `defaultBase: "USD"` and no `USD`-rooted rates have ever been set, and `fetchRates()` (no options) is called - **THEN** the promise rejects with `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` **Verified by:** `src/provider/provider.test.ts::"throws CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is missing"` AND `src/provider/provider.test.ts::"throws UNKNOWN_CURRENCY when the default base has no stored rates"` --- ### Requirement: Clock injection for deterministic timestamps The provider constructor SHALL accept an optional `clock: Clock` (`@suaveplan/types`'s `Clock = () => number`) and SHALL use it to populate `RateFetchResult.timestamp` on every `fetchRates` call. When no `clock` is provided the provider SHALL default to `() => Date.now()`. The provider SHALL call the clock at the moment of the `fetchRates` invocation, not at construction. #### Scenario: FakeClock-driven timestamps are deterministic across calls - **WHEN** the provider is constructed with `clock: () => fakeClock.now()` where `fakeClock` is a `FakeClock({ startTime: 1_700_000_000_000 })`, and `fetchRates()` is called twice with `fakeClock.advance(1000)` between them - **THEN** the first result's `timestamp === 1_700_000_000_000` and the second result's `timestamp === 1_700_000_001_000` #### Scenario: default clock falls back to Date.now - **WHEN** the provider is constructed without a `clock` and `fetchRates()` is called - **THEN** the returned `timestamp` is a finite number whose value is within ±1000 ms of `Date.now()` measured immediately around the call **Verified by:** `src/provider/provider.test.ts::"FakeClock-driven timestamps are deterministic across calls"` AND `src/provider/provider.test.ts::"defaults to Date.now when no clock is injected"`