Initial Commit
This commit is contained in:
348
packages/money/finance/currency/src/converter/converter.ts
Normal file
348
packages/money/finance/currency/src/converter/converter.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user