Files
2026-05-27 23:29:53 +01:00

184 lines
8.4 KiB
Markdown

# Design — @suaveplan/currency
## Architecture
`@suaveplan/currency` is a universal-tier orchestrator. It owns no rate data; it
owns the contract every rate source implements (`CurrencyProvider`), the
in-memory TTL cache that flattens repeated calls into a single provider hit
(`RateCache`), and the converter that combines them with cross-rate
triangulation and a fallback chain (`CurrencyConverter`). Every public boundary
is Zod-validated; every failure path raises a typed `CurrencyError`. The package
runs unchanged under Node, Bun, browsers, and Web Workers.
```text
┌──────────────────────────┐
│ CurrencyConverter │
│ │
convert(money) ───▶│ validate options (Zod) │
│ walk fallback chain │
│ fetch → cache → seed │
│ triangulate via cache │
│ enrich result │
│ honour AbortSignal │
└──┬────────────────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ RateCache │ │ CurrencyProvider │
│ (TTL) │ │ (primary + │
│ prune / │ │ fallbacks) │
│ triangulate│ └────────┬─────────┘
└─────────────┘ │
├─ InMemoryProvider
├─ createStaticProvider(...)
└─ (future external providers)
```
## File layout
Every feature lives at `src/<feature>/<feature>.ts` per the no-prefix rule;
every feature has a sibling `<feature>.md`, `<feature>.test.ts`, and an
`index.ts` barrel using explicit named re-exports.
```text
packages/finance/currency/
├── package.json
├── README.md
├── tsconfig.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── tests/
│ └── setup/
│ └── polyfills.ts
└── src/
├── index.ts # root barrel — namespace + named re-exports
├── types/
│ ├── index.ts
│ ├── common.ts # ExchangeRate, RateFetchOptions, RateFetchResult,
│ │ # CurrencyProvider, VirtualCurrencyConfig,
│ │ # ConverterConfig, ConvertOptions, ConversionResult
│ └── common.md
├── error-codes/
│ ├── index.ts
│ ├── error-codes.ts # CURRENCY_CODES + CurrencyError
│ ├── error-codes.md
│ └── error-codes.test.ts
├── schemas/
│ ├── index.ts
│ ├── schemas.ts # Zod schemas + parse helpers raising CurrencyError
│ ├── schemas.md
│ └── schemas.test.ts
├── rate-cache/
│ ├── index.ts
│ ├── rate-cache.ts # RateCache class
│ ├── rate-cache.md
│ └── rate-cache.test.ts
├── provider/
│ ├── index.ts
│ ├── provider.ts # InMemoryProvider + createStaticProvider
│ ├── provider.md
│ └── provider.test.ts
└── converter/
├── index.ts
├── converter.ts # CurrencyConverter class
├── converter.md
└── converter.test.ts
```
## Public surface
Re-exported from the package root (`@suaveplan/currency`):
- Classes: `CurrencyConverter`, `RateCache`, `InMemoryProvider`, `CurrencyError`.
- Factories: `createStaticProvider`.
- Constants: `CURRENCY_CODES`.
- Schemas: `ConverterConfigSchema`, `ConvertOptionsSchema`,
`VirtualCurrencyConfigSchema`, `RateFetchOptionsSchema`.
- Types: `ConversionResult`, `ConverterConfig`, `ConvertOptions`,
`CurrencyProvider`, `ExchangeRate`, `RateFetchOptions`, `RateFetchResult`,
`VirtualCurrencyConfig`, `CurrencyErrorCode`, `CurrencyErrorOptions`.
Every public module is also reachable via a subpath export
(`@suaveplan/currency/converter/converter` etc.) so consumers can tree-shake.
## Behavioural notes
### Triangulation
The migration preserves the upstream single-hop-via-cache triangulation. When
`getRate(from, to)` is called and the cache has neither a direct nor an inverse
entry, `RateCache.getRate` walks every cached base entry looking for one whose
`rates` map contains both `from` and `to`. If found, the converter records the
intermediary base in `ConversionResult.triangulatedThrough = [base]`. If not
found, the converter fetches fresh data from the primary provider against
`from`; if that fails, it falls back to the chain; if still nothing produces a
direct rate, it raises `CurrencyError({ code: CURRENCY.RATE_UNAVAILABLE })`.
Multi-hop BFS is explicitly out of scope (see proposal `## Out of scope`). The
`triangulatedThrough` field returns `string[]` to leave room for the future
multi-hop change without breaking the type.
### Fallback chain & cancellation
Providers are tried in order: primary then `fallbackProviders`. The first one
whose `fetchRates` resolves wins; its result is set in the cache and virtual
rates are re-seeded. If `options.signal` is supplied and aborts between
provider attempts, the chain stops immediately and the converter raises
`CurrencyError({ code: CURRENCY.ABORTED })`. If every provider fails the chain
raises `CurrencyError({ code: CURRENCY.PROVIDER_UNAVAILABLE })` whose `cause`
carries the last underlying error and whose `providerName` discriminant is the
last attempted provider.
### Virtual currency seeding
`registerVirtualCurrencies` is called from the constructor when
`config.virtualCurrencies` is provided. Each entry is registered through
`@suaveplan/money`'s `registerCurrency` (so `Money.fromMajor("100", "GOLD")`
works for callers), and its rate against `defaultBase` is added to the
`virtualRates` private map. `seedVirtualRates` writes those rates into the
cache entry keyed by `defaultBase`. After every successful provider fetch the
seeding runs again so virtual rates survive provider refreshes.
`setVirtualRate(code, rate)` and `updateVirtualRate(code, rate)` mutate the
private map and call `seedVirtualRates`; the two are aliases — the audit
mentions both names.
### `ConversionResult.providerName`
- `"<cache>"` — the rate was served from the cache (no provider call).
- `"<virtual>"` — the rate was seeded by the virtual-currency configuration.
- `"<identity>"` — `from === to` short-circuit.
- otherwise — the `name` of the provider whose `fetchRates` produced the entry
the cache resolved against.
### Zod boundary
`ConverterConfigSchema` validates the constructor argument. `ConvertOptionsSchema`,
`RateFetchOptionsSchema`, and `VirtualCurrencyConfigSchema` validate every other
public entry point. Failures raise `CurrencyError` with the appropriate code
(`INVALID_CONFIG` for the constructor; `INVALID_OPTIONS` for everything else)
and the Zod issue list on `error.context.issues`.
### Strict typing
`CurrencyProvider` and every option bundle is a top-level named interface (per
AGENTS.md §11 — no inline shapes). `CurrencyConverter` is generic-free; future
markup-policy work can graft generics on without breaking the current surface.
## Dependencies
- `@suaveplan/money` (`workspace:^`) — currency metadata, `MoneyLike`,
`minorUnitMultiplier`, `registerCurrency`, `getCurrencyOrThrow`.
- `@suaveplan/error` (`workspace:^`) — `SuaveplanError`, `defineCodes`.
- `zod` (`catalog:forms`) — boundary validation.
- `tslib` (`catalog:build`).
## Test plan
- Every requirement in the spec delta has a `**Verified by:**` line naming
concrete `it(...)` titles in the test files above.
- Real implementations only — no mocks. AbortSignal is exercised via
`AbortController` + `queueMicrotask` to land a real abort between fallback
attempts.
- Coverage gate: `provider: "istanbul"`, all four axes at 100%.