6.9 KiB
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
CurrencyProviderchain (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-constructionsetVirtualRate/updateVirtualRatemutators (aliases) andgetVirtualRatesinspector. Re-seeded after every provider refresh so virtual rates survive. AbortSignalhonoured 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("<identity>"/"<cache>"/"<virtual>"/ actual provider name) andtriangulatedThrough(the intermediate base for triangulated rates, otherwisenull). - Management surface:
setRate,clearCache,getCache.
Usage: Basic Example
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:
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<ConversionResult>
Converts money to to. Returns an enriched ConversionResult. Raises:
CURRENCY.INVALID_OPTIONS— badoptions.CURRENCY.ABORTED—options.signalaborted 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<number>
Numeric counterpart of convert; same errors apply.
getRate(from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>
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<Record<string, number>>
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
(<identity> / <cache> / <virtual> / 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 "<cache>".
seedVirtualRates runs after every successful provider refresh so virtual
rates survive new fetches. It writes virtual entries with origin
"<virtual>"; the lookup code therefore reports "<virtual>" 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.