# Add @suaveplan/currency-provider-virtual **Package**: `@suaveplan/currency-provider-virtual` **Status**: PROPOSED **Type**: new package (sibling of `@suaveplan/currency-providers` slim core) - **Design**: ./design.md - **Spec delta**: ./specs/currency-provider-virtual/spec.md ## Why The 2026-05-17 `slim-currency-providers` refactor split the original 9-provider bundle into a slim core (`@suaveplan/currency-providers`) plus nine sibling `@suaveplan/currency-provider-` packages. Of the nine, eight wrap remote HTTP APIs (ECB, Fixer, OXR, Binance, CoinGecko, CoinMarketCap, CryptoCompare, Kraken); the ninth — **virtual** — serves operator-defined virtual / game currencies whose rates are programmable in memory and never round-trip a network. This change lands the virtual sibling. It is the only sibling that does NOT consume `httpRequest` / `FetchLike` from the slim core (it has no upstream to fetch) and that DOES set `supportsVirtual: true` on the `CurrencyProvider` contract. ## What changes ### ADDED - New package `@suaveplan/currency-provider-virtual` at `packages/finance/currency-provider-virtual/` (universal-tier, `0.1.0`). - `VirtualCurrencyProvider` — implements `CurrencyProvider` from `@suaveplan/currency`. Constructor takes an initial rate table and an optional `Clock` (default `Date.now`) used for `fetchRates` timestamps. No HTTP, no API key, no `FetchLike`. - Programmable rate-table API: `setRate(from, to, rate)`, `removeRate(from, to): boolean`, `clear()`, plus the read helpers `hasRate(from, to)`, `getRate(from, to)`, `listCurrencies(): readonly string[]`. - `fetchRates(options?)` — returns every rate denominated against the requested `base` (default the provider's `defaultBase`), filtered by the optional `targets` set, with `timestamp` derived from the injected `Clock`. - Raises `CurrencyProviderError(UNKNOWN_CURRENCY)` (registered in `@suaveplan/currency-providers`) when `fetchRates` is asked for a base that is not present in the rate table — there is no triangulation, the registry can be empty. ### KEPT (inherited) - `CurrencyProvider` contract from `@suaveplan/currency` (unchanged). - `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` from `@suaveplan/currency-providers` (already present in the 8-code slim-core registry). - Universal-tier purity (zero browser / Node APIs in `src/`), explicit named barrels, `@suaveplan/testing/runner` imports, 100/100/100/100 istanbul coverage. ## Source mapping The upstream reference shape lived in `../suaveplan/packages/finance/currency-providers/src/virtual/virtual.ts` (pre-slim, now removed). That upstream shape coupled the provider to `@suaveplan/money` (`registerCurrency`) and stored rates as a record of `VirtualCurrencyConfig { rate, name, symbol, exponent?, issuerId? }` keyed by currency code, assuming a single shared base. | Capability | Upstream | Migrated | Notes | |---|---|---|---| | Programmable per-currency rate table | ✅ | ✅ | Surface generalised — `setRate(from, to, rate)` lets callers store arbitrary directed pairs (game internal rates, partial graphs) rather than only "one base, many targets". | | `fetchRates({ base, targets })` | ✅ | ✅ | | | `supportsVirtual: true` flag | ✅ | ✅ | | | Triangulation from any virtual base | ✅ | 🚫 | Scoped out: the slim-core / `CurrencyConverter` already handles triangulation via the rate cache; duplicating it here would split the algorithm across two packages. Callers that need triangulated rates either pre-populate both directions with `setRate` or wire the provider through `CurrencyConverter` which triangulates from the cached `RateFetchResult`. | | Auto-registering currencies via `@suaveplan/money` `registerCurrency` | ✅ | 🚫 | Scoped out: the slim refactor explicitly dropped the `@suaveplan/money` dep so the slim core stays universal and zero-side-effect. Callers that want money-registry integration call `registerCurrency` themselves before constructing the provider — keeps this package side-effect-free per `sideEffects: false`. | | `Clock`-injected `timestamp` for deterministic tests | ⬆️ | ⬆️ | Enhanced: upstream hard-coded `Date.now()`. We inject a `Clock` so `FakeClock`-driven tests get deterministic timestamps without monkey-patching `Date`. | | `UNKNOWN_CURRENCY` typed error | ⬆️ | ⬆️ | Enhanced: upstream threw plain `Error("VirtualCurrencyProvider: no rates for base UNKNOWN")`. We throw `CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` with `providerName` populated. | | `defineCurrencyProvider`-friendly | ⬆️ | ⬆️ | Enhanced: the provider exposes a stable class with the programmable methods AND can be wrapped via `defineCurrencyProvider` if a consumer prefers the object-literal idiom. | The enterprise audit reference is `../suaveplan/enterprise/currency-providers.md`; the virtual provider is one row in that audit and carried no specific gaps beyond the registry-typed-error gap, which is closed here. ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual` to enumerate. - No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/currency-provider-virtual`). The package is a leaf sibling — the future commerce / gaming stack downstream will consume it directly, but those packages are still pending migration. ## Out of scope - Triangulation across the rate table (see Parity Checklist row). - `@suaveplan/money` `registerCurrency` integration (see Parity Checklist row). - Persistence — the rate table is purely in-memory by design (this provider's whole purpose). Callers that need persistence layer a storage adapter on top and reload the table at construction. - `AbortSignal` plumbing on `fetchRates` — the operation is synchronous over an in-memory `Map`; an injected `signal` would have nothing to abort. The optional `signal` from `RateFetchOptions` is accepted by the type but unused.