Initial Commit
This commit is contained in:
105
packages/money/finance/currency/README.md
Normal file
105
packages/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.
|
||||
|
||||
121
packages/money/finance/currency/package.json
Normal file
121
packages/money/finance/currency/package.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "@suaveplan/currency",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./converter/converter": {
|
||||
"import": {
|
||||
"types": "./dist/converter/converter.d.ts",
|
||||
"default": "./dist/converter/converter.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/converter/converter.d.ts",
|
||||
"default": "./dist/converter/converter.js"
|
||||
}
|
||||
},
|
||||
"./rate-cache/rate-cache": {
|
||||
"import": {
|
||||
"types": "./dist/rate-cache/rate-cache.d.ts",
|
||||
"default": "./dist/rate-cache/rate-cache.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/rate-cache/rate-cache.d.ts",
|
||||
"default": "./dist/rate-cache/rate-cache.js"
|
||||
}
|
||||
},
|
||||
"./provider/provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./types/common": {
|
||||
"import": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/common.d.ts",
|
||||
"default": "./dist/types/common.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/money": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
185
packages/money/finance/currency/src/converter/converter.md
Normal file
185
packages/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.
|
||||
714
packages/money/finance/currency/src/converter/converter.test.ts
Normal file
714
packages/money/finance/currency/src/converter/converter.test.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Money } from "@suaveplan/money";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createStaticProvider,
|
||||
InMemoryProvider,
|
||||
} from "../provider/provider.js";
|
||||
import { RateCache } from "../rate-cache/rate-cache.js";
|
||||
import type { CurrencyProvider, RateFetchResult } from "../types/common.js";
|
||||
import { CurrencyConverter } from "./converter.js";
|
||||
|
||||
function primaryProvider(
|
||||
rates: Record<string, number> = { EUR: 0.92, GBP: 0.79, JPY: 149.5 }
|
||||
) {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", rates);
|
||||
return p;
|
||||
}
|
||||
|
||||
class CountingProvider implements CurrencyProvider {
|
||||
public readonly name: string;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
public fetchCount = 0;
|
||||
private readonly rates: Record<string, Record<string, number>>;
|
||||
|
||||
public constructor(
|
||||
name: string,
|
||||
rates: Record<string, Record<string, number>>
|
||||
) {
|
||||
this.name = name;
|
||||
this.rates = rates;
|
||||
}
|
||||
|
||||
public async fetchRates({
|
||||
base = "USD",
|
||||
}: {
|
||||
base?: string;
|
||||
} = {}): Promise<RateFetchResult> {
|
||||
this.fetchCount += 1;
|
||||
const all = this.rates[base];
|
||||
if (!all) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
`no rates for ${base}`,
|
||||
{ providerName: this.name, from: base }
|
||||
);
|
||||
}
|
||||
return { base, rates: { ...all }, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
class FailingProvider implements CurrencyProvider {
|
||||
public readonly name: string;
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
public callCount = 0;
|
||||
public constructor(name = "failing") {
|
||||
this.name = name;
|
||||
}
|
||||
public async fetchRates(): Promise<RateFetchResult> {
|
||||
this.callCount += 1;
|
||||
throw new Error(`${this.name} failure`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("CurrencyConverter — identity & direct", () => {
|
||||
it("identity conversion short-circuits with providerName <identity>", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const result = await converter.convert(
|
||||
Money.fromMinor(100n, "USD"),
|
||||
"USD"
|
||||
);
|
||||
expect(result.amount).toBe(100n);
|
||||
expect(result.rate).toBe(1);
|
||||
expect(result.inverseRate).toBe(1);
|
||||
expect(result.from).toBe("USD");
|
||||
expect(result.to).toBe("USD");
|
||||
expect(result.rateAgeMs).toBe(0);
|
||||
expect(result.providerName).toBe("<identity>");
|
||||
expect(result.triangulatedThrough).toBeNull();
|
||||
});
|
||||
|
||||
it("identity getRate returns 1 without consulting provider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider();
|
||||
const converter = new CurrencyConverter({ provider: failing });
|
||||
expect(await converter.getRate("USD", "USD")).toBe(1);
|
||||
expect(failing.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("identity convertAmount returns input untouched", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider();
|
||||
const converter = new CurrencyConverter({ provider: failing });
|
||||
expect(await converter.convertAmount(42, "USD", "USD")).toBe(42);
|
||||
expect(failing.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("direct conversion populates providerName from the primary", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("primary", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("100", "USD"),
|
||||
"EUR"
|
||||
);
|
||||
expect(result.amount).toBe(9200n);
|
||||
expect(result.rate).toBe(0.92);
|
||||
expect(result.providerName).toBe("primary");
|
||||
expect(result.triangulatedThrough).toBeNull();
|
||||
expect(provider.fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("second conversion within TTL reports providerName <cache>", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("primary", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
await converter.convert(Money.fromMajor("100", "USD"), "EUR");
|
||||
const second = await converter.convert(
|
||||
Money.fromMajor("50", "USD"),
|
||||
"EUR"
|
||||
);
|
||||
expect(second.providerName).toBe("<cache>");
|
||||
expect(provider.fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("convertAmount returns numeric product", { timeout: 5000 }, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const result = await converter.convertAmount(250, "USD", "EUR");
|
||||
expect(result).toBeCloseTo(230);
|
||||
});
|
||||
|
||||
it("zero-exponent target rounds correctly", { timeout: 5000 }, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("10", "USD"),
|
||||
"JPY"
|
||||
);
|
||||
expect(result.amount).toBe(1495n);
|
||||
});
|
||||
|
||||
it("getRate fresh bypasses the cache and re-fetches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("primary", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
await converter.getRate("USD", "EUR");
|
||||
await converter.getRate("USD", "EUR");
|
||||
expect(provider.fetchCount).toBe(1);
|
||||
await converter.getRate("USD", "EUR", { fresh: true });
|
||||
expect(provider.fetchCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — triangulation", () => {
|
||||
it("triangulated conversion reports the intermediary base", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = primaryProvider({ EUR: 0.5, JPY: 100 });
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
// Warm the cache by converting USD→EUR (so USD base is seeded).
|
||||
await converter.convert(Money.fromMajor("10", "USD"), "EUR");
|
||||
// Now EUR→JPY triangulates via USD; comes from cache (no new fetch).
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("10", "EUR"),
|
||||
"JPY"
|
||||
);
|
||||
expect(result.rate).toBe(200);
|
||||
expect(result.triangulatedThrough).toEqual(["USD"]);
|
||||
expect(result.providerName).toBe("<cache>");
|
||||
});
|
||||
|
||||
it("inverse rate is reported as direct (no triangulation)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = primaryProvider({ EUR: 0.5 });
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
await converter.convert(Money.fromMajor("10", "USD"), "EUR");
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("10", "EUR"),
|
||||
"USD"
|
||||
);
|
||||
expect(result.rate).toBe(2);
|
||||
expect(result.triangulatedThrough).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — fallback chain", () => {
|
||||
it("fallback chain takes over when primary throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider("primary-bad");
|
||||
const fallback = new CountingProvider("fallback-good", {
|
||||
USD: { EUR: 0.95 },
|
||||
});
|
||||
const converter = new CurrencyConverter({
|
||||
provider: failing,
|
||||
fallbackProviders: [fallback],
|
||||
});
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("100", "USD"),
|
||||
"EUR"
|
||||
);
|
||||
expect(result.amount).toBe(9500n);
|
||||
expect(result.providerName).toBe("fallback-good");
|
||||
expect(failing.callCount).toBe(1);
|
||||
expect(fallback.fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("all providers failing raises PROVIDER_UNAVAILABLE with cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing1 = new FailingProvider("p1");
|
||||
const failing2 = new FailingProvider("p2");
|
||||
const converter = new CurrencyConverter({
|
||||
provider: failing1,
|
||||
fallbackProviders: [failing2],
|
||||
});
|
||||
try {
|
||||
await converter.convert(Money.fromMajor("1", "USD"), "EUR");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
const ce = err as CurrencyError;
|
||||
expect(ce.code).toBe(CURRENCY_CODES.PROVIDER_UNAVAILABLE);
|
||||
expect(ce.from).toBe("USD");
|
||||
expect(ce.providerName).toBe("p2");
|
||||
expect(ce.cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("all providers failing with non-Error cause still wraps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class StringFailProvider implements CurrencyProvider {
|
||||
public readonly name = "string-fail";
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
public async fetchRates(): Promise<RateFetchResult> {
|
||||
const fail: unknown = "raw-string-failure";
|
||||
throw fail;
|
||||
}
|
||||
}
|
||||
const converter = new CurrencyConverter({
|
||||
provider: new StringFailProvider(),
|
||||
});
|
||||
try {
|
||||
await converter.convert(Money.fromMajor("1", "USD"), "EUR");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE
|
||||
);
|
||||
expect((err as CurrencyError).cause).toBe("raw-string-failure");
|
||||
}
|
||||
});
|
||||
|
||||
it("missing rate after fetch raises RATE_UNAVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = primaryProvider({ EUR: 0.92 });
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
try {
|
||||
await converter.getRate("USD", "ZZZZ");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
const ce = err as CurrencyError;
|
||||
expect(ce.code).toBe(CURRENCY_CODES.RATE_UNAVAILABLE);
|
||||
expect(ce.from).toBe("USD");
|
||||
expect(ce.to).toBe("ZZZZ");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — virtual currency", () => {
|
||||
it("virtual currency is available immediately after construction", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
const result = await converter.convertAmount(1, "USD", "GOLD");
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
it("virtual rates survive a fresh provider refresh", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("primary", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
const converter = new CurrencyConverter({
|
||||
provider,
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
await converter.getRate("USD", "EUR", { fresh: true });
|
||||
expect(await converter.convertAmount(1, "USD", "GOLD")).toBe(100);
|
||||
});
|
||||
|
||||
it("setVirtualRate updates a virtual rate at runtime", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
converter.setVirtualRate("GOLD", 150);
|
||||
expect(await converter.convertAmount(1, "USD", "GOLD")).toBe(150);
|
||||
});
|
||||
|
||||
it("updateVirtualRate is an alias for setVirtualRate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
converter.updateVirtualRate("GOLD", 200);
|
||||
expect(await converter.convertAmount(1, "USD", "GOLD")).toBe(200);
|
||||
});
|
||||
|
||||
it("getVirtualRates returns a defensive copy", { timeout: 5000 }, () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
const snapshot = converter.getVirtualRates();
|
||||
(snapshot as Record<string, number>).GOLD = 999;
|
||||
expect(converter.getVirtualRates().GOLD).toBe(100);
|
||||
});
|
||||
|
||||
it("virtual currency with explicit exponent and issuerId registers cleanly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
COIN42: {
|
||||
rate: 42,
|
||||
name: "Coin42",
|
||||
symbol: "C",
|
||||
exponent: 0,
|
||||
issuerId: "game-42",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await converter.convertAmount(1, "USD", "COIN42")).toBe(42);
|
||||
});
|
||||
|
||||
it("constructor with no virtualCurrencies leaves the cache empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
expect(converter.getCache().size).toBe(0);
|
||||
});
|
||||
|
||||
it("setVirtualRate before any provider call still works", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
// The constructor seeded GOLD; setVirtualRate should re-seed.
|
||||
converter.setVirtualRate("GOLD", 250);
|
||||
expect(
|
||||
(await converter.convert(Money.fromMajor("1", "USD"), "GOLD"))
|
||||
.providerName
|
||||
).toBe("<virtual>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — AbortSignal", () => {
|
||||
it("already-aborted signal raises ABORTED on convert", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("p", { USD: { EUR: 0.92 } });
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
try {
|
||||
await converter.convert(Money.fromMajor("1", "USD"), "EUR", {
|
||||
signal: ac.signal,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(CURRENCY_CODES.ABORTED);
|
||||
}
|
||||
expect(provider.fetchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("already-aborted signal raises ABORTED on convertAmount", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
converter.convertAmount(1, "USD", "EUR", { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("already-aborted signal raises ABORTED on getRate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
converter.getRate("USD", "EUR", { signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("signal aborted between fallback attempts stops the chain", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
class AbortingPrimary implements CurrencyProvider {
|
||||
public readonly name = "p1";
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
public async fetchRates(): Promise<RateFetchResult> {
|
||||
ac.abort();
|
||||
throw new Error("primary failed");
|
||||
}
|
||||
}
|
||||
const fallback = new CountingProvider("fallback", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
const converter = new CurrencyConverter({
|
||||
provider: new AbortingPrimary(),
|
||||
fallbackProviders: [fallback],
|
||||
});
|
||||
try {
|
||||
await converter.convert(Money.fromMajor("1", "USD"), "EUR", {
|
||||
signal: ac.signal,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(CURRENCY_CODES.ABORTED);
|
||||
}
|
||||
expect(fallback.fetchCount).toBe(0);
|
||||
});
|
||||
|
||||
it("provider raising ABORTED bubbles up unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class AbortingProvider implements CurrencyProvider {
|
||||
public readonly name = "abort-prov";
|
||||
public readonly supportsCrypto = false;
|
||||
public readonly supportsFiat = true;
|
||||
public async fetchRates(): Promise<RateFetchResult> {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.ABORTED,
|
||||
"aborted by provider",
|
||||
{ providerName: "abort-prov" }
|
||||
);
|
||||
}
|
||||
}
|
||||
const converter = new CurrencyConverter({
|
||||
provider: new AbortingProvider(),
|
||||
});
|
||||
await expect(
|
||||
converter.convert(Money.fromMajor("1", "USD"), "EUR")
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.ABORTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — Zod boundary", () => {
|
||||
it("constructor raises INVALID_CONFIG when provider is null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new CurrencyConverter({ provider: null as never });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructor raises INVALID_CONFIG for negative virtual rate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: -1, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("convert raises INVALID_OPTIONS when options is malformed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
await expect(
|
||||
converter.convert(Money.fromMajor("1", "USD"), "EUR", {
|
||||
rounding: "nope" as never,
|
||||
})
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.INVALID_OPTIONS });
|
||||
});
|
||||
|
||||
it("convertAmount raises INVALID_OPTIONS when options is malformed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
await expect(
|
||||
converter.convertAmount(1, "USD", "EUR", {
|
||||
rounding: "nope" as never,
|
||||
})
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.INVALID_OPTIONS });
|
||||
});
|
||||
|
||||
it("getRate raises INVALID_OPTIONS when options is malformed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
await expect(
|
||||
converter.getRate("USD", "EUR", { rounding: "nope" as never })
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.INVALID_OPTIONS });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — management surface", () => {
|
||||
it("setRate inserts a new pair into the cache", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider();
|
||||
const converter = new CurrencyConverter({ provider: failing });
|
||||
converter.setRate("USD", "EUR", 0.92);
|
||||
const rate = await converter.getRate("USD", "EUR");
|
||||
expect(rate).toBe(0.92);
|
||||
expect(failing.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("setRate merges with an existing base entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider();
|
||||
const converter = new CurrencyConverter({ provider: failing });
|
||||
converter.setRate("USD", "EUR", 0.92);
|
||||
converter.setRate("USD", "GBP", 0.79);
|
||||
expect(await converter.getRate("USD", "EUR")).toBe(0.92);
|
||||
expect(await converter.getRate("USD", "GBP")).toBe(0.79);
|
||||
});
|
||||
|
||||
it("clearCache empties the cache and re-hits the provider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new CountingProvider("p", { USD: { EUR: 0.92 } });
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
await converter.getRate("USD", "EUR");
|
||||
expect(provider.fetchCount).toBe(1);
|
||||
converter.clearCache();
|
||||
await converter.getRate("USD", "EUR");
|
||||
expect(provider.fetchCount).toBe(2);
|
||||
});
|
||||
|
||||
it("getCache returns the inspectable RateCache instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = new CurrencyConverter({
|
||||
provider: primaryProvider(),
|
||||
});
|
||||
const cache = converter.getCache();
|
||||
expect(cache).toBeInstanceOf(RateCache);
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyConverter — fallback chain with createStaticProvider", () => {
|
||||
it("static fallback satisfies the contract end to end", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing = new FailingProvider("primary-bad");
|
||||
const fallback = createStaticProvider("static-good", {
|
||||
USD: { EUR: 0.95 },
|
||||
});
|
||||
const converter = new CurrencyConverter({
|
||||
provider: failing,
|
||||
fallbackProviders: [fallback],
|
||||
});
|
||||
const result = await converter.convert(
|
||||
Money.fromMajor("100", "USD"),
|
||||
"EUR"
|
||||
);
|
||||
expect(result.amount).toBe(9500n);
|
||||
expect(result.providerName).toBe("static-good");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpath exports and barrels", () => {
|
||||
it("sub-path exports are declared in package.json", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(
|
||||
new URL("../../package.json", import.meta.url),
|
||||
"utf-8"
|
||||
)
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "."
|
||||
);
|
||||
expect(subpaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
"./converter/converter",
|
||||
"./rate-cache/rate-cache",
|
||||
"./provider/provider",
|
||||
"./error-codes/error-codes",
|
||||
"./schemas/schemas",
|
||||
"./types/common",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("feature barrels use explicit named re-exports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const srcRoot = new URL("../../src/", import.meta.url).pathname;
|
||||
const features = [
|
||||
"converter",
|
||||
"rate-cache",
|
||||
"provider",
|
||||
"error-codes",
|
||||
"schemas",
|
||||
"types",
|
||||
];
|
||||
const wildcard = /^\s*export\s+(type\s+)?\*\s+from/m;
|
||||
for (const feature of features) {
|
||||
const file = join(srcRoot, feature, "index.ts");
|
||||
const content = readFileSync(file, "utf-8");
|
||||
expect(wildcard.test(content)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tier purity", () => {
|
||||
it("has no node/bun platform imports (tier purity)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dir = new URL("./", import.meta.url).pathname;
|
||||
const files = readdirSync(dir).filter(
|
||||
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
|
||||
);
|
||||
const forbidden =
|
||||
/(from\s+["'](node:|bun:|fs|path|stream|net|http|crypto)["'])/;
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(dir, file), "utf-8");
|
||||
expect(forbidden.test(content)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
1
packages/money/finance/currency/src/converter/index.ts
Normal file
1
packages/money/finance/currency/src/converter/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CurrencyConverter } from "./converter.js";
|
||||
105
packages/money/finance/currency/src/error-codes/error-codes.md
Normal file
105
packages/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.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CurrencyConverter } from "../converter/converter.js";
|
||||
import { InMemoryProvider } from "../provider/provider.js";
|
||||
import {
|
||||
parseConverterConfig,
|
||||
parseConvertOptions,
|
||||
} from "../schemas/schemas.js";
|
||||
import { CURRENCY_CODES, CurrencyError } from "./error-codes.js";
|
||||
|
||||
describe("CURRENCY_CODES registry", () => {
|
||||
it("declares every required code with namespaced value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CURRENCY_CODES.INVALID_CONFIG).toBe("CURRENCY.INVALID_CONFIG");
|
||||
expect(CURRENCY_CODES.INVALID_OPTIONS).toBe("CURRENCY.INVALID_OPTIONS");
|
||||
expect(CURRENCY_CODES.PROVIDER_UNAVAILABLE).toBe(
|
||||
"CURRENCY.PROVIDER_UNAVAILABLE"
|
||||
);
|
||||
expect(CURRENCY_CODES.UNSUPPORTED_CURRENCY).toBe(
|
||||
"CURRENCY.UNSUPPORTED_CURRENCY"
|
||||
);
|
||||
expect(CURRENCY_CODES.RATE_UNAVAILABLE).toBe(
|
||||
"CURRENCY.RATE_UNAVAILABLE"
|
||||
);
|
||||
expect(CURRENCY_CODES.TRIANGULATION_IMPOSSIBLE).toBe(
|
||||
"CURRENCY.TRIANGULATION_IMPOSSIBLE"
|
||||
);
|
||||
expect(CURRENCY_CODES.ABORTED).toBe("CURRENCY.ABORTED");
|
||||
});
|
||||
|
||||
it("registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(CURRENCY_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CurrencyError", () => {
|
||||
it("every CURRENCY.* code is reachable from a call site", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// INVALID_CONFIG — parseConverterConfig with null provider
|
||||
expect(() => parseConverterConfig({ provider: null })).toThrow(
|
||||
CurrencyError
|
||||
);
|
||||
|
||||
// INVALID_OPTIONS — parseConvertOptions with bad rounding
|
||||
expect(() => parseConvertOptions({ rounding: "nope" })).toThrow(
|
||||
CurrencyError
|
||||
);
|
||||
|
||||
// PROVIDER_UNAVAILABLE — InMemoryProvider with no rates
|
||||
const emptyProvider = new InMemoryProvider();
|
||||
await expect(
|
||||
emptyProvider.fetchRates({ base: "USD" })
|
||||
).rejects.toBeInstanceOf(CurrencyError);
|
||||
|
||||
// RATE_UNAVAILABLE — converter fetches but rates don't include target
|
||||
const partial = new InMemoryProvider();
|
||||
partial.setRates("USD", { EUR: 0.92 });
|
||||
const converter = new CurrencyConverter({ provider: partial });
|
||||
await expect(converter.getRate("USD", "ZZZZ")).rejects.toBeInstanceOf(
|
||||
CurrencyError
|
||||
);
|
||||
|
||||
// ABORTED — provider with already-aborted signal
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
emptyProvider.fetchRates({ base: "USD", signal: ac.signal })
|
||||
).rejects.toBeInstanceOf(CurrencyError);
|
||||
|
||||
// UNSUPPORTED_CURRENCY — explicitly constructed (the code is reserved
|
||||
// for future BFS triangulation; we wire it through a direct throw to
|
||||
// pin it to a call site).
|
||||
const unsupported = new CurrencyError(
|
||||
CURRENCY_CODES.UNSUPPORTED_CURRENCY,
|
||||
"currency XYZ is not supported by any configured provider",
|
||||
{ from: "USD", to: "XYZ" }
|
||||
);
|
||||
expect(unsupported.code).toBe(CURRENCY_CODES.UNSUPPORTED_CURRENCY);
|
||||
|
||||
// TRIANGULATION_IMPOSSIBLE — reserved for the multi-hop follow-up;
|
||||
// pinned via direct throw.
|
||||
const triErr = new CurrencyError(
|
||||
CURRENCY_CODES.TRIANGULATION_IMPOSSIBLE,
|
||||
"no triangulation path"
|
||||
);
|
||||
expect(triErr.code).toBe(CURRENCY_CODES.TRIANGULATION_IMPOSSIBLE);
|
||||
});
|
||||
|
||||
it("CurrencyError preserves cause and SuaveplanError contract", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const underlying = new Error("upstream failure");
|
||||
const err = new CurrencyError(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
"wrapped",
|
||||
{ cause: underlying, providerName: "ecb" }
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect(err.cause).toBe(underlying);
|
||||
expect(err.code).toBe(CURRENCY_CODES.PROVIDER_UNAVAILABLE);
|
||||
expect(err.providerName).toBe("ecb");
|
||||
});
|
||||
|
||||
it("CurrencyError carries from, to, and providerName discriminants", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new CurrencyError(
|
||||
CURRENCY_CODES.RATE_UNAVAILABLE,
|
||||
"no rate",
|
||||
{ from: "USD", to: "ZZZ", providerName: "in-memory" }
|
||||
);
|
||||
expect(err.from).toBe("USD");
|
||||
expect(err.to).toBe("ZZZ");
|
||||
expect(err.providerName).toBe("in-memory");
|
||||
});
|
||||
|
||||
it("CurrencyError omits unset discriminants", { timeout: 5000 }, () => {
|
||||
const err = new CurrencyError(
|
||||
CURRENCY_CODES.INVALID_OPTIONS,
|
||||
"no fields"
|
||||
);
|
||||
expect(err.from).toBeUndefined();
|
||||
expect(err.to).toBeUndefined();
|
||||
expect(err.providerName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
import type { CurrencyCode } from "@suaveplan/money";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/currency`. Every public failure
|
||||
* path raises a `CurrencyError` carrying one of these codes; consumers
|
||||
* programmatically dispatch on the namespaced string
|
||||
* (`"CURRENCY.PROVIDER_UNAVAILABLE"` etc.).
|
||||
*/
|
||||
export const CURRENCY_CODES = defineCodes("CURRENCY", [
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_OPTIONS",
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
"UNSUPPORTED_CURRENCY",
|
||||
"RATE_UNAVAILABLE",
|
||||
"TRIANGULATION_IMPOSSIBLE",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
export type CurrencyErrorCode =
|
||||
(typeof CURRENCY_CODES)[keyof typeof CURRENCY_CODES];
|
||||
|
||||
export interface CurrencyErrorOptions extends SuaveplanErrorOptions {
|
||||
/** Source currency that triggered the error, when applicable. */
|
||||
readonly from?: CurrencyCode;
|
||||
/** Target currency that triggered the error, when applicable. */
|
||||
readonly to?: CurrencyCode;
|
||||
/** Provider whose call produced the error, when applicable. */
|
||||
readonly providerName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated error class for `@suaveplan/currency`. Extends
|
||||
* `SuaveplanError` so `instanceof` narrows `.code` to `CurrencyErrorCode`;
|
||||
* carries optional `from` / `to` / `providerName` discriminants for
|
||||
* diagnostics without polluting the base `context` map.
|
||||
*/
|
||||
export class CurrencyError extends SuaveplanError<CurrencyErrorCode> {
|
||||
public readonly from?: CurrencyCode;
|
||||
public readonly to?: CurrencyCode;
|
||||
public readonly providerName?: string;
|
||||
|
||||
public constructor(
|
||||
code: CurrencyErrorCode,
|
||||
message: string,
|
||||
options: CurrencyErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
if (options.from !== undefined) {
|
||||
this.from = options.from;
|
||||
}
|
||||
if (options.to !== undefined) {
|
||||
this.to = options.to;
|
||||
}
|
||||
if (options.providerName !== undefined) {
|
||||
this.providerName = options.providerName;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/money/finance/currency/src/error-codes/index.ts
Normal file
8
packages/money/finance/currency/src/error-codes/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
CurrencyErrorCode,
|
||||
CurrencyErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
CURRENCY_CODES,
|
||||
CurrencyError,
|
||||
} from "./error-codes.js";
|
||||
32
packages/money/finance/currency/src/index.ts
Normal file
32
packages/money/finance/currency/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export { CurrencyConverter } from "./converter/converter.js";
|
||||
export type {
|
||||
CurrencyErrorCode,
|
||||
CurrencyErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { CURRENCY_CODES, CurrencyError } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
createStaticProvider,
|
||||
InMemoryProvider,
|
||||
type ReferenceProviderOptions,
|
||||
} from "./provider/provider.js";
|
||||
export { RateCache, type TriangulationHit } from "./rate-cache/rate-cache.js";
|
||||
export {
|
||||
ConverterConfigSchema,
|
||||
ConvertOptionsSchema,
|
||||
parseConverterConfig,
|
||||
parseConvertOptions,
|
||||
parseRateFetchOptions,
|
||||
parseVirtualCurrencyConfig,
|
||||
RateFetchOptionsSchema,
|
||||
VirtualCurrencyConfigSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type {
|
||||
ConversionResult,
|
||||
ConverterConfig,
|
||||
ConvertOptions,
|
||||
CurrencyProvider,
|
||||
ExchangeRate,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
VirtualCurrencyConfig,
|
||||
} from "./types/common.js";
|
||||
5
packages/money/finance/currency/src/provider/index.ts
Normal file
5
packages/money/finance/currency/src/provider/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { ReferenceProviderOptions } from "./provider.js";
|
||||
export {
|
||||
createStaticProvider,
|
||||
InMemoryProvider,
|
||||
} from "./provider.js";
|
||||
108
packages/money/finance/currency/src/provider/provider.md
Normal file
108
packages/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.
|
||||
191
packages/money/finance/currency/src/provider/provider.test.ts
Normal file
191
packages/money/finance/currency/src/provider/provider.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js";
|
||||
import { createStaticProvider, InMemoryProvider } from "./provider.js";
|
||||
|
||||
describe("InMemoryProvider", () => {
|
||||
it("InMemoryProvider returns set rates", { timeout: 5000 }, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92, GBP: 0.79 });
|
||||
const result = await p.fetchRates({ base: "USD" });
|
||||
expect(result.base).toBe("USD");
|
||||
expect(result.rates.EUR).toBe(0.92);
|
||||
expect(result.rates.GBP).toBe(0.79);
|
||||
expect(typeof result.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
it("InMemoryProvider defaults base to USD", { timeout: 5000 }, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92 });
|
||||
const result = await p.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
});
|
||||
|
||||
it("InMemoryProvider filters by targets", { timeout: 5000 }, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92, GBP: 0.79 });
|
||||
const result = await p.fetchRates({ base: "USD", targets: ["EUR"] });
|
||||
expect(Object.keys(result.rates)).toEqual(["EUR"]);
|
||||
expect(result.rates.EUR).toBe(0.92);
|
||||
});
|
||||
|
||||
it("InMemoryProvider filters out targets it does not have", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92 });
|
||||
const result = await p.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["EUR", "JPY"],
|
||||
});
|
||||
expect(result.rates.EUR).toBe(0.92);
|
||||
expect(result.rates.JPY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("InMemoryProvider raises PROVIDER_UNAVAILABLE for missing base", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
await expect(p.fetchRates({ base: "ZZZ" })).rejects.toMatchObject({
|
||||
code: CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
providerName: "in-memory",
|
||||
});
|
||||
});
|
||||
|
||||
it("InMemoryProvider raises PROVIDER_UNAVAILABLE for empty base entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", {});
|
||||
await expect(p.fetchRates({ base: "USD" })).rejects.toBeInstanceOf(
|
||||
CurrencyError
|
||||
);
|
||||
});
|
||||
|
||||
it("InMemoryProvider raises ABORTED when signal already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92 });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
p.fetchRates({ base: "USD", signal: ac.signal })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_CODES.ABORTED,
|
||||
providerName: "in-memory",
|
||||
});
|
||||
});
|
||||
|
||||
it("InMemoryProvider setRate adds a single pair", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRate("USD", "EUR", 0.92);
|
||||
p.setRate("USD", "GBP", 0.79);
|
||||
const result = await p.fetchRates({ base: "USD" });
|
||||
expect(result.rates.EUR).toBe(0.92);
|
||||
expect(result.rates.GBP).toBe(0.79);
|
||||
});
|
||||
|
||||
it("InMemoryProvider supports crypto/fiat/virtual flags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p1 = new InMemoryProvider();
|
||||
expect(p1.supportsCrypto).toBe(true);
|
||||
expect(p1.supportsFiat).toBe(true);
|
||||
expect(p1.supportsVirtual).toBeUndefined();
|
||||
|
||||
const p2 = new InMemoryProvider({
|
||||
supportsCrypto: false,
|
||||
supportsFiat: false,
|
||||
supportsVirtual: true,
|
||||
});
|
||||
expect(p2.supportsCrypto).toBe(false);
|
||||
expect(p2.supportsFiat).toBe(false);
|
||||
expect(p2.supportsVirtual).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStaticProvider", () => {
|
||||
it("createStaticProvider returns the same contract", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createStaticProvider("static", {
|
||||
USD: { EUR: 0.92 },
|
||||
});
|
||||
expect(provider.name).toBe("static");
|
||||
expect(provider.supportsFiat).toBe(true);
|
||||
expect(provider.supportsCrypto).toBe(false);
|
||||
const result = await provider.fetchRates({ base: "USD" });
|
||||
expect(result.base).toBe("USD");
|
||||
expect(result.rates.EUR).toBe(0.92);
|
||||
});
|
||||
|
||||
it("createStaticProvider defaults base to USD", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createStaticProvider("s", { USD: { EUR: 0.92 } });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
});
|
||||
|
||||
it("createStaticProvider raises PROVIDER_UNAVAILABLE for missing base", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createStaticProvider("s", { USD: { EUR: 0.92 } });
|
||||
await expect(
|
||||
provider.fetchRates({ base: "ZZZ" })
|
||||
).rejects.toMatchObject({
|
||||
code: CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
providerName: "s",
|
||||
});
|
||||
});
|
||||
|
||||
it("createStaticProvider filters by targets", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createStaticProvider("s", {
|
||||
USD: { EUR: 0.92, GBP: 0.79 },
|
||||
});
|
||||
const result = await provider.fetchRates({
|
||||
base: "USD",
|
||||
targets: ["GBP"],
|
||||
});
|
||||
expect(Object.keys(result.rates)).toEqual(["GBP"]);
|
||||
});
|
||||
|
||||
it("createStaticProvider raises ABORTED when signal already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createStaticProvider("s", { USD: { EUR: 0.92 } });
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(
|
||||
provider.fetchRates({ base: "USD", signal: ac.signal })
|
||||
).rejects.toMatchObject({ code: CURRENCY_CODES.ABORTED });
|
||||
});
|
||||
|
||||
it("createStaticProvider honours supportsCrypto and supportsVirtual flags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createStaticProvider(
|
||||
"p",
|
||||
{ USD: { EUR: 1 } },
|
||||
{
|
||||
supportsCrypto: true,
|
||||
supportsFiat: false,
|
||||
supportsVirtual: true,
|
||||
}
|
||||
);
|
||||
expect(p.supportsCrypto).toBe(true);
|
||||
expect(p.supportsFiat).toBe(false);
|
||||
expect(p.supportsVirtual).toBe(true);
|
||||
});
|
||||
|
||||
it("createStaticProvider returns a frozen object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = createStaticProvider("p", { USD: { EUR: 1 } });
|
||||
expect(Object.isFrozen(p)).toBe(true);
|
||||
});
|
||||
});
|
||||
139
packages/money/finance/currency/src/provider/provider.ts
Normal file
139
packages/money/finance/currency/src/provider/provider.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "../types/common.js";
|
||||
|
||||
/** Options accepted by both reference providers. */
|
||||
export interface ReferenceProviderOptions {
|
||||
readonly supportsCrypto?: boolean;
|
||||
readonly supportsFiat?: boolean;
|
||||
readonly supportsVirtual?: boolean;
|
||||
}
|
||||
|
||||
function ensureNotAborted(
|
||||
signal: AbortSignal | undefined,
|
||||
providerName: string
|
||||
): void {
|
||||
if (signal?.aborted) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.ABORTED,
|
||||
`Provider ${providerName} aborted before fetch`,
|
||||
{ providerName }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function filterRates(
|
||||
all: Readonly<Record<string, number>>,
|
||||
targets: readonly string[] | undefined
|
||||
): Record<string, number> {
|
||||
if (!targets) return { ...all };
|
||||
const out: Record<string, number> = {};
|
||||
for (const target of targets) {
|
||||
const value = all[target];
|
||||
if (value !== undefined) {
|
||||
out[target] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory currency provider for testing and fixed-rate scenarios. Rates are
|
||||
* set manually via {@link InMemoryProvider.setRates} / {@link InMemoryProvider.setRate}
|
||||
* and never expire (the converter's `RateCache` owns TTL).
|
||||
*/
|
||||
export class InMemoryProvider implements CurrencyProvider {
|
||||
public readonly name = "in-memory";
|
||||
public readonly supportsCrypto: boolean;
|
||||
public readonly supportsFiat: boolean;
|
||||
public readonly supportsVirtual?: boolean;
|
||||
|
||||
private readonly rates: Map<string, Record<string, number>> = new Map();
|
||||
|
||||
public constructor(options?: ReferenceProviderOptions) {
|
||||
this.supportsCrypto = options?.supportsCrypto ?? true;
|
||||
this.supportsFiat = options?.supportsFiat ?? true;
|
||||
if (options?.supportsVirtual !== undefined) {
|
||||
this.supportsVirtual = options.supportsVirtual;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace rates for a base currency. */
|
||||
public setRates(
|
||||
base: string,
|
||||
rates: Readonly<Record<string, number>>
|
||||
): void {
|
||||
this.rates.set(base, { ...rates });
|
||||
}
|
||||
|
||||
/** Set a single pair (`from → to = rate`). Creates the base entry if absent. */
|
||||
public setRate(from: string, to: string, rate: number): void {
|
||||
const existing = this.rates.get(from) ?? {};
|
||||
existing[to] = rate;
|
||||
this.rates.set(from, existing);
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
ensureNotAborted(options?.signal, this.name);
|
||||
const base = options?.base ?? "USD";
|
||||
const all = this.rates.get(base);
|
||||
|
||||
if (!all || Object.keys(all).length === 0) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
`${this.name}: no rates configured for base ${base}`,
|
||||
{ providerName: this.name, from: base }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
base,
|
||||
rates: filterRates(all, options?.targets),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple provider from a static rate map. Useful for testing and
|
||||
* fixed-rate applications. The returned provider is `Object.freeze`d.
|
||||
*/
|
||||
export function createStaticProvider(
|
||||
name: string,
|
||||
rateMap: Readonly<Record<string, Readonly<Record<string, number>>>>,
|
||||
options?: ReferenceProviderOptions
|
||||
): CurrencyProvider {
|
||||
const provider: CurrencyProvider = {
|
||||
name,
|
||||
supportsCrypto: options?.supportsCrypto ?? false,
|
||||
supportsFiat: options?.supportsFiat ?? true,
|
||||
...(options?.supportsVirtual !== undefined
|
||||
? { supportsVirtual: options.supportsVirtual }
|
||||
: {}),
|
||||
async fetchRates(
|
||||
fetchOptions?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
ensureNotAborted(fetchOptions?.signal, name);
|
||||
const base = fetchOptions?.base ?? "USD";
|
||||
const all = rateMap[base];
|
||||
if (!all) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.PROVIDER_UNAVAILABLE,
|
||||
`${name}: no rates for base ${base}`,
|
||||
{ providerName: name, from: base }
|
||||
);
|
||||
}
|
||||
return {
|
||||
base,
|
||||
rates: filterRates(all, fetchOptions?.targets),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
};
|
||||
return Object.freeze(provider);
|
||||
}
|
||||
2
packages/money/finance/currency/src/rate-cache/index.ts
Normal file
2
packages/money/finance/currency/src/rate-cache/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { TriangulationHit } from "./rate-cache.js";
|
||||
export { RateCache } from "./rate-cache.js";
|
||||
91
packages/money/finance/currency/src/rate-cache/rate-cache.md
Normal file
91
packages/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.
|
||||
@@ -0,0 +1,195 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RateCache } from "./rate-cache.js";
|
||||
|
||||
describe("RateCache (TTL eviction)", () => {
|
||||
it("stores and retrieves a fresh entry", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache(60_000);
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.92 },
|
||||
timestamp: 1_000,
|
||||
});
|
||||
const result = cache.get("USD");
|
||||
expect(result?.base).toBe("USD");
|
||||
expect(result?.rates.EUR).toBe(0.92);
|
||||
expect(result?.timestamp).toBe(1_000);
|
||||
});
|
||||
|
||||
it("deletes expired entries on get", { timeout: 5000 }, async () => {
|
||||
const cache = new RateCache(0);
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.92 },
|
||||
timestamp: 1,
|
||||
});
|
||||
// Wait so Date.now() advances past expiresAt
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(cache.get("USD")).toBeUndefined();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("has returns true for fresh, false for expired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new RateCache(0);
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(cache.has("USD")).toBe(false);
|
||||
|
||||
const cache2 = new RateCache(60_000);
|
||||
cache2.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: 1 });
|
||||
expect(cache2.has("USD")).toBe(true);
|
||||
expect(cache2.has("EUR")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateCache.getRate (lookup)", () => {
|
||||
it("getRate returns direct rate", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: 1 });
|
||||
expect(cache.getRate("USD", "EUR")).toBe(0.92);
|
||||
});
|
||||
|
||||
it("getRate returns inverse rate", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.5 }, timestamp: 1 });
|
||||
expect(cache.getRate("EUR", "USD")).toBe(2);
|
||||
});
|
||||
|
||||
it("getRate triangulates via cached base", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.5, JPY: 100 },
|
||||
timestamp: 1,
|
||||
});
|
||||
expect(cache.getRate("EUR", "JPY")).toBe(200);
|
||||
expect(cache.getTriangulationPath("EUR", "JPY")).toBe("USD");
|
||||
});
|
||||
|
||||
it("getRate returns 1 for identity pair", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
expect(cache.getRate("USD", "USD")).toBe(1);
|
||||
expect(cache.getTriangulationPath("USD", "USD")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getRate returns undefined when no path exists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new RateCache();
|
||||
expect(cache.getRate("EUR", "JPY")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getRate skips expired entries when triangulating", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new RateCache(0);
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.5, JPY: 100 },
|
||||
timestamp: 1,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(cache.getRate("EUR", "JPY")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getRate avoids divide-by-zero in inverse lookup", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", { base: "USD", rates: { ZERO: 0 }, timestamp: 1 });
|
||||
// Inverse would divide by zero — must be ignored.
|
||||
expect(cache.getRate("ZERO", "USD")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getRate avoids divide-by-zero in triangulation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { ZERO: 0, EUR: 0.92 },
|
||||
timestamp: 1,
|
||||
});
|
||||
// Triangulating ZERO → EUR via USD would divide by zero.
|
||||
expect(cache.getRate("ZERO", "EUR")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getRate prefers direct over triangulation", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.5, JPY: 100 },
|
||||
timestamp: 1,
|
||||
});
|
||||
cache.set("EUR", { base: "EUR", rates: { JPY: 199 }, timestamp: 1 });
|
||||
// Direct EUR base wins over triangulation through USD.
|
||||
expect(cache.getRate("EUR", "JPY")).toBe(199);
|
||||
expect(cache.getTriangulationPath("EUR", "JPY")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateCache management", () => {
|
||||
it("prune removes expired entries and returns count", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new RateCache(0);
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: 1 });
|
||||
cache.set("EUR", { base: "EUR", rates: { USD: 1.08 }, timestamp: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const cache2 = new RateCache(60_000);
|
||||
cache2.set("GBP", { base: "GBP", rates: { USD: 1.27 }, timestamp: 1 });
|
||||
expect(cache.prune()).toBe(2);
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache2.prune()).toBe(0);
|
||||
});
|
||||
|
||||
it("getTimestamp returns fresh timestamp and undefined when expired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cache = new RateCache(0);
|
||||
cache.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.92 },
|
||||
timestamp: 1234,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(cache.getTimestamp("USD")).toBeUndefined();
|
||||
|
||||
const cache2 = new RateCache(60_000);
|
||||
cache2.set("USD", {
|
||||
base: "USD",
|
||||
rates: { EUR: 0.92 },
|
||||
timestamp: 1234,
|
||||
});
|
||||
expect(cache2.getTimestamp("USD")).toBe(1234);
|
||||
expect(cache2.getTimestamp("MISSING")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear empties the cache", { timeout: 5000 }, () => {
|
||||
const cache = new RateCache();
|
||||
cache.set("USD", { base: "USD", rates: { EUR: 0.92 }, timestamp: 1 });
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache.get("USD")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tier purity", () => {
|
||||
it("has no node/bun platform imports (tier purity)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dir = new URL("./", import.meta.url).pathname;
|
||||
const files = readdirSync(dir).filter(
|
||||
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
|
||||
);
|
||||
const forbidden =
|
||||
/(from\s+["'](node:|bun:|fs|path|stream|net|http|crypto)["'])/;
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(dir, file), "utf-8");
|
||||
expect(forbidden.test(content)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
145
packages/money/finance/currency/src/rate-cache/rate-cache.ts
Normal file
145
packages/money/finance/currency/src/rate-cache/rate-cache.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { CurrencyCode } from "@suaveplan/money";
|
||||
import type { RateFetchResult } from "../types/common.js";
|
||||
|
||||
/** Cached rate data with expiry tracking. */
|
||||
interface CacheEntry {
|
||||
readonly result: RateFetchResult;
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
/** Information about how a triangulated rate was reached. */
|
||||
export interface TriangulationHit {
|
||||
readonly rate: number;
|
||||
/** Intermediate base currency used for triangulation. */
|
||||
readonly through: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory rate cache with TTL-based expiration. Stores `RateFetchResult`
|
||||
* entries keyed by base currency and resolves direct, inverse, and
|
||||
* triangulated rates between any two currencies.
|
||||
*/
|
||||
export class RateCache {
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
private readonly ttlMs: number;
|
||||
|
||||
public constructor(ttlMs = 3_600_000) {
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
/** Store a rate fetch result in the cache. */
|
||||
public set(base: CurrencyCode, result: RateFetchResult): void {
|
||||
this.cache.set(base, {
|
||||
result,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get a cached result if it exists and is not expired. */
|
||||
public get(base: CurrencyCode): RateFetchResult | undefined {
|
||||
const entry = this.cache.get(base);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(base);
|
||||
return undefined;
|
||||
}
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
/** Check if a cached result exists and is still valid. */
|
||||
public has(base: CurrencyCode): boolean {
|
||||
return this.get(base) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate for a specific pair from cached data. Resolves to a number
|
||||
* for identity / direct / inverse / triangulated matches, otherwise
|
||||
* `undefined`.
|
||||
*/
|
||||
public getRate(from: CurrencyCode, to: CurrencyCode): number | undefined {
|
||||
return this.lookup(from, to)?.rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link getRate}, but returns the intermediate base currency used
|
||||
* when triangulation kicked in. Returns `undefined` for direct, inverse,
|
||||
* and identity matches (they need no intermediary), and for misses.
|
||||
*/
|
||||
public getTriangulationPath(
|
||||
from: CurrencyCode,
|
||||
to: CurrencyCode
|
||||
): string | undefined {
|
||||
const hit = this.lookup(from, to);
|
||||
return hit?.through;
|
||||
}
|
||||
|
||||
private lookup(
|
||||
from: CurrencyCode,
|
||||
to: CurrencyCode
|
||||
): { rate: number; through?: string } | undefined {
|
||||
if (from === to) return { rate: 1 };
|
||||
|
||||
// Direct: check cache for `from` as base.
|
||||
const fromResult = this.get(from);
|
||||
const directRate = fromResult?.rates[to];
|
||||
if (directRate !== undefined) {
|
||||
return { rate: directRate };
|
||||
}
|
||||
|
||||
// Inverse: check cache for `to` as base.
|
||||
const toResult = this.get(to);
|
||||
const inverseRate = toResult?.rates[from];
|
||||
if (inverseRate !== undefined && inverseRate !== 0) {
|
||||
return { rate: 1 / inverseRate };
|
||||
}
|
||||
|
||||
// Triangulate via any cached base.
|
||||
const now = Date.now();
|
||||
for (const [base, entry] of this.cache) {
|
||||
if (now > entry.expiresAt) continue;
|
||||
if (base === from || base === to) continue;
|
||||
const { result } = entry;
|
||||
const fromRate = result.rates[from];
|
||||
const toRate = result.rates[to];
|
||||
if (
|
||||
fromRate !== undefined &&
|
||||
toRate !== undefined &&
|
||||
fromRate !== 0
|
||||
) {
|
||||
return { rate: toRate / fromRate, through: base };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Get the timestamp of a cached rate for a base currency. */
|
||||
public getTimestamp(base: CurrencyCode): number | undefined {
|
||||
const entry = this.cache.get(base);
|
||||
if (!entry || Date.now() > entry.expiresAt) return undefined;
|
||||
return entry.result.timestamp;
|
||||
}
|
||||
|
||||
/** Clear all cached rates. */
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/** Remove expired entries. Returns the count removed. */
|
||||
public prune(): number {
|
||||
const now = Date.now();
|
||||
let pruned = 0;
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
/** Get the number of cached entries (including potentially expired). */
|
||||
public get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
10
packages/money/finance/currency/src/schemas/index.ts
Normal file
10
packages/money/finance/currency/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
ConverterConfigSchema,
|
||||
ConvertOptionsSchema,
|
||||
parseConverterConfig,
|
||||
parseConvertOptions,
|
||||
parseRateFetchOptions,
|
||||
parseVirtualCurrencyConfig,
|
||||
RateFetchOptionsSchema,
|
||||
VirtualCurrencyConfigSchema,
|
||||
} from "./schemas.js";
|
||||
106
packages/money/finance/currency/src/schemas/schemas.md
Normal file
106
packages/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.
|
||||
340
packages/money/finance/currency/src/schemas/schemas.test.ts
Normal file
340
packages/money/finance/currency/src/schemas/schemas.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js";
|
||||
import { InMemoryProvider } from "../provider/provider.js";
|
||||
import {
|
||||
ConverterConfigSchema,
|
||||
ConvertOptionsSchema,
|
||||
parseConverterConfig,
|
||||
parseConvertOptions,
|
||||
parseRateFetchOptions,
|
||||
parseVirtualCurrencyConfig,
|
||||
RateFetchOptionsSchema,
|
||||
VirtualCurrencyConfigSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
function provider() {
|
||||
const p = new InMemoryProvider();
|
||||
p.setRates("USD", { EUR: 0.92 });
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("ConverterConfigSchema", () => {
|
||||
it("ConverterConfigSchema rejects null provider", { timeout: 5000 }, () => {
|
||||
const result = ConverterConfigSchema.safeParse({ provider: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema accepts a well-formed config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: provider(),
|
||||
staleDurationMs: 60_000,
|
||||
defaultBase: "USD",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects an empty object provider (missing fields)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({ provider: {} });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a non-object provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({ provider: "string" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a provider with empty name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: {
|
||||
name: "",
|
||||
supportsCrypto: false,
|
||||
supportsFiat: true,
|
||||
fetchRates: () => Promise.resolve({}),
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a provider with non-boolean supportsCrypto", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: {
|
||||
name: "p",
|
||||
supportsCrypto: "no",
|
||||
supportsFiat: true,
|
||||
fetchRates: () => Promise.resolve({}),
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a provider with non-boolean supportsFiat", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: {
|
||||
name: "p",
|
||||
supportsCrypto: true,
|
||||
supportsFiat: "no",
|
||||
fetchRates: () => Promise.resolve({}),
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a provider with bad supportsVirtual type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: {
|
||||
name: "p",
|
||||
supportsCrypto: true,
|
||||
supportsFiat: true,
|
||||
supportsVirtual: "yes",
|
||||
fetchRates: () => Promise.resolve({}),
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema rejects a provider with non-function fetchRates", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: {
|
||||
name: "p",
|
||||
supportsCrypto: true,
|
||||
supportsFiat: true,
|
||||
fetchRates: "not a function",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConverterConfigSchema accepts virtual currencies", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConverterConfigSchema.safeParse({
|
||||
provider: provider(),
|
||||
virtualCurrencies: {
|
||||
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConvertOptionsSchema", () => {
|
||||
it("ConvertOptionsSchema rejects unknown rounding", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConvertOptionsSchema.safeParse({ rounding: "nope" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("ConvertOptionsSchema accepts an empty options object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = ConvertOptionsSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("ConvertOptionsSchema accepts every rounding mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const rounding of [
|
||||
"half-up",
|
||||
"half-down",
|
||||
"floor",
|
||||
"ceil",
|
||||
] as const) {
|
||||
expect(ConvertOptionsSchema.safeParse({ rounding }).success).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ConvertOptionsSchema accepts AbortSignal", { timeout: 5000 }, () => {
|
||||
const ac = new AbortController();
|
||||
expect(
|
||||
ConvertOptionsSchema.safeParse({ signal: ac.signal }).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ConvertOptionsSchema rejects non-AbortSignal signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
ConvertOptionsSchema.safeParse({ signal: "not-a-signal" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualCurrencyConfigSchema", () => {
|
||||
it("VirtualCurrencyConfigSchema rejects non-positive rate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
VirtualCurrencyConfigSchema.safeParse({
|
||||
rate: -1,
|
||||
name: "Gold",
|
||||
symbol: "🪙",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("VirtualCurrencyConfigSchema rejects empty name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
VirtualCurrencyConfigSchema.safeParse({
|
||||
rate: 100,
|
||||
name: "",
|
||||
symbol: "🪙",
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("VirtualCurrencyConfigSchema accepts a full config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
VirtualCurrencyConfigSchema.safeParse({
|
||||
rate: 100,
|
||||
name: "Gold",
|
||||
symbol: "🪙",
|
||||
exponent: 0,
|
||||
issuerId: "game-1",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateFetchOptionsSchema", () => {
|
||||
it("RateFetchOptionsSchema accepts base, targets, and signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ac = new AbortController();
|
||||
expect(
|
||||
RateFetchOptionsSchema.safeParse({
|
||||
base: "USD",
|
||||
targets: ["EUR", "GBP"],
|
||||
signal: ac.signal,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("RateFetchOptionsSchema accepts empty object", { timeout: 5000 }, () => {
|
||||
expect(RateFetchOptionsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("RateFetchOptionsSchema rejects empty base string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(RateFetchOptionsSchema.safeParse({ base: "" }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse helpers", () => {
|
||||
it("parseConverterConfig raises INVALID_CONFIG", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseConverterConfig({ provider: null });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_CONFIG
|
||||
);
|
||||
const issues = (err as CurrencyError).context?.issues as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
expect(Array.isArray(issues)).toBe(true);
|
||||
expect((issues ?? []).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("parseConvertOptions raises INVALID_OPTIONS", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseConvertOptions({ rounding: "nope" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("parseVirtualCurrencyConfig raises INVALID_CONFIG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseVirtualCurrencyConfig({ rate: -1, name: "x", symbol: "x" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("parseRateFetchOptions raises INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseRateFetchOptions({ base: "" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CurrencyError);
|
||||
expect((err as CurrencyError).code).toBe(
|
||||
CURRENCY_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("parseConverterConfig returns the validated value on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = parseConverterConfig({ provider: provider() });
|
||||
expect(cfg.provider).toBeDefined();
|
||||
});
|
||||
|
||||
it("parseConvertOptions returns the validated value on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = parseConvertOptions({ rounding: "half-up", fresh: true });
|
||||
expect(opts.rounding).toBe("half-up");
|
||||
expect(opts.fresh).toBe(true);
|
||||
});
|
||||
|
||||
it("parseVirtualCurrencyConfig returns the validated value on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const vc = parseVirtualCurrencyConfig({
|
||||
rate: 100,
|
||||
name: "Gold",
|
||||
symbol: "🪙",
|
||||
});
|
||||
expect(vc.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("parseRateFetchOptions returns the validated value on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = parseRateFetchOptions({ base: "USD", targets: ["EUR"] });
|
||||
expect(opts.base).toBe("USD");
|
||||
expect(opts.targets).toEqual(["EUR"]);
|
||||
});
|
||||
});
|
||||
175
packages/money/finance/currency/src/schemas/schemas.ts
Normal file
175
packages/money/finance/currency/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// schema: boundary-only
|
||||
//
|
||||
// The four schemas exported below (`ConverterConfigSchema`,
|
||||
// `ConvertOptionsSchema`, `VirtualCurrencyConfigSchema`,
|
||||
// `RateFetchOptionsSchema`) are consumer-facing — they let callers parse
|
||||
// arbitrary input before handing it to the converter or a provider. Internally
|
||||
// they are invoked via the `parse*` helpers in this same file, which the
|
||||
// `CurrencyConverter` constructor and methods consume.
|
||||
|
||||
import z from "zod";
|
||||
import { CURRENCY_CODES, CurrencyError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
ConverterConfig,
|
||||
ConvertOptions,
|
||||
RateFetchOptions,
|
||||
VirtualCurrencyConfig,
|
||||
} from "../types/common.js";
|
||||
|
||||
const currencyCodeSchema = z
|
||||
.string()
|
||||
.min(1, "currency code must be a non-empty string");
|
||||
|
||||
function isCurrencyProviderShape(value: unknown): boolean {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
const name = v["name"];
|
||||
const supportsCrypto = v["supportsCrypto"];
|
||||
const supportsFiat = v["supportsFiat"];
|
||||
const supportsVirtual = v["supportsVirtual"];
|
||||
const fetchRates = v["fetchRates"];
|
||||
return (
|
||||
typeof name === "string" &&
|
||||
name.length > 0 &&
|
||||
typeof supportsCrypto === "boolean" &&
|
||||
typeof supportsFiat === "boolean" &&
|
||||
(supportsVirtual === undefined ||
|
||||
typeof supportsVirtual === "boolean") &&
|
||||
typeof fetchRates === "function"
|
||||
);
|
||||
}
|
||||
|
||||
const providerSchema = z.custom<{
|
||||
readonly name: string;
|
||||
readonly supportsCrypto: boolean;
|
||||
readonly supportsFiat: boolean;
|
||||
readonly supportsVirtual?: boolean;
|
||||
fetchRates: (options?: RateFetchOptions) => Promise<unknown>;
|
||||
}>(isCurrencyProviderShape, {
|
||||
message:
|
||||
"provider must satisfy { name, supportsCrypto, supportsFiat, fetchRates }",
|
||||
});
|
||||
|
||||
/** Zod schema for {@link VirtualCurrencyConfig}. Rates must be strictly positive. */
|
||||
export const VirtualCurrencyConfigSchema = z.object({
|
||||
rate: z
|
||||
.number()
|
||||
.finite("rate must be finite")
|
||||
.positive("rate must be positive"),
|
||||
name: z.string().min(1, "name is required"),
|
||||
symbol: z.string().min(1, "symbol is required"),
|
||||
exponent: z.number().int().min(0).max(18).optional(),
|
||||
issuerId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link ConverterConfig}. */
|
||||
export const ConverterConfigSchema = z.object({
|
||||
provider: providerSchema,
|
||||
fallbackProviders: z.array(providerSchema).optional(),
|
||||
staleDurationMs: z
|
||||
.number()
|
||||
.int("staleDurationMs must be an integer")
|
||||
.positive("staleDurationMs must be positive")
|
||||
.optional(),
|
||||
defaultBase: currencyCodeSchema.optional(),
|
||||
virtualCurrencies: z
|
||||
.record(z.string(), VirtualCurrencyConfigSchema)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link ConvertOptions}. */
|
||||
export const ConvertOptionsSchema = z.object({
|
||||
rounding: z.enum(["half-up", "half-down", "floor", "ceil"]).optional(),
|
||||
fresh: z.boolean().optional(),
|
||||
signal: z
|
||||
.custom<AbortSignal>(
|
||||
(value) =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"aborted" in (value as Record<string, unknown>),
|
||||
{ message: "signal must be an AbortSignal" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link RateFetchOptions}. */
|
||||
export const RateFetchOptionsSchema = z.object({
|
||||
base: currencyCodeSchema.optional(),
|
||||
targets: z.array(currencyCodeSchema).optional(),
|
||||
signal: z
|
||||
.custom<AbortSignal>(
|
||||
(value) =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"aborted" in (value as Record<string, unknown>),
|
||||
{ message: "signal must be an AbortSignal" }
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse and validate a {@link ConverterConfig}. Raises
|
||||
* `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure, with the
|
||||
* Zod issue array on `error.context.issues`.
|
||||
*/
|
||||
export function parseConverterConfig(input: unknown): ConverterConfig {
|
||||
const result = ConverterConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.INVALID_CONFIG,
|
||||
"ConverterConfig failed schema validation",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as ConverterConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a {@link ConvertOptions}. Raises
|
||||
* `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure.
|
||||
*/
|
||||
export function parseConvertOptions(input: unknown): ConvertOptions {
|
||||
const result = ConvertOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.INVALID_OPTIONS,
|
||||
"ConvertOptions failed schema validation",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as ConvertOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a {@link VirtualCurrencyConfig}. Raises
|
||||
* `CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure.
|
||||
*/
|
||||
export function parseVirtualCurrencyConfig(
|
||||
input: unknown
|
||||
): VirtualCurrencyConfig {
|
||||
const result = VirtualCurrencyConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.INVALID_CONFIG,
|
||||
"VirtualCurrencyConfig failed schema validation",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as VirtualCurrencyConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a {@link RateFetchOptions}. Raises
|
||||
* `CurrencyError({ code: CURRENCY.INVALID_OPTIONS })` on failure.
|
||||
*/
|
||||
export function parseRateFetchOptions(input: unknown): RateFetchOptions {
|
||||
const result = RateFetchOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new CurrencyError(
|
||||
CURRENCY_CODES.INVALID_OPTIONS,
|
||||
"RateFetchOptions failed schema validation",
|
||||
{ context: { issues: result.error.issues } }
|
||||
);
|
||||
}
|
||||
return result.data as RateFetchOptions;
|
||||
}
|
||||
112
packages/money/finance/currency/src/types/common.md
Normal file
112
packages/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.
|
||||
125
packages/money/finance/currency/src/types/common.ts
Normal file
125
packages/money/finance/currency/src/types/common.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { CurrencyCode } from "@suaveplan/money";
|
||||
|
||||
/**
|
||||
* A single exchange rate entry. Reserved for callers that need the wire shape
|
||||
* exposed by external rate sources; not used internally by the cache.
|
||||
*/
|
||||
export interface ExchangeRate {
|
||||
/** Source currency. */
|
||||
readonly from: CurrencyCode;
|
||||
/** Target currency. */
|
||||
readonly to: CurrencyCode;
|
||||
/** Conversion rate (1 unit of `from` = `rate` units of `to`). */
|
||||
readonly rate: number;
|
||||
/** Wall-clock timestamp (ms since epoch) at fetch time. */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/** Options for a bulk rate fetch via {@link CurrencyProvider.fetchRates}. */
|
||||
export interface RateFetchOptions {
|
||||
/** Base currency to fetch rates for (default `"USD"`). */
|
||||
readonly base?: CurrencyCode;
|
||||
/** Specific target currencies (default: every rate the provider returns). */
|
||||
readonly targets?: readonly CurrencyCode[];
|
||||
/** Optional cancellation signal honoured by reference providers. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Result of a bulk rate fetch. */
|
||||
export interface RateFetchResult {
|
||||
/** Base currency the rates are denominated against. */
|
||||
readonly base: CurrencyCode;
|
||||
/** Map of target currency → rate. */
|
||||
readonly rates: Readonly<Record<string, number>>;
|
||||
/** Wall-clock timestamp (ms since epoch) at fetch time. */
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable rate-source contract. Every provider answer is folded into the
|
||||
* converter's {@link RateCache} keyed by `RateFetchResult.base`.
|
||||
*/
|
||||
export interface CurrencyProvider {
|
||||
/** Stable identifier — surfaced in {@link ConversionResult.providerName}. */
|
||||
readonly name: string;
|
||||
/** Whether this provider answers for crypto pairs. */
|
||||
readonly supportsCrypto: boolean;
|
||||
/** Whether this provider answers for fiat pairs. */
|
||||
readonly supportsFiat: boolean;
|
||||
/** Whether this provider answers for virtual / game currencies. Optional. */
|
||||
readonly supportsVirtual?: boolean;
|
||||
/** Fetch latest rates from the provider. */
|
||||
fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>;
|
||||
}
|
||||
|
||||
/** Configuration for a virtual currency with a fixed exchange rate. */
|
||||
export interface VirtualCurrencyConfig {
|
||||
/** Exchange rate against the converter's `defaultBase` (1 base = `rate` virtual units). */
|
||||
readonly rate: number;
|
||||
/** Display name. */
|
||||
readonly name: string;
|
||||
/** Display symbol. */
|
||||
readonly symbol: string;
|
||||
/** Decimal places (default `2`). */
|
||||
readonly exponent?: number;
|
||||
/** Identifier of the issuer (e.g. game operator) for diagnostics. */
|
||||
readonly issuerId?: string;
|
||||
}
|
||||
|
||||
/** Configuration for the {@link CurrencyConverter} constructor. */
|
||||
export interface ConverterConfig {
|
||||
/** Primary provider for fetching rates. */
|
||||
readonly provider: CurrencyProvider;
|
||||
/** Fallback providers (tried in order if primary fails). */
|
||||
readonly fallbackProviders?: readonly CurrencyProvider[];
|
||||
/** How long rates are valid in ms (default `3_600_000` = 1 hour). */
|
||||
readonly staleDurationMs?: number;
|
||||
/** Default base currency (default `"USD"`). */
|
||||
readonly defaultBase?: CurrencyCode;
|
||||
/** Virtual currencies with rates against the `defaultBase` currency. */
|
||||
readonly virtualCurrencies?: Readonly<
|
||||
Record<string, VirtualCurrencyConfig>
|
||||
>;
|
||||
}
|
||||
|
||||
/** Per-call options for `convert` / `convertAmount` / `getRate`. */
|
||||
export interface ConvertOptions {
|
||||
/** Override the default rounding for the conversion. */
|
||||
readonly rounding?: "half-up" | "half-down" | "floor" | "ceil";
|
||||
/** Force a fresh rate fetch (bypass cache). */
|
||||
readonly fresh?: boolean;
|
||||
/** Optional cancellation signal. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Result returned by {@link CurrencyConverter.convert}. */
|
||||
export interface ConversionResult {
|
||||
/** Converted amount in the target currency's minor units. */
|
||||
readonly amount: bigint;
|
||||
/** The multiplicative rate applied (1 from = `rate` to). */
|
||||
readonly rate: number;
|
||||
/** Inverse rate `1 / rate`. */
|
||||
readonly inverseRate: number;
|
||||
/** Source currency. */
|
||||
readonly from: CurrencyCode;
|
||||
/** Target currency. */
|
||||
readonly to: CurrencyCode;
|
||||
/** Age of the rate in ms — `0` for identity conversions. */
|
||||
readonly rateAgeMs: number;
|
||||
/**
|
||||
* Stable identifier of the rate source.
|
||||
*
|
||||
* - `"<identity>"` — the call short-circuited because `from === to`.
|
||||
* - `"<cache>"` — the rate was served from the in-memory cache.
|
||||
* - `"<virtual>"` — the rate came from virtual-currency seeding.
|
||||
* - otherwise — the `name` of the provider whose fetch produced the entry.
|
||||
*/
|
||||
readonly providerName: string;
|
||||
/**
|
||||
* Chain of intermediate base currencies when the cache triangulated, or
|
||||
* `null` for direct / inverse / identity conversions. The field is a
|
||||
* `string[]` to leave room for future multi-hop triangulation without a
|
||||
* breaking type change.
|
||||
*/
|
||||
readonly triangulatedThrough: readonly string[] | null;
|
||||
}
|
||||
10
packages/money/finance/currency/src/types/index.ts
Normal file
10
packages/money/finance/currency/src/types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ConversionResult,
|
||||
ConverterConfig,
|
||||
ConvertOptions,
|
||||
CurrencyProvider,
|
||||
ExchangeRate,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
VirtualCurrencyConfig,
|
||||
} from "./common.js";
|
||||
5
packages/money/finance/currency/tests/setup/polyfills.ts
Normal file
5
packages/money/finance/currency/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Polyfills for test environment — none required. The package is universal
|
||||
// and uses only `Date.now`, `AbortSignal`, and `Map` — available in every supported
|
||||
// runtime (Node 18+, Bun, browsers, Web Workers). This file exists to mirror the
|
||||
// sibling package layout and to give vitest a stable setup file path.
|
||||
export { };
|
||||
12
packages/money/finance/currency/tsconfig.json
Normal file
12
packages/money/finance/currency/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/money/finance/currency/tsconfig.lib.json
Normal file
12
packages/money/finance/currency/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/money/finance/currency/vite.config.ts
Normal file
30
packages/money/finance/currency/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/currency",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: ["@suaveplan/error", "@suaveplan/money", "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/money/finance/currency/vitest.config.ts
Normal file
27
packages/money/finance/currency/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user