Files
skills/docs/money/finance/currency/src/converter/converter.md
2026-05-29 08:20:56 +01:00

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 CurrencyProvider chain (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-construction setVirtualRate / updateVirtualRate mutators (aliases) and getVirtualRates inspector. Re-seeded after every provider refresh so virtual rates survive.
  • AbortSignal honoured 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) and triangulatedThrough (the intermediate base for triangulated rates, otherwise null).
  • 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 — bad options.
  • CURRENCY.ABORTED — options.signal aborted 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.