first commit
This commit is contained in:
105
docs/money/finance/currency/README.md
Normal file
105
docs/money/finance/currency/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @suaveplan/currency - Universal currency conversion engine — pluggable CurrencyProvider interface, TTL-bounded RateCache with direct / inverse / triangulated lookup, primary + fallback chain with AbortSignal cancellation, Zod-validated public boundary, typed CurrencyError hierarchy with stable CURRENCY.* code registry, virtual-currency seeding with post-construction setVirtualRate / updateVirtualRate mutators, enriched ConversionResult carrying providerName and triangulatedThrough diagnostics. Built on @suaveplan/money minor-unit bigint arithmetic.
|
||||
|
||||
Universal currency conversion engine — pluggable `CurrencyProvider`
|
||||
interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup,
|
||||
primary + fallback chain with `AbortSignal` cancellation, Zod-validated public
|
||||
boundary, typed `CurrencyError` hierarchy with stable `CURRENCY.*` code
|
||||
registry, virtual-currency seeding with post-construction
|
||||
`setVirtualRate` / `updateVirtualRate` mutators, and enriched `ConversionResult`
|
||||
carrying `providerName` and `triangulatedThrough` diagnostics. Built on
|
||||
`@suaveplan/money` minor-unit `bigint` arithmetic.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
|
||||
- [converter](./src/converter/converter.md) — `CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It combines a pluggable `CurrencyProvider` (pl…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/currency` raises only typed errors. This module owns the stable `CURRENCY_CODES` registry (built with `defi…
|
||||
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
|
||||
- [provider](./src/provider/provider.md) — The provider module ships two reference implementations of the `CurrencyProvider` contract: a mutable `InMemoryProvider…
|
||||
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
|
||||
- [rate-cache](./src/rate-cache/rate-cache.md) — `RateCache` is the converter's in-memory rate store. It folds every successful `fetchRates` answer into a single map ke…
|
||||
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
|
||||
- [schemas](./src/schemas/schemas.md) — The schemas module is the package's input-validation boundary. Every public options bundle that crosses into a `Currenc…
|
||||
- [types](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
|
||||
- [common](./src/types/common.md) — Single home for every shared type the `@suaveplan/currency` public surface exposes. The module defines the `CurrencyPro…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency @suaveplan/money
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```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 usd = Money.fromMajor("100", "USD");
|
||||
const result = await converter.convert(usd, "EUR");
|
||||
// result.amount === 9200n
|
||||
// result.rate === 0.92
|
||||
// result.providerName === "in-memory"
|
||||
// result.triangulatedThrough === null
|
||||
|
||||
const eur = await converter.convertAmount(250, "USD", "EUR");
|
||||
// eur === 230 (250 * 0.92)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Pluggable `CurrencyProvider` interface** for any exchange-rate source.
|
||||
- **In-memory `RateCache`** with configurable TTL eviction.
|
||||
- **Cross-rate triangulation** via cached intermediate base (single-hop).
|
||||
- **Inverse rate calculation** from cached data when only one direction is
|
||||
known.
|
||||
- **Provider fallback chain** for resilience — tries providers in order.
|
||||
- **`AbortSignal` cancellation** on `convert` / `convertAmount` / `getRate`,
|
||||
honoured between fallback attempts.
|
||||
- **Zod-validated boundary** on every public option bundle.
|
||||
- **Typed `CurrencyError` hierarchy** with stable `CURRENCY.*` codes
|
||||
(`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
|
||||
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
|
||||
`ABORTED`).
|
||||
- **Virtual-currency seeding** for game currencies, with
|
||||
`setVirtualRate` / `updateVirtualRate` post-construction mutation.
|
||||
- **Enriched `ConversionResult`** — `providerName` (`"<identity>"` / `"<cache>"`
|
||||
/ `"<virtual>"` / actual provider name) plus `triangulatedThrough` (chain of
|
||||
intermediate currencies for triangulated rates).
|
||||
- **Built-in `InMemoryProvider` + `createStaticProvider` factory** for tests
|
||||
and fixed-rate apps.
|
||||
- **Tree-shakeable subpath exports** per public module.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/converter/converter.md`](./src/converter/converter.md) —
|
||||
`CurrencyConverter` orchestrator (provider chain, cache, triangulation,
|
||||
virtual currency, AbortSignal).
|
||||
- [`src/rate-cache/rate-cache.md`](./src/rate-cache/rate-cache.md) —
|
||||
TTL-bounded `RateCache` with direct / inverse / triangulated lookup.
|
||||
- [`src/provider/provider.md`](./src/provider/provider.md) —
|
||||
`InMemoryProvider` + `createStaticProvider` reference implementations.
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
|
||||
`CURRENCY_CODES` registry + `CurrencyError` discriminated class.
|
||||
- [`src/schemas/schemas.md`](./src/schemas/schemas.md) — Zod schemas + parse
|
||||
helpers for every public option bundle.
|
||||
- [`src/types/common.md`](./src/types/common.md) — public type surface
|
||||
(`CurrencyProvider`, `ConversionResult`, `ConverterConfig`, …).
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` (pre-graduation). Migrated from
|
||||
`../suaveplan/packages/finance/currency/` with feature parity plus typed
|
||||
errors, Zod boundary, `AbortSignal` support, and enriched
|
||||
`ConversionResult`. See
|
||||
the design proposal for the migration
|
||||
record and the deferred-feature list.
|
||||
|
||||
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.
|
||||
105
docs/money/finance/currency/src/error-codes/error-codes.md
Normal file
105
docs/money/finance/currency/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `error-codes` — `CURRENCY_CODES` + `CurrencyError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/currency` raises only typed errors. This module owns the stable
|
||||
`CURRENCY_CODES` registry (built with `defineCodes("CURRENCY", [...])` from
|
||||
`@suaveplan/error`) and the `CurrencyError` subclass that every public failure
|
||||
path constructs. Consumers programmatically dispatch on the namespaced strings
|
||||
(`"CURRENCY.PROVIDER_UNAVAILABLE"`, `"CURRENCY.RATE_UNAVAILABLE"`, ...) and
|
||||
inspect the `from` / `to` / `providerName` discriminants to recover from or
|
||||
report on failures.
|
||||
|
||||
The class extends `SuaveplanError<CurrencyErrorCode>`, so
|
||||
`instanceof CurrencyError` narrows `err.code` to the literal union, and
|
||||
`instanceof SuaveplanError` carries the full project contract: `.code`,
|
||||
`.cause`, `.context`, `.timestamp`, `.traceId`. Seven codes are declared:
|
||||
`INVALID_CONFIG`, `INVALID_OPTIONS`, `PROVIDER_UNAVAILABLE`,
|
||||
`UNSUPPORTED_CURRENCY`, `RATE_UNAVAILABLE`, `TRIANGULATION_IMPOSSIBLE`,
|
||||
`ABORTED`. Every code is reachable from a real production call site —
|
||||
registry pollution is banned by repo policy.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable namespaced codes via `defineCodes("CURRENCY", [...] as const)` — the
|
||||
registry is frozen, the namespace is encoded into each value.
|
||||
- `CurrencyError extends SuaveplanError<CurrencyErrorCode>` — `instanceof`
|
||||
narrows `.code` to the literal-string union; `cause` chains arbitrary
|
||||
throwables.
|
||||
- `from` / `to` / `providerName` discriminants on every error instance for
|
||||
programmatic diagnostics (`err.from === "USD"`, `err.providerName === "ecb"`).
|
||||
- Co-located test pins every code to a real call site so the registry never
|
||||
drifts ahead of the implementation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter, CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
|
||||
|
||||
try {
|
||||
await converter.convert(money, "XYZ");
|
||||
} catch (err) {
|
||||
if (err instanceof CurrencyError && err.code === CURRENCY_CODES.RATE_UNAVAILABLE) {
|
||||
console.error(`No rate for ${err.from} → ${err.to}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Wrap an upstream provider failure while keeping the original throwable on
|
||||
`cause` so downstream loggers can still report it, and annotate the typed
|
||||
error with the provider that triggered the fallback chain failure:
|
||||
|
||||
```ts
|
||||
import { CurrencyError, CURRENCY_CODES } from "@suaveplan/currency";
|
||||
|
||||
async function safeFetch(provider) {
|
||||
try {
|
||||
return await provider.fetchRates({ base: "USD" });
|
||||
} catch (err) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
`provider ${provider.name} failed`,
|
||||
{ cause: err, providerName: provider.name, from: "USD" }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `CURRENCY_CODES` | `const` (frozen) | Registry of seven namespaced codes built via `defineCodes`. |
|
||||
| `CurrencyErrorCode` | `type` | Literal-string union of every registry value. |
|
||||
| `CurrencyErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with optional `from`, `to`, `providerName`. |
|
||||
| `CurrencyError` | `class` | `extends SuaveplanError<CurrencyErrorCode>` — constructor takes `(code, message, options?)`. |
|
||||
|
||||
### `CurrencyError` properties
|
||||
|
||||
- `code: CurrencyErrorCode` — the namespaced error code (e.g. `"CURRENCY.PROVIDER_UNAVAILABLE"`).
|
||||
- `from?: CurrencyCode` — the source currency the operation was attempted against.
|
||||
- `to?: CurrencyCode` — the target currency the operation was attempted against.
|
||||
- `providerName?: string` — the provider whose call produced the error.
|
||||
- `cause?: unknown` — chained underlying throwable.
|
||||
- `timestamp: number` — captured at construction time via the injected clock.
|
||||
- `context?: ErrorContext` — bag of structured fields propagated to the formatter.
|
||||
- `traceId?: string` — propagated W3C trace identifier when supplied.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is `public` so any module in the package can throw it; the
|
||||
optional discriminants are assigned only when set so `JSON.stringify` does not
|
||||
emit `"from": undefined`. `CurrencyError` is `instanceof SuaveplanError` and
|
||||
`instanceof Error`; tests pin both. The registry is created with `as const`
|
||||
so TypeScript infers literal types — `CURRENCY_CODES.PROVIDER_UNAVAILABLE` is
|
||||
`"CURRENCY.PROVIDER_UNAVAILABLE"`, not `string`. Adding a new code requires
|
||||
appending to the array in `error-codes.ts`, wiring it into a real production
|
||||
raise site, and updating the registry test to assert the literal value.
|
||||
108
docs/money/finance/currency/src/provider/provider.md
Normal file
108
docs/money/finance/currency/src/provider/provider.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `provider` — reference `CurrencyProvider` implementations
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The provider module ships two reference implementations of the
|
||||
`CurrencyProvider` contract: a mutable `InMemoryProvider` for tests and
|
||||
fixed-rate scenarios, and a `createStaticProvider(name, rateMap)` factory
|
||||
that returns a frozen provider built from a static rate map. Both share the
|
||||
same internal helpers for `RateFetchOptions.targets` filtering and
|
||||
`AbortSignal` handling. External providers (ECB, Fixer, CoinGecko, Binance,
|
||||
…) live in a separate `@suaveplan/currency-providers` package that consumes
|
||||
the same contract.
|
||||
|
||||
Both reference providers raise `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE, providerName })`
|
||||
when no rates are configured for the requested base, and
|
||||
`CurrencyError({ code: CURRENCY.ABORTED, providerName })` when called with
|
||||
an already-aborted `AbortSignal`. The `name` field is surfaced on
|
||||
`ConversionResult.providerName` so consumers can attribute every successful
|
||||
conversion to its source.
|
||||
|
||||
## Features
|
||||
|
||||
- `InMemoryProvider` — `setRates(base, rates)`, `setRate(from, to, rate)`,
|
||||
`fetchRates(options?)`. `supportsCrypto` / `supportsFiat` /
|
||||
`supportsVirtual` configurable at construction.
|
||||
- `createStaticProvider(name, rateMap, options?)` — frozen `CurrencyProvider`
|
||||
serving a static rate map.
|
||||
- AbortSignal honoured pre-fetch on both implementations.
|
||||
- `RateFetchOptions.targets` filtering — only the requested target rates are
|
||||
returned.
|
||||
- Missing-base error raises typed `CURRENCY.PROVIDER_UNAVAILABLE` with the
|
||||
provider name on the discriminant.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { InMemoryProvider, CurrencyConverter } from "@suaveplan/currency";
|
||||
|
||||
const provider = new InMemoryProvider();
|
||||
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
|
||||
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
const eur = await converter.convertAmount(100, "USD", "EUR"); // 92
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Compose a static fallback chain with `createStaticProvider`, using filtered
|
||||
target rates to keep payloads small:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStaticProvider,
|
||||
InMemoryProvider,
|
||||
CurrencyConverter,
|
||||
} from "@suaveplan/currency";
|
||||
|
||||
const primary = new InMemoryProvider();
|
||||
primary.setRates("USD", { EUR: 0.92 });
|
||||
|
||||
const fallback = createStaticProvider(
|
||||
"static-fallback",
|
||||
{
|
||||
USD: { EUR: 0.91, GBP: 0.78 },
|
||||
},
|
||||
{ supportsCrypto: false }
|
||||
);
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primary,
|
||||
fallbackProviders: [fallback],
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
queueMicrotask(() => ac.abort());
|
||||
try {
|
||||
await converter.convert(
|
||||
{ amount: 100n, currency: "USD" },
|
||||
"EUR",
|
||||
{ signal: ac.signal }
|
||||
);
|
||||
} catch (err) {
|
||||
// err is CurrencyError({ code: "CURRENCY.ABORTED" })
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `InMemoryProvider` | class | `name = "in-memory"`; `setRates`, `setRate`, `fetchRates`. |
|
||||
| `createStaticProvider(name, rateMap, options?)` | factory | Returns a frozen `CurrencyProvider` answering from `rateMap`. |
|
||||
| `ReferenceProviderOptions` | interface | Optional `supportsCrypto` / `supportsFiat` / `supportsVirtual` flags. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`InMemoryProvider` stores rates in a `Map<string, Record<string, number>>`;
|
||||
`setRates` copies the input object so later caller mutations don't leak.
|
||||
`createStaticProvider`'s returned object is `Object.freeze`d so callers can't
|
||||
mutate `name` / `supportsCrypto` / `supportsFiat` after construction. Abort
|
||||
handling is intentionally pre-fetch only — the reference providers do no
|
||||
real I/O, so once `fetchRates` resolves there is no in-flight call to
|
||||
abort. External providers (HTTP, WebSocket) propagate the signal into their
|
||||
network call.
|
||||
91
docs/money/finance/currency/src/rate-cache/rate-cache.md
Normal file
91
docs/money/finance/currency/src/rate-cache/rate-cache.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `rate-cache` — TTL-bounded rate cache with triangulated lookup
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RateCache` is the converter's in-memory rate store. It folds every successful
|
||||
`fetchRates` answer into a single map keyed by base currency, evicts entries
|
||||
older than the configured TTL, and resolves rates for any pair through one of
|
||||
four paths: identity (`from === to`), direct (cache has a `from` base with a
|
||||
`to` rate), inverse (cache has a `to` base with a `from` rate — return
|
||||
`1 / rate`), or triangulated (any cached non-`from`/`to` base contains both
|
||||
`from` and `to`). The cache is universal: only `Date.now`, `Map`, and
|
||||
plain object access.
|
||||
|
||||
The cache is a pure storage substrate — it does not call providers, does not
|
||||
seed virtual rates, and does not raise typed errors. Those concerns live in
|
||||
`CurrencyConverter`. Keeping the cache concern-free makes it cheap to swap
|
||||
for a persistent adapter in a follow-up change (see proposal `## Out of
|
||||
scope`).
|
||||
|
||||
## Features
|
||||
|
||||
- TTL-bounded entries (`set` records `Date.now() + ttlMs` as `expiresAt`).
|
||||
- Lazy eviction — expired entries are deleted on `get` / `has`.
|
||||
- `getRate(from, to)` resolves identity, direct, inverse, and triangulated
|
||||
matches.
|
||||
- `getTriangulationPath(from, to)` surfaces the intermediate base used for
|
||||
triangulation (or `undefined` for direct / inverse / identity / miss).
|
||||
- `prune()` removes every expired entry and returns the count removed.
|
||||
- `clear()`, `size`, `getTimestamp(base)` round out the inspection surface.
|
||||
- Skips zero rates during inverse / triangulation lookup to avoid `Infinity`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RateCache } from "@suaveplan/currency";
|
||||
|
||||
const cache = new RateCache(60_000); // 1-minute TTL
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: Date.now() });
|
||||
|
||||
cache.getRate("USD", "EUR"); // 0.92
|
||||
cache.getRate("EUR", "USD"); // 1 / 0.92 (inverse)
|
||||
cache.getRate("USD", "USD"); // 1 (identity)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Use the triangulation path to log which intermediate base resolved a quote:
|
||||
|
||||
```ts
|
||||
import { RateCache } from "@suaveplan/currency";
|
||||
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.5, JPY: 100 },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const rate = cache.getRate("EUR", "JPY"); // 200 (100 / 0.5)
|
||||
const through = cache.getTriangulationPath("EUR", "JPY"); // "USD"
|
||||
console.log(`EUR → JPY via ${through} = ${rate}`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Kind | Description |
|
||||
|---|---|---|
|
||||
| `new RateCache(ttlMs?)` | constructor | `ttlMs` defaults to `3_600_000` (1 hour). |
|
||||
| `set(base, result)` | method | Stores a `RateFetchResult` and stamps `expiresAt`. |
|
||||
| `get(base)` | method | Returns the cached `RateFetchResult` or `undefined`; deletes expired entries. |
|
||||
| `has(base)` | method | `get(base) !== undefined`. |
|
||||
| `getRate(from, to)` | method | Number for identity / direct / inverse / triangulated matches, otherwise `undefined`. |
|
||||
| `getTriangulationPath(from, to)` | method | The intermediate base when triangulation hit, otherwise `undefined`. |
|
||||
| `getTimestamp(base)` | method | `result.timestamp` of the fresh entry; `undefined` when missing or expired. |
|
||||
| `prune()` | method | Deletes every expired entry; returns the count removed. |
|
||||
| `clear()` | method | Empties the cache. |
|
||||
| `size` | getter | The underlying `Map.size` (includes potentially expired entries until pruned). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`size` deliberately mirrors the underlying `Map.size` so callers can observe
|
||||
hold-over expired entries before calling `prune()` — the counterpart `has` is
|
||||
TTL-aware. Lookup avoids dividing by `0` (a degenerate inverse rate) by
|
||||
short-circuiting the inverse branch when `rate === 0`; the same guard
|
||||
protects triangulation against a zero `fromRate`. The triangulation loop
|
||||
skips bases equal to `from` or `to` because those would be the inverse /
|
||||
direct path the previous branches already covered.
|
||||
106
docs/money/finance/currency/src/schemas/schemas.md
Normal file
106
docs/money/finance/currency/src/schemas/schemas.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# `schemas` — Zod boundary for `@suaveplan/currency`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The schemas module is the package's input-validation boundary. Every public
|
||||
options bundle that crosses into a `CurrencyConverter` method or a reference
|
||||
provider is parsed against a Zod schema first; failures raise a typed
|
||||
`CurrencyError` with code `CURRENCY.INVALID_CONFIG` (constructor arguments,
|
||||
virtual-currency entries) or `CURRENCY.INVALID_OPTIONS` (per-call options,
|
||||
`RateFetchOptions`). The Zod issue array is attached to
|
||||
`error.context.issues` so downstream loggers and APIs can surface field-level
|
||||
diagnostics without re-running validation.
|
||||
|
||||
Centralising validation here keeps the converter and provider implementations
|
||||
focused on behaviour: by the time control enters `convert` or `fetchRates`,
|
||||
every option is already the shape the implementation expects, and the
|
||||
implementation can rely on TypeScript's structural typing without runtime
|
||||
double-checks.
|
||||
|
||||
## Features
|
||||
|
||||
- `ConverterConfigSchema` — validates the constructor argument (provider
|
||||
shape, optional fallback array, positive integer TTL, currency-code
|
||||
default base, virtual-currency record).
|
||||
- `ConvertOptionsSchema` — validates per-call options (`rounding` enum,
|
||||
optional `fresh: boolean`, optional `AbortSignal`).
|
||||
- `VirtualCurrencyConfigSchema` — `rate > 0`, non-empty `name` / `symbol`,
|
||||
optional `exponent ∈ [0, 18]` / `issuerId`.
|
||||
- `RateFetchOptionsSchema` — optional `base`, `targets` array of currency
|
||||
codes, optional `AbortSignal`.
|
||||
- `parseConverterConfig` / `parseConvertOptions` /
|
||||
`parseVirtualCurrencyConfig` / `parseRateFetchOptions` — safe-parse
|
||||
helpers that raise the appropriate `CurrencyError` on failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseConvertOptions } from "@suaveplan/currency";
|
||||
|
||||
const options = parseConvertOptions({ rounding: "half-up", fresh: false });
|
||||
// options is typed as ConvertOptions; bad shapes raise CURRENCY.INVALID_OPTIONS.
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A consumer wraps API input through the schemas before delegating to the
|
||||
converter, surfacing Zod issues as user-facing field errors:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CurrencyError,
|
||||
CURRENCY_CODES,
|
||||
parseConverterConfig,
|
||||
CurrencyConverter,
|
||||
} from "@suaveplan/currency";
|
||||
|
||||
export function makeConverter(raw: unknown) {
|
||||
try {
|
||||
const config = parseConverterConfig(raw);
|
||||
return new CurrencyConverter(config);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof CurrencyError &&
|
||||
err.code === CURRENCY_CODES.INVALID_CONFIG
|
||||
) {
|
||||
const issues = (err.context?.issues as { path: (string | number)[]; message: string }[] | undefined) ?? [];
|
||||
throw new Error(
|
||||
`Bad converter config: ${issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `ConverterConfigSchema` | Zod schema | Validates `ConverterConfig`. |
|
||||
| `ConvertOptionsSchema` | Zod schema | Validates `ConvertOptions`. |
|
||||
| `VirtualCurrencyConfigSchema` | Zod schema | Validates a single `VirtualCurrencyConfig`. |
|
||||
| `RateFetchOptionsSchema` | Zod schema | Validates `RateFetchOptions`. |
|
||||
| `parseConverterConfig(input)` | `(unknown) => ConverterConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
|
||||
| `parseConvertOptions(input)` | `(unknown) => ConvertOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
|
||||
| `parseVirtualCurrencyConfig(input)` | `(unknown) => VirtualCurrencyConfig` | Raises `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure. |
|
||||
| `parseRateFetchOptions(input)` | `(unknown) => RateFetchOptions` | Raises `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `signal` field uses a `z.custom<AbortSignal>` because `AbortSignal` is a
|
||||
host object — Zod cannot introspect it. The custom predicate accepts any
|
||||
object exposing an `aborted` property, which matches both the platform
|
||||
`AbortSignal` and consumer-side polyfills. `provider` is validated
|
||||
structurally (`name`, `supportsCrypto`, `supportsFiat`, optional
|
||||
`supportsVirtual`, function-typed `fetchRates`); we deliberately do not
|
||||
descend into the function body. Currency codes are validated as non-empty
|
||||
strings — the converter checks against the `@suaveplan/money` registry at
|
||||
use time, so unknown codes raise `CURRENCY.UNSUPPORTED_CURRENCY` (via
|
||||
`getCurrencyOrThrow`) rather than failing schema validation.
|
||||
112
docs/money/finance/currency/src/types/common.md
Normal file
112
docs/money/finance/currency/src/types/common.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# `types/common` — public type surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single home for every shared type the `@suaveplan/currency` public surface
|
||||
exposes. The module defines the `CurrencyProvider` contract every rate source
|
||||
implements, the option bundles for the converter constructor and its three
|
||||
public methods (`convert`, `convertAmount`, `getRate`), the `RateCache` wire
|
||||
shape (`RateFetchResult`), and the enriched `ConversionResult` returned by
|
||||
`convert`. Every interface is named (no inline shapes — AGENTS.md §11) so
|
||||
downstream consumers can import the same name and structural-type assertions
|
||||
stay tight.
|
||||
|
||||
Types live here rather than inside their feature module because they cross
|
||||
module boundaries: the converter consumes `CurrencyProvider`, the provider
|
||||
module satisfies it, the cache stores `RateFetchResult`, and the schemas module
|
||||
mirrors each bundle into a Zod parser. Centralising them keeps the contract in
|
||||
one auditable place.
|
||||
|
||||
## Features
|
||||
|
||||
- `CurrencyProvider` contract: `name`, `supportsCrypto`, `supportsFiat`,
|
||||
optional `supportsVirtual`, `fetchRates(options?)`.
|
||||
- `RateFetchOptions` / `RateFetchResult` — the wire shape every provider speaks.
|
||||
- `ConverterConfig` — constructor arg for `CurrencyConverter`.
|
||||
- `ConvertOptions` — per-call options bundle (rounding, fresh, signal).
|
||||
- `ConversionResult` — converter return shape; enriched beyond upstream with
|
||||
`providerName` and `triangulatedThrough` diagnostics.
|
||||
- `VirtualCurrencyConfig` — fixed-rate virtual / game currency configuration.
|
||||
- `ExchangeRate` — single-pair wire shape (reserved for external transports).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
ConverterConfig,
|
||||
ConversionResult,
|
||||
} from "@suaveplan/currency";
|
||||
|
||||
const provider: CurrencyProvider = {
|
||||
name: "ecb",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
async fetchRates() {
|
||||
return { base: "EUR", rates: { USD: 1.08 }, timestamp: Date.now() };
|
||||
},
|
||||
};
|
||||
|
||||
const config: ConverterConfig = { provider, defaultBase: "EUR" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A consumer can satisfy the `CurrencyProvider` contract with an external HTTP
|
||||
client and surface its identifier through `ConversionResult.providerName`:
|
||||
|
||||
```ts
|
||||
import type { CurrencyProvider, RateFetchResult } from "@suaveplan/currency";
|
||||
|
||||
class EcbProvider implements CurrencyProvider {
|
||||
readonly name = "ecb";
|
||||
readonly supportsCrypto = false;
|
||||
readonly supportsFiat = true;
|
||||
|
||||
async fetchRates({
|
||||
base = "EUR",
|
||||
targets,
|
||||
signal,
|
||||
} = {}): Promise<RateFetchResult> {
|
||||
const res = await fetch(`https://api.example.com/${base}`, { signal });
|
||||
const data = (await res.json()) as { rates: Record<string, number> };
|
||||
let rates = data.rates;
|
||||
if (targets) {
|
||||
const filtered: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
if (rates[t] !== undefined) filtered[t] = rates[t];
|
||||
}
|
||||
rates = filtered;
|
||||
}
|
||||
return { base, rates, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `CurrencyProvider` | `interface` | Rate-source contract — `name`, `supportsCrypto`, `supportsFiat`, optional `supportsVirtual`, `fetchRates`. |
|
||||
| `RateFetchOptions` | `interface` | `base?`, `targets?`, `signal?`. |
|
||||
| `RateFetchResult` | `interface` | `base`, `rates`, `timestamp`. |
|
||||
| `ExchangeRate` | `interface` | Single-pair wire shape: `from`, `to`, `rate`, `timestamp`. |
|
||||
| `VirtualCurrencyConfig` | `interface` | `rate`, `name`, `symbol`, optional `exponent` / `issuerId`. |
|
||||
| `ConverterConfig` | `interface` | `provider`, optional `fallbackProviders`, `staleDurationMs`, `defaultBase`, `virtualCurrencies`. |
|
||||
| `ConvertOptions` | `interface` | `rounding?`, `fresh?`, `signal?`. |
|
||||
| `ConversionResult` | `interface` | `amount`, `rate`, `inverseRate`, `from`, `to`, `rateAgeMs`, `providerName`, `triangulatedThrough`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every field is `readonly` so consumers can confidently pass results around
|
||||
without defensive copies; the converter never mutates a `RateFetchResult`
|
||||
after handing it to the cache. `signal?: AbortSignal` is intentionally
|
||||
optional on every options bundle so callers that have no cancellation source
|
||||
need not synthesise one. `triangulatedThrough` is typed as
|
||||
`readonly string[] | null` (not `string | null`) so future multi-hop BFS work
|
||||
does not require a breaking type change; the migration baseline emits arrays
|
||||
of length 0 or 1 only.
|
||||
Reference in New Issue
Block a user