# `converter` — `CurrencyConverter` orchestrator _(no submodules)_ ## Purpose `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (plus an ordered fallback chain), a TTL-bounded `RateCache`, virtual-currency seeding, cross-rate triangulation, and `AbortSignal` cancellation into a single typed API that converts a `MoneyLike` value (or plain numeric amount) between any two currencies. Every public boundary is Zod-validated; every failure path raises a typed `CurrencyError`; every successful conversion is enriched with `providerName` and `triangulatedThrough` diagnostics so callers can observe which source answered and whether the rate is direct or derived. The converter does not perform I/O itself — it delegates to the provider chain — which keeps it universal. Reference providers ship in `./provider`; external providers (ECB, Fixer, CoinGecko, Binance, …) live in `@suaveplan/currency-providers`. The class is intentionally generic-free; the markup-policy / multi-tenant work flagged in the enterprise audit will land in a follow-up change. ## Features - Primary + ordered fallback `CurrencyProvider` chain (`config.fallbackProviders`). - TTL-bounded cache (`config.staleDurationMs`, default 1 hour). - Cross-rate triangulation via cached intermediate base (single-hop; multi-hop BFS deferred). - Identity short-circuit when `from === to`. - Virtual-currency seeding (`config.virtualCurrencies`) with post-construction `setVirtualRate` / `updateVirtualRate` mutators (aliases) and `getVirtualRates` inspector. Re-seeded after every provider refresh so virtual rates survive. - `AbortSignal` honoured on every public method and between fallback attempts. - Zod-validated constructor and call sites — failures raise `CurrencyError({ code: CURRENCY.INVALID_CONFIG | CURRENCY.INVALID_OPTIONS })`. - Enriched `ConversionResult` — `providerName` (`""` / `""` / `""` / actual provider name) and `triangulatedThrough` (the intermediate base for triangulated rates, otherwise `null`). - Management surface: `setRate`, `clearCache`, `getCache`. ## Usage: Basic Example ```ts import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency"; import { Money } from "@suaveplan/money"; const provider = new InMemoryProvider(); provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 }); const converter = new CurrencyConverter({ provider }); const result = await converter.convert( Money.fromMajor("100", "USD"), "EUR" ); // result.amount === 9200n, result.rate === 0.92, // result.providerName === "in-memory", result.triangulatedThrough === null ``` ## Usage: Advanced Example A multi-source converter with a fallback provider, virtual currency, and `AbortSignal` cancellation: ```ts import { CurrencyConverter, InMemoryProvider, createStaticProvider, CurrencyError, CURRENCY_CODES, } from "@suaveplan/currency"; import { Money } from "@suaveplan/money"; const primary = new InMemoryProvider(); primary.setRates("USD", { EUR: 0.5, JPY: 100 }); const fallback = createStaticProvider("static", { USD: { EUR: 0.51, JPY: 99 }, }); const converter = new CurrencyConverter({ provider: primary, fallbackProviders: [fallback], defaultBase: "USD", virtualCurrencies: { GOLD: { rate: 100, name: "Gold", symbol: "🪙" }, }, }); // Triangulated conversion via USD. const eurToJpy = await converter.convert(Money.fromMajor("10", "EUR"), "JPY"); // eurToJpy.triangulatedThrough === ["USD"], eurToJpy.rate === 200 // Virtual-currency conversion. const gold = await converter.convertAmount(1, "USD", "GOLD"); // gold === 100; converter.getVirtualRates() === { GOLD: 100 } // Cancellation. const ac = new AbortController(); queueMicrotask(() => ac.abort()); try { await converter.convert(Money.fromMajor("1", "USD"), "EUR", { signal: ac.signal, fresh: true, }); } catch (err) { if ( err instanceof CurrencyError && err.code === CURRENCY_CODES.ABORTED ) { console.warn("Aborted as expected"); } else { throw err; } } ``` ## API Reference ### `new CurrencyConverter(config: ConverterConfig)` Validates `config` through `ConverterConfigSchema`; raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. ### `convert(money: MoneyLike, to: CurrencyCode, options?: ConvertOptions): Promise` Converts `money` to `to`. Returns an enriched `ConversionResult`. Raises: - `CURRENCY.INVALID_OPTIONS` — bad `options`. - `CURRENCY.ABORTED` — `options.signal` aborted at call time or between fallback attempts. - `CURRENCY.PROVIDER_UNAVAILABLE` — every provider in the chain failed. - `CURRENCY.RATE_UNAVAILABLE` — no rate path exists even after refresh. ### `convertAmount(amount: number, from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise` Numeric counterpart of `convert`; same errors apply. ### `getRate(from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise` Returns the multiplicative rate for `from → to`. Same errors apply. ### `setVirtualRate(code: string, rate: number): void` / `updateVirtualRate(code: string, rate: number): void` Update a virtual currency's rate against `defaultBase` at runtime. Both names are aliases; the audit named `setVirtualRate`, and the upstream library named `updateVirtualRate`. ### `getVirtualRates(): Readonly>` Defensive copy of the in-memory virtual rate map. ### `setRate(from: CurrencyCode, to: CurrencyCode, rate: number): void` / `clearCache(): void` / `getCache(): RateCache` Direct cache management — useful for testing and overrides. ## Implementation Notes The converter stores a per-pair `rateOrigin` map so it can report `ConversionResult.providerName` correctly across the four sources (`` / `` / `` / actual provider). The `justFetchedFromProvider` field is set transiently inside `resolveRate` so the immediate read after a successful fetch surfaces the real provider name; subsequent calls hit the cache and report `""`. `seedVirtualRates` runs after every successful provider refresh so virtual rates survive new fetches. It writes virtual entries with origin `""`; the lookup code therefore reports `""` for any conversion that targets a virtual currency, even after later fetches refresh other rates in the same cache entry. The single-hop triangulation policy preserves upstream behaviour bit-for-bit; multi-hop BFS is deferred (see proposal `## Out of scope`). The `triangulatedThrough` field is typed as `readonly string[] | null` to leave the door open for future multi-hop work without a breaking type change. `Math.round` on the converted minor amount preserves the upstream rounding behaviour. The per-pair rounding-mode policy from the enterprise audit is deferred to a dedicated proposal because it is a behaviour change that affects every caller.