first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
# `converter` — `CurrencyConverter` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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:
```ts
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.