import type { CurrencyCode, MoneyLike } from "@suaveplan/money"; import { getCurrencyOrThrow, minorUnitMultiplier, registerCurrency, } from "@suaveplan/money"; import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js"; import { RateCache } from "../rate-cache/rate-cache.js"; import { parseConverterConfig, parseConvertOptions, } from "../schemas/schemas.js"; import type { ConversionResult, ConverterConfig, ConvertOptions, CurrencyProvider, VirtualCurrencyConfig, } from "../types/common.js"; const PROVIDER_IDENTITY = ""; const PROVIDER_CACHE = ""; const PROVIDER_VIRTUAL = ""; interface ResolveOutcome { readonly rate: number; readonly providerName: string; readonly triangulatedThrough: readonly string[] | null; readonly timestamp: number; } function ensureNotAborted(signal: AbortSignal | undefined): void { if (signal?.aborted) { throw new CurrencyError( CURRENCY_CODES.ABORTED, "Conversion aborted before completion" ); } } function pairKey(from: CurrencyCode, to: CurrencyCode): string { return `${from}|${to}`; } /** * Currency converter with pluggable providers, rate caching, and automatic * cross-rate triangulation. See `converter.md` for the full behavioural * contract. */ export class CurrencyConverter { private readonly provider: CurrencyProvider; private readonly fallbacks: readonly CurrencyProvider[]; private readonly cache: RateCache; private readonly defaultBase: CurrencyCode; private readonly virtualRates: Map = new Map(); /** * Origin tag per `|` cache pair — either a real provider name, * `""`, or `""` (after manual setRate). Read by * `readFromCache` to attribute hits to their source. */ private readonly rateOrigin: Map = new Map(); /** Set transiently by `fetchAndCache` so the immediate post-fetch read * surfaces the actual provider name (not `""`). */ private justFetchedFromProvider: string | undefined; public constructor(config: ConverterConfig) { const validated = parseConverterConfig(config); this.provider = validated.provider; this.fallbacks = validated.fallbackProviders ?? []; this.cache = new RateCache(validated.staleDurationMs ?? 3_600_000); this.defaultBase = validated.defaultBase ?? "USD"; if (validated.virtualCurrencies) { this.registerVirtualCurrencies(validated.virtualCurrencies); } } private registerVirtualCurrencies( currencies: Readonly> ): void { for (const [code, vc] of Object.entries(currencies)) { registerCurrency({ code, exponent: vc.exponent ?? 2, symbol: vc.symbol, name: vc.name, virtual: true, ...(vc.issuerId !== undefined ? { issuerId: vc.issuerId } : {}), }); this.virtualRates.set(code, vc.rate); } this.seedVirtualRates(); } private seedVirtualRates(): void { if (this.virtualRates.size === 0) return; const existing = this.cache.get(this.defaultBase); const rates: Record = existing ? { ...existing.rates } : {}; for (const [code, rate] of this.virtualRates) { rates[code] = rate; this.rateOrigin.set( pairKey(this.defaultBase, code), PROVIDER_VIRTUAL ); } const timestamp = existing?.timestamp ?? Date.now(); this.cache.set(this.defaultBase, { base: this.defaultBase, rates, timestamp, }); } public setVirtualRate(code: string, rate: number): void { this.virtualRates.set(code, rate); this.seedVirtualRates(); } public updateVirtualRate(code: string, rate: number): void { this.setVirtualRate(code, rate); } public getVirtualRates(): Readonly> { return Object.fromEntries(this.virtualRates); } public async convert( money: MoneyLike, to: CurrencyCode, options?: ConvertOptions ): Promise { const validated = options === undefined ? {} : parseConvertOptions(options); ensureNotAborted(validated.signal); if (money.currency === to) { return { amount: money.amount, rate: 1, inverseRate: 1, from: money.currency, to, rateAgeMs: 0, providerName: PROVIDER_IDENTITY, triangulatedThrough: null, }; } const outcome = await this.resolveRate(money.currency, to, validated); const fromInfo = getCurrencyOrThrow(money.currency); const toInfo = getCurrencyOrThrow(to); const majorAmount = Number(money.amount) / Number(minorUnitMultiplier(fromInfo.exponent)); const convertedMajor = majorAmount * outcome.rate; const convertedMinor = Math.round( convertedMajor * Number(minorUnitMultiplier(toInfo.exponent)) ); return { amount: BigInt(convertedMinor), rate: outcome.rate, inverseRate: 1 / outcome.rate, from: money.currency, to, rateAgeMs: Date.now() - outcome.timestamp, providerName: outcome.providerName, triangulatedThrough: outcome.triangulatedThrough, }; } public async convertAmount( amount: number, from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions ): Promise { const validated = options === undefined ? {} : parseConvertOptions(options); ensureNotAborted(validated.signal); if (from === to) return amount; const outcome = await this.resolveRate(from, to, validated); return amount * outcome.rate; } public async getRate( from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions ): Promise { const validated = options === undefined ? {} : parseConvertOptions(options); ensureNotAborted(validated.signal); if (from === to) return 1; const outcome = await this.resolveRate(from, to, validated); return outcome.rate; } private async resolveRate( from: CurrencyCode, to: CurrencyCode, options: ConvertOptions ): Promise { this.justFetchedFromProvider = undefined; if (!options.fresh) { const cached = this.readFromCache(from, to); if (cached) return cached; } await this.fetchAndCache(from, options.signal); const after = this.readFromCache(from, to); this.justFetchedFromProvider = undefined; if (after) return after; throw new CurrencyError( CURRENCY_CODES.RATE_UNAVAILABLE, `No rate available for ${from} → ${to} after provider refresh`, { from, to } ); } private readFromCache( from: CurrencyCode, to: CurrencyCode ): ResolveOutcome | undefined { const rate = this.cache.getRate(from, to); if (rate === undefined) return undefined; const through = this.cache.getTriangulationPath(from, to); // Determine which cache entry timestamp to surface. // `cache.getRate` returned a non-undefined value, so at least one of // `from`, `to`, or `through` must be cached; the corresponding // `getTimestamp` therefore returns a number. const baseForTimestamp = through ?? (this.cache.has(from) ? from : to); const timestamp = this.cache.getTimestamp(baseForTimestamp) as number; // Attribute the rate to its origin. const direct = this.rateOrigin.get(pairKey(from, to)); const inverse = this.rateOrigin.get(pairKey(to, from)); const tri = through ? this.rateOrigin.get(pairKey(through, to)) : undefined; const recorded = direct ?? inverse ?? tri; let providerName: string; if (recorded === PROVIDER_VIRTUAL) { providerName = PROVIDER_VIRTUAL; } else if (this.justFetchedFromProvider !== undefined) { providerName = this.justFetchedFromProvider; } else { providerName = PROVIDER_CACHE; } return { rate, providerName, triangulatedThrough: through ? [through] : null, timestamp, }; } private async fetchAndCache( base: CurrencyCode, signal: AbortSignal | undefined ): Promise { const providers: readonly CurrencyProvider[] = [ this.provider, ...this.fallbacks, ]; let lastError: unknown; let lastProviderName = this.provider.name; for (const provider of providers) { ensureNotAborted(signal); lastProviderName = provider.name; try { const result = await provider.fetchRates({ base, ...(signal !== undefined ? { signal } : {}), }); this.cache.set(result.base, result); for (const target of Object.keys(result.rates)) { this.rateOrigin.set( pairKey(result.base, target), provider.name ); } this.justFetchedFromProvider = provider.name; this.seedVirtualRates(); return; } catch (err) { if ( err instanceof CurrencyError && err.code === CURRENCY_CODES.ABORTED ) { throw err; } lastError = err; } } // After the loop above ran at least once and every attempt either // threw or this method already returned. `lastError` is therefore // always defined. const cause = lastError; const message = cause instanceof Error ? `All providers failed to fetch rates for ${base}: ${cause.message}` : `All providers failed to fetch rates for ${base}`; throw new CurrencyError(CURRENCY_CODES.PROVIDER_UNAVAILABLE, message, { from: base, providerName: lastProviderName, cause, }); } public setRate(from: CurrencyCode, to: CurrencyCode, rate: number): void { const existing = this.cache.get(from); if (existing) { const merged: Record = { ...existing.rates }; merged[to] = rate; this.cache.set(from, { base: from, rates: merged, timestamp: existing.timestamp, }); } else { this.cache.set(from, { base: from, rates: { [to]: rate }, timestamp: Date.now(), }); } this.rateOrigin.set(pairKey(from, to), PROVIDER_CACHE); } public clearCache(): void { this.cache.clear(); this.rateOrigin.clear(); } public getCache(): RateCache { return this.cache; } }