349 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|