Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View 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"
]
}

View File

@@ -0,0 +1,185 @@
# `converter` — `CurrencyConverter` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CurrencyConverter` is the orchestration heart of `@suaveplan/currency`. It
combines a pluggable `CurrencyProvider` (plus an ordered fallback chain), a
TTL-bounded `RateCache`, virtual-currency seeding, cross-rate triangulation,
and `AbortSignal` cancellation into a single typed API that converts a
`MoneyLike` value (or plain numeric amount) between any two currencies. Every
public boundary is Zod-validated; every failure path raises a typed
`CurrencyError`; every successful conversion is enriched with `providerName`
and `triangulatedThrough` diagnostics so callers can observe which source
answered and whether the rate is direct or derived.
The converter does not perform I/O itself — it delegates to the provider chain
— which keeps it universal. Reference providers ship in `./provider`;
external providers (ECB, Fixer, CoinGecko, Binance, …) live in
`@suaveplan/currency-providers`. The class is intentionally generic-free; the
markup-policy / multi-tenant work flagged in the enterprise audit will land
in a follow-up change.
## Features
- Primary + ordered fallback `CurrencyProvider` chain (`config.fallbackProviders`).
- TTL-bounded cache (`config.staleDurationMs`, default 1 hour).
- Cross-rate triangulation via cached intermediate base (single-hop;
multi-hop BFS deferred).
- Identity short-circuit when `from === to`.
- Virtual-currency seeding (`config.virtualCurrencies`) with post-construction
`setVirtualRate` / `updateVirtualRate` mutators (aliases) and
`getVirtualRates` inspector. Re-seeded after every provider refresh so
virtual rates survive.
- `AbortSignal` honoured on every public method and between fallback attempts.
- Zod-validated constructor and call sites — failures raise
`CurrencyError({ code: CURRENCY.INVALID_CONFIG | CURRENCY.INVALID_OPTIONS })`.
- Enriched `ConversionResult` — `providerName` (`"<identity>"` / `"<cache>"` /
`"<virtual>"` / actual provider name) and `triangulatedThrough` (the
intermediate base for triangulated rates, otherwise `null`).
- Management surface: `setRate`, `clearCache`, `getCache`.
## Usage: Basic Example
```ts
import { CurrencyConverter, InMemoryProvider } from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const provider = new InMemoryProvider();
provider.setRates("USD", { EUR: 0.92, GBP: 0.79, JPY: 149.5 });
const converter = new CurrencyConverter({ provider });
const result = await converter.convert(
Money.fromMajor("100", "USD"),
"EUR"
);
// result.amount === 9200n, result.rate === 0.92,
// result.providerName === "in-memory", result.triangulatedThrough === null
```
## Usage: Advanced Example
A multi-source converter with a fallback provider, virtual currency, and
`AbortSignal` cancellation:
```ts
import {
CurrencyConverter,
InMemoryProvider,
createStaticProvider,
CurrencyError,
CURRENCY_CODES,
} from "@suaveplan/currency";
import { Money } from "@suaveplan/money";
const primary = new InMemoryProvider();
primary.setRates("USD", { EUR: 0.5, JPY: 100 });
const fallback = createStaticProvider("static", {
USD: { EUR: 0.51, JPY: 99 },
});
const converter = new CurrencyConverter({
provider: primary,
fallbackProviders: [fallback],
defaultBase: "USD",
virtualCurrencies: {
GOLD: { rate: 100, name: "Gold", symbol: "🪙" },
},
});
// Triangulated conversion via USD.
const eurToJpy = await converter.convert(Money.fromMajor("10", "EUR"), "JPY");
// eurToJpy.triangulatedThrough === ["USD"], eurToJpy.rate === 200
// Virtual-currency conversion.
const gold = await converter.convertAmount(1, "USD", "GOLD");
// gold === 100; converter.getVirtualRates() === { GOLD: 100 }
// Cancellation.
const ac = new AbortController();
queueMicrotask(() => ac.abort());
try {
await converter.convert(Money.fromMajor("1", "USD"), "EUR", {
signal: ac.signal,
fresh: true,
});
} catch (err) {
if (
err instanceof CurrencyError &&
err.code === CURRENCY_CODES.ABORTED
) {
console.warn("Aborted as expected");
} else {
throw err;
}
}
```
## API Reference
### `new CurrencyConverter(config: ConverterConfig)`
Validates `config` through `ConverterConfigSchema`; raises
`CurrencyError({ code: CURRENCY.INVALID_CONFIG })` on failure.
### `convert(money: MoneyLike, to: CurrencyCode, options?: ConvertOptions): Promise<ConversionResult>`
Converts `money` to `to`. Returns an enriched `ConversionResult`. Raises:
- `CURRENCY.INVALID_OPTIONS` — bad `options`.
- `CURRENCY.ABORTED` — `options.signal` aborted at call time or between
fallback attempts.
- `CURRENCY.PROVIDER_UNAVAILABLE` — every provider in the chain failed.
- `CURRENCY.RATE_UNAVAILABLE` — no rate path exists even after refresh.
### `convertAmount(amount: number, from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Numeric counterpart of `convert`; same errors apply.
### `getRate(from: CurrencyCode, to: CurrencyCode, options?: ConvertOptions): Promise<number>`
Returns the multiplicative rate for `from → to`. Same errors apply.
### `setVirtualRate(code: string, rate: number): void` / `updateVirtualRate(code: string, rate: number): void`
Update a virtual currency's rate against `defaultBase` at runtime. Both
names are aliases; the audit named `setVirtualRate`, and the upstream library
named `updateVirtualRate`.
### `getVirtualRates(): Readonly<Record<string, number>>`
Defensive copy of the in-memory virtual rate map.
### `setRate(from: CurrencyCode, to: CurrencyCode, rate: number): void` / `clearCache(): void` / `getCache(): RateCache`
Direct cache management — useful for testing and overrides.
## Implementation Notes
The converter stores a per-pair `rateOrigin` map so it can report
`ConversionResult.providerName` correctly across the four sources
(`<identity>` / `<cache>` / `<virtual>` / actual provider). The
`justFetchedFromProvider` field is set transiently inside `resolveRate` so the
immediate read after a successful fetch surfaces the real provider name;
subsequent calls hit the cache and report `"<cache>"`.
`seedVirtualRates` runs after every successful provider refresh so virtual
rates survive new fetches. It writes virtual entries with origin
`"<virtual>"`; the lookup code therefore reports `"<virtual>"` for any
conversion that targets a virtual currency, even after later fetches refresh
other rates in the same cache entry.
The single-hop triangulation policy preserves upstream behaviour bit-for-bit;
multi-hop BFS is deferred (see proposal `## Out of scope`). The
`triangulatedThrough` field is typed as `readonly string[] | null` to leave
the door open for future multi-hop work without a breaking type change.
`Math.round` on the converted minor amount preserves the upstream rounding
behaviour. The per-pair rounding-mode policy from the enterprise audit is
deferred to a dedicated proposal because it is a behaviour change that
affects every caller.

View 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);
}
});
});

View 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;
}
}

View File

@@ -0,0 +1 @@
export { CurrencyConverter } from "./converter.js";

View 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.

View File

@@ -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();
});
});

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,8 @@
export type {
CurrencyErrorCode,
CurrencyErrorOptions,
} from "./error-codes.js";
export {
CURRENCY_CODES,
CurrencyError,
} from "./error-codes.js";

View 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";

View File

@@ -0,0 +1,5 @@
export type { ReferenceProviderOptions } from "./provider.js";
export {
createStaticProvider,
InMemoryProvider,
} from "./provider.js";

View 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.

View 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);
});
});

View 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);
}

View File

@@ -0,0 +1,2 @@
export type { TriangulationHit } from "./rate-cache.js";
export { RateCache } from "./rate-cache.js";

View 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.

View File

@@ -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);
}
});
});

View 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;
}
}

View File

@@ -0,0 +1,10 @@
export {
ConverterConfigSchema,
ConvertOptionsSchema,
parseConverterConfig,
parseConvertOptions,
parseRateFetchOptions,
parseVirtualCurrencyConfig,
RateFetchOptionsSchema,
VirtualCurrencyConfigSchema,
} from "./schemas.js";

View 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.

View 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"]);
});
});

View 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;
}

View 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.

View 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;
}

View File

@@ -0,0 +1,10 @@
export type {
ConversionResult,
ConverterConfig,
ConvertOptions,
CurrencyProvider,
ExchangeRate,
RateFetchOptions,
RateFetchResult,
VirtualCurrencyConfig,
} from "./common.js";

View 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 { };

View 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"]
}

View 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"]
}

View 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"],
},
},
});

View 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,
},
});