first commit
This commit is contained in:
185
docs/money/finance/currency/src/converter/converter.md
Normal file
185
docs/money/finance/currency/src/converter/converter.md
Normal 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.
|
||||
Reference in New Issue
Block a user