# Design — @suaveplan/currency ## Architecture `@suaveplan/currency` is a universal-tier orchestrator. It owns no rate data; it owns the contract every rate source implements (`CurrencyProvider`), the in-memory TTL cache that flattens repeated calls into a single provider hit (`RateCache`), and the converter that combines them with cross-rate triangulation and a fallback chain (`CurrencyConverter`). Every public boundary is Zod-validated; every failure path raises a typed `CurrencyError`. The package runs unchanged under Node, Bun, browsers, and Web Workers. ```text ┌──────────────────────────┐ │ CurrencyConverter │ │ │ convert(money) ───▶│ validate options (Zod) │ │ walk fallback chain │ │ fetch → cache → seed │ │ triangulate via cache │ │ enrich result │ │ honour AbortSignal │ └──┬────────────────┬──────┘ │ │ ▼ ▼ ┌─────────────┐ ┌──────────────────┐ │ RateCache │ │ CurrencyProvider │ │ (TTL) │ │ (primary + │ │ prune / │ │ fallbacks) │ │ triangulate│ └────────┬─────────┘ └─────────────┘ │ ├─ InMemoryProvider ├─ createStaticProvider(...) └─ (future external providers) ``` ## File layout Every feature lives at `src//.ts` per the no-prefix rule; every feature has a sibling `.md`, `.test.ts`, and an `index.ts` barrel using explicit named re-exports. ```text packages/finance/currency/ ├── package.json ├── README.md ├── tsconfig.json ├── tsconfig.lib.json ├── vite.config.ts ├── vitest.config.ts ├── tests/ │ └── setup/ │ └── polyfills.ts └── src/ ├── index.ts # root barrel — namespace + named re-exports ├── types/ │ ├── index.ts │ ├── common.ts # ExchangeRate, RateFetchOptions, RateFetchResult, │ │ # CurrencyProvider, VirtualCurrencyConfig, │ │ # ConverterConfig, ConvertOptions, ConversionResult │ └── common.md ├── error-codes/ │ ├── index.ts │ ├── error-codes.ts # CURRENCY_CODES + CurrencyError │ ├── error-codes.md │ └── error-codes.test.ts ├── schemas/ │ ├── index.ts │ ├── schemas.ts # Zod schemas + parse helpers raising CurrencyError │ ├── schemas.md │ └── schemas.test.ts ├── rate-cache/ │ ├── index.ts │ ├── rate-cache.ts # RateCache class │ ├── rate-cache.md │ └── rate-cache.test.ts ├── provider/ │ ├── index.ts │ ├── provider.ts # InMemoryProvider + createStaticProvider │ ├── provider.md │ └── provider.test.ts └── converter/ ├── index.ts ├── converter.ts # CurrencyConverter class ├── converter.md └── converter.test.ts ``` ## Public surface Re-exported from the package root (`@suaveplan/currency`): - Classes: `CurrencyConverter`, `RateCache`, `InMemoryProvider`, `CurrencyError`. - Factories: `createStaticProvider`. - Constants: `CURRENCY_CODES`. - Schemas: `ConverterConfigSchema`, `ConvertOptionsSchema`, `VirtualCurrencyConfigSchema`, `RateFetchOptionsSchema`. - Types: `ConversionResult`, `ConverterConfig`, `ConvertOptions`, `CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`, `VirtualCurrencyConfig`, `CurrencyErrorCode`, `CurrencyErrorOptions`. Every public module is also reachable via a subpath export (`@suaveplan/currency/converter/converter` etc.) so consumers can tree-shake. ## Behavioural notes ### Triangulation The migration preserves the upstream single-hop-via-cache triangulation. When `getRate(from, to)` is called and the cache has neither a direct nor an inverse entry, `RateCache.getRate` walks every cached base entry looking for one whose `rates` map contains both `from` and `to`. If found, the converter records the intermediary base in `ConversionResult.triangulatedThrough = [base]`. If not found, the converter fetches fresh data from the primary provider against `from`; if that fails, it falls back to the chain; if still nothing produces a direct rate, it raises `CurrencyError({ code: CURRENCY.RATE_UNAVAILABLE })`. Multi-hop BFS is explicitly out of scope (see proposal `## Out of scope`). The `triangulatedThrough` field returns `string[]` to leave room for the future multi-hop change without breaking the type. ### Fallback chain & cancellation Providers are tried in order: primary then `fallbackProviders`. The first one whose `fetchRates` resolves wins; its result is set in the cache and virtual rates are re-seeded. If `options.signal` is supplied and aborts between provider attempts, the chain stops immediately and the converter raises `CurrencyError({ code: CURRENCY.ABORTED })`. If every provider fails the chain raises `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE })` whose `cause` carries the last underlying error and whose `providerName` discriminant is the last attempted provider. ### Virtual currency seeding `registerVirtualCurrencies` is called from the constructor when `config.virtualCurrencies` is provided. Each entry is registered through `@suaveplan/money`'s `registerCurrency` (so `Money.fromMajor("100", "GOLD")` works for callers), and its rate against `defaultBase` is added to the `virtualRates` private map. `seedVirtualRates` writes those rates into the cache entry keyed by `defaultBase`. After every successful provider fetch the seeding runs again so virtual rates survive provider refreshes. `setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` mutate the private map and call `seedVirtualRates`; the two are aliases — the audit mentions both names. ### `ConversionResult.providerName` - `""` — the rate was served from the cache (no provider call). - `""` — the rate was seeded by the virtual-currency configuration. - `""` — `from === to` short-circuit. - otherwise — the `name` of the provider whose `fetchRates` produced the entry the cache resolved against. ### Zod boundary `ConverterConfigSchema` validates the constructor argument. `ConvertOptionsSchema`, `RateFetchOptionsSchema`, and `VirtualCurrencyConfigSchema` validate every other public entry point. Failures raise `CurrencyError` with the appropriate code (`INVALID_CONFIG` for the constructor; `INVALID_OPTIONS` for everything else) and the Zod issue list on `error.context.issues`. ### Strict typing `CurrencyProvider` and every option bundle is a top-level named interface (per AGENTS.md §11 — no inline shapes). `CurrencyConverter` is generic-free; future markup-policy work can graft generics on without breaking the current surface. ## Dependencies - `@suaveplan/money` (`workspace:^`) — currency metadata, `MoneyLike`, `minorUnitMultiplier`, `registerCurrency`, `getCurrencyOrThrow`. - `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`. - `zod` (`catalog:forms`) — boundary validation. - `tslib` (`catalog:build`). ## Test plan - Every requirement in the spec delta has a `**Verified by:**` line naming concrete `it(...)` titles in the test files above. - Real implementations only — no mocks. AbortSignal is exercised via `AbortController` + `queueMicrotask` to land a real abort between fallback attempts. - Coverage gate: `provider: "istanbul"`, all four axes at 100%.