Files
suaveplan/packages/money/finance/currency/src/converter/converter.ts
2026-05-27 23:47:53 +01:00

349 lines
11 KiB
TypeScript

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 = "<identity>";
const PROVIDER_CACHE = "<cache>";
const PROVIDER_VIRTUAL = "<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<string, number> = new Map();
/**
* Origin tag per `<from>|<to>` cache pair — either a real provider name,
* `"<virtual>"`, or `"<cache>"` (after manual setRate). Read by
* `readFromCache` to attribute hits to their source.
*/
private readonly rateOrigin: Map<string, string> = new Map();
/** Set transiently by `fetchAndCache` so the immediate post-fetch read
* surfaces the actual provider name (not `"<cache>"`). */
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<Record<string, VirtualCurrencyConfig>>
): 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<string, number> = 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<Record<string, number>> {
return Object.fromEntries(this.virtualRates);
}
public async convert(
money: MoneyLike,
to: CurrencyCode,
options?: ConvertOptions
): Promise<ConversionResult> {
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<number> {
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<number> {
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<ResolveOutcome> {
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<void> {
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<string, number> = { ...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;
}
}