Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# `money`
Payments, currencies, cryptocurrency, and financial primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 20 packages.
### `finance`
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
| Package | Description |
|---|---|
| [`@suaveplan/cryptocurrency`](./cryptocurrency/README.md) | Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-s… |
| [`@suaveplan/currency`](./currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primary + f… |
| [`@suaveplan/currency-provider-binance`](./currency-provider-binance/README.md) | Binance public-ticker crypto-rates provider for `@suaveplan/currency`. Sibling package in the foundation-with-sibling-plugins shape: implements the `CurrencyPr… |
| [`@suaveplan/currency-provider-coingecko`](./currency-provider-coingecko/README.md) | Universal CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/currency` against the public `/sim… |
| [`@suaveplan/currency-provider-coinmarketcap`](./currency-provider-coinmarketcap/README.md) | CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One of the nine `@suaveplan/currency-provider-<name>` sibling packages spun out of the 2… |
| [`@suaveplan/currency-provider-cryptocompare`](./currency-provider-cryptocompare/README.md) | Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`. Sibling provider package on top of the slim `@suaveplan/currency-providers` core (`htt… |
| [`@suaveplan/currency-provider-ecb`](./currency-provider-ecb/README.md) | Universal `@suaveplan/currency` rate provider for the European Central Bank's daily exchange-rates XML feed. Free, no API key required. Built on the slim `@sua… |
| [`@suaveplan/currency-provider-fixer`](./currency-provider-fixer/README.md) | Fixer.io fiat-rates sibling provider for the slim `@suaveplan/currency-providers` core. Implements the `CurrencyProvider` contract from `@suaveplan/currency`,… |
| [`@suaveplan/currency-provider-kraken`](./currency-provider-kraken/README.md) | Universal Kraken public-ticker rate provider for `@suaveplan/currency`. Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a `CurrencyProvider` s… |
| [`@suaveplan/currency-provider-open-exchange-rates`](./currency-provider-open-exchange-rates/README.md) | Universal `@suaveplan/currency` provider for openexchangerates.org. Routes `/latest.json` through the slim-core `httpRequest()` helper, validates the upstream… |
| [`@suaveplan/currency-provider-virtual`](./currency-provider-virtual/README.md) | In-memory, programmable virtual / game currency provider for `@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers` slim plugin core. No HTTP, n… |
| [`@suaveplan/currency-providers`](./currency-providers/README.md) | Slim plugin core for `@suaveplan/currency` rate providers. Ships the `CurrencyProviderRegistry` (name-keyed lookup), the `defineCurrencyProvider` factory, the… |
| [`@suaveplan/money`](./money/README.md) | Universal precise currency and money arithmetic using integer minor-unit representation. All operations use `bigint` so consumers never hit floating-point roun… |
### `payment`
Payment intent lifecycle, provider adapters, and fraud detection.
| Package | Description |
|---|---|
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestration, r… |
| [`@suaveplan/payment-provider-adyen`](./payment-provider-adyen/README.md) | Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`. Implements the `PaymentProvider` interface via the slim `@suaveplan/payment-providers` c… |
| [`@suaveplan/payment-provider-mollie`](./payment-provider-mollie/README.md) | Universal Mollie Payments API v2 adapter for `@suaveplan/payment`. Sibling provider package on the slim `@suaveplan/payment-providers` core: implements `Paymen… |
| [`@suaveplan/payment-provider-paypal`](./payment-provider-paypal/README.md) | PayPal sibling adapter for the slim `@suaveplan/payment-providers` foundation. Ships `PayPalProvider` — a `PaymentProvider` implementation against the PayPal O… |
| [`@suaveplan/payment-provider-square`](./payment-provider-square/README.md) | Universal Square Payments API adapter for `@suaveplan/payment`. Ships the `SquareProvider` class (implements the structural `PaymentProvider` contract with lit… |
| [`@suaveplan/payment-provider-stripe`](./payment-provider-stripe/README.md) | Universal Stripe adapter for `@suaveplan/payment`. Implements `PaymentProvider` against the Stripe REST API using the slim `@suaveplan/payment-providers` found… |
| [`@suaveplan/payment-providers`](./payment-providers/README.md) | Universal **foundation** for the `@suaveplan/payment-provider-*` sibling-package family. This package ships only the shared abstractions every concrete adapter… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `money`
Payments, currencies, cryptocurrency, and financial primitives.

View File

@@ -0,0 +1,25 @@
# `finance`
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
<!-- begin:auto-docs -->
## Packages in `finance`
13 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cryptocurrency`](./cryptocurrency/README.md) | Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-s… |
| [`@suaveplan/currency`](./currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primary + f… |
| [`@suaveplan/currency-provider-binance`](./currency-provider-binance/README.md) | Binance public-ticker crypto-rates provider for `@suaveplan/currency`. Sibling package in the foundation-with-sibling-plugins shape: implements the `CurrencyPr… |
| [`@suaveplan/currency-provider-coingecko`](./currency-provider-coingecko/README.md) | Universal CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/currency` against the public `/sim… |
| [`@suaveplan/currency-provider-coinmarketcap`](./currency-provider-coinmarketcap/README.md) | CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One of the nine `@suaveplan/currency-provider-<name>` sibling packages spun out of the 2… |
| [`@suaveplan/currency-provider-cryptocompare`](./currency-provider-cryptocompare/README.md) | Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`. Sibling provider package on top of the slim `@suaveplan/currency-providers` core (`htt… |
| [`@suaveplan/currency-provider-ecb`](./currency-provider-ecb/README.md) | Universal `@suaveplan/currency` rate provider for the European Central Bank's daily exchange-rates XML feed. Free, no API key required. Built on the slim `@sua… |
| [`@suaveplan/currency-provider-fixer`](./currency-provider-fixer/README.md) | Fixer.io fiat-rates sibling provider for the slim `@suaveplan/currency-providers` core. Implements the `CurrencyProvider` contract from `@suaveplan/currency`,… |
| [`@suaveplan/currency-provider-kraken`](./currency-provider-kraken/README.md) | Universal Kraken public-ticker rate provider for `@suaveplan/currency`. Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a `CurrencyProvider` s… |
| [`@suaveplan/currency-provider-open-exchange-rates`](./currency-provider-open-exchange-rates/README.md) | Universal `@suaveplan/currency` provider for openexchangerates.org. Routes `/latest.json` through the slim-core `httpRequest()` helper, validates the upstream… |
| [`@suaveplan/currency-provider-virtual`](./currency-provider-virtual/README.md) | In-memory, programmable virtual / game currency provider for `@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers` slim plugin core. No HTTP, n… |
| [`@suaveplan/currency-providers`](./currency-providers/README.md) | Slim plugin core for `@suaveplan/currency` rate providers. Ships the `CurrencyProviderRegistry` (name-keyed lookup), the `defineCurrencyProvider` factory, the… |
| [`@suaveplan/money`](./money/README.md) | Universal precise currency and money arithmetic using integer minor-unit representation. All operations use `bigint` so consumers never hit floating-point roun… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Currency conversion, cryptocurrency utilities, and exchange-rate providers.

View File

@@ -0,0 +1,147 @@
# @suaveplan/cryptocurrency - Universal cryptocurrency utilities: wallet address validation across 15 chains with EIP-55 checksumming, unit conversion (ETH/BTC/SOL), chain registry with async adapter, EIP-1559 gas estimation, ERC-20 token registry, calldata/selector decoder, transaction hash parsing. Zero browser/Node deps; injectable LoggerLike; typed CryptocurrencyError hierarchy.
Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / function-selector decoding, and transaction hash parsing. Zero browser or Node-specific dependencies — runs unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. Not a wallet, not a signing library — just pure utility primitives.
<!-- begin:auto-docs -->
## Documentation tree
- [address](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
- [validator](./src/address/validator.md) — Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for c…
- [chain-data](./src/chains/chain-data.md) — Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet networ…
- [chain-registry](./src/chains/chain-registry.md) — Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime regist…
- [registry](./src/chains/registry.md) — Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` clas…
- [gas](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
- [gas-estimator](./src/gas/gas-estimator.md) — Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values…
- [token-data](./src/tokens/token-data.md) — Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USD…
- [token-registry](./src/tokens/token-registry.md) — Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus…
- [transaction](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
- [tx-decoder](./src/transaction/tx-decoder.md) — Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` c…
- [units](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
- [converter](./src/units/converter.md) — Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/cryptocurrency
```
## Quick start
```ts
import {
estimateGas,
formatGwei,
getChain,
getExplorerTxUrl,
satoshiToBitcoin,
validateAddress,
weiToEther,
} from "@suaveplan/cryptocurrency";
// Multi-chain address validation with auto-detection
const r = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
// { valid: true, normalized: "0x...", chains: ["ethereum", "polygon", ...] }
// Unit conversion (bigint-pure)
weiToEther("1000000000000000000"); // "1"
satoshiToBitcoin("100000000"); // "1"
// Chain metadata + explorer URLs
const eth = getChain("ethereum");
// { id: "ethereum", evmChainId: 1, decimals: 18, ... }
const url = getExplorerTxUrl("ethereum", "0xabc...");
// EIP-1559 gas estimation
const estimate = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n,
maxPriorityFeePerGas: 2000000000n,
});
formatGwei(estimate.gasPrice); // "32"
```
## Features
- **Address validation across 15 chains** (Bitcoin, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BSC, Base, TRON, Litecoin, Dogecoin, Cardano, Polkadot, Cosmos) via `validateAddress` / `validateAddressForChain`.
- **Transaction hash classification** (EVM / UTXO / Solana) via `validateTxHash` and `decodeTxHash`.
- **EIP-55 checksumming** for EVM addresses (simplified positional algorithm preserved from upstream; full keccak256 deferred to a follow-up change).
- **Static chain registry** with `getChain` / `getAllChainIds` / `getEvmChains` / `getChainByEvmId` / explorer URL helpers.
- **Async chain registry** (`ChainRegistry` + `MemoryChainRegistry`) supporting custom adapters and typed `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })`.
- **Bigint-pure unit conversion** across ETH / BTC / SOL (`convertUnits`, `weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`, `getUnits`).
- **EIP-1559 gas estimation** (`estimateGas`, `calculateMaxFeePerGas`, `calculateEffectiveGasPrice`, `formatGwei`, `estimateTxCostEth`).
- **ERC-20 token registry** (`TokenRegistry`, `COMMON_TOKENS`, `formatTokenBalance`) — case-insensitive address lookups, substring search by symbol / name.
- **Calldata + function-selector decoder** (`KNOWN_SELECTORS`, `lookupSelector`, `extractSelector`, `decodeCalldata`).
- **Typed `CryptocurrencyError` hierarchy** with `CRYPTOCURRENCY.*` code registry and optional `chainId` / `currency` / `unit` discriminants.
- **Optional `LoggerLike` injection** on `ChainRegistry` (no-op default) emitting `chain.registered` / `chain.lookup.miss` events.
- **Tree-shakeable subpath exports** — import only what you use.
- **Universal tier** — no `node:*`, no `bun:*`, no `fs` / `path` / `crypto` imports.
## Modules
Co-located docs for every implementation file:
- [`src/address/validator.md`](./src/address/validator.md) — `validateAddress`, `validateAddressForChain`, `validateTxHash`.
- [`src/chains/chain-data.md`](./src/chains/chain-data.md) — `CHAINS` constant.
- [`src/chains/chain-registry.md`](./src/chains/chain-registry.md) — `ChainRegistry`, `MemoryChainRegistry`, `LoggerLike` integration.
- [`src/chains/registry.md`](./src/chains/registry.md) — synchronous getters.
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `CRYPTOCURRENCY_CODES`, `CryptocurrencyError`.
- [`src/gas/gas-estimator.md`](./src/gas/gas-estimator.md) — EIP-1559 gas helpers.
- [`src/tokens/token-data.md`](./src/tokens/token-data.md) — `COMMON_TOKENS`.
- [`src/tokens/token-registry.md`](./src/tokens/token-registry.md) — `TokenRegistry`, `formatTokenBalance`.
- [`src/transaction/tx-decoder.md`](./src/transaction/tx-decoder.md) — calldata / selector / tx-hash decoder.
- [`src/types/crypto.types.md`](./src/types/crypto.types.md) — public type contract.
- [`src/units/converter.md`](./src/units/converter.md) — generic + convenience unit conversion.
## Subpath exports
Every public module ships its own subpath so consumers can tree-shake:
```ts
import { validateAddress } from "@suaveplan/cryptocurrency/address/validator";
import { CHAINS } from "@suaveplan/cryptocurrency/chains/chain-data";
import { ChainRegistry } from "@suaveplan/cryptocurrency/chains/chain-registry";
import { getChain } from "@suaveplan/cryptocurrency/chains/registry";
import { CRYPTOCURRENCY_CODES } from "@suaveplan/cryptocurrency/error-codes/error-codes";
import { estimateGas } from "@suaveplan/cryptocurrency/gas/gas-estimator";
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency/tokens/token-data";
import { TokenRegistry } from "@suaveplan/cryptocurrency/tokens/token-registry";
import { decodeCalldata } from "@suaveplan/cryptocurrency/transaction/tx-decoder";
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
import { convertUnits } from "@suaveplan/cryptocurrency/units/converter";
```
## Error handling
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
convertUnits,
} from "@suaveplan/cryptocurrency";
try {
convertUnits("1", "wei", "ether", "XYZ");
} catch (err) {
if (err instanceof CryptocurrencyError) {
if (err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY) {
console.error(`unsupported currency: ${err.currency}`);
}
}
}
```
## What this package is not
- **Not a wallet** — no key management, no transaction signing.
- **Not an RPC client** — no on-chain calls; the `ChainRegistry` adapter contract lets you wire your own RPC layer.
- **Not an ABI decoder** — `decodeCalldata` only recognises 4-byte selectors. Full ABI decoding (dynamic types, tuples) lands in a follow-up change.
- **Not an ENS resolver** — name-resolution lands in a separate package.
- **Not RFC-correct EIP-55** — uses a simplified positional checksum preserved from upstream; full keccak256 lands in a follow-up change.
## License
MIT

View File

@@ -0,0 +1,84 @@
# validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the three address / hash validation primitives that the rest of the package builds on: `validateAddress` for chain auto-detection from an address string, `validateAddressForChain` for chain-locked validation, and `validateTxHash` for transaction-hash classification. All three return structured `AddressValidation` / `TxHashValidation` records rather than throwing, so callers can route on success / failure without try-catch ceremony. The validators recognise 11 distinct address shapes across 15 chains and three transaction-hash shapes across the EVM / UTXO / Solana families.
This module is the broadest consumer-facing entry point in the package — every wallet, payment, or explorer integration starts here. The structured-return contract (no throws) was inherited from upstream and preserved so existing consumers can migrate without changing call sites.
## Features
- 15-chain address detection (`validateAddress`) covering EVM hex, Bitcoin legacy + Segwit, Litecoin legacy + Segwit, Solana base58, TRON base58, Dogecoin base58, Cardano bech32, Cosmos bech32, Polkadot ss58.
- Chain-locked validation (`validateAddressForChain`) narrowing the result to a single id on success.
- Transaction hash classification (`validateTxHash`) returning the set of chains a hash could belong to.
- Positional EIP-55 checksumming on EVM `normalized` output (preserved bit-for-bit from upstream — see Implementation Notes for the keccak256 deferral).
- Leading/trailing whitespace trimmed on every input.
- Structured failure returns (`{ valid: false, chains: [], error: "..." }`) — no throws.
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
## Usage: Basic
```ts
import { validateAddress, validateTxHash } from "@suaveplan/cryptocurrency";
const r1 = validateAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD28");
// { valid: true, chains: ["ethereum", "polygon", ...], normalized: "0x..." }
const r2 = validateTxHash(`0x${"a".repeat(64)}`);
// { valid: true, chains: ["ethereum", "polygon", ...] }
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
type ChainId,
validateAddressForChain,
} from "@suaveplan/cryptocurrency";
function requireAddressForChain(address: string, chainId: ChainId): string {
const result = validateAddressForChain(address, chainId);
if (!result.valid) {
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.INVALID_ADDRESS,
result.error ?? `Invalid address for ${chainId}`,
{ chainId }
);
}
return result.normalized ?? address;
}
const normalized = requireAddressForChain(userInput, "ethereum");
```
This pattern lets application-tier consumers turn the validators' structured returns into typed throws when their architecture prefers exceptions. The exposed `CRYPTOCURRENCY.INVALID_ADDRESS` code makes the failure mode programmatically dispatchable.
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `validateAddress` | `(address: string) => AddressValidation` | auto-detects chains; populates `normalized` (positional-EIP-55 checksum) for EVM matches |
| `validateAddressForChain` | `(address: string, chainId: ChainId) => AddressValidation` | narrows to a single chain on success; propagates `validateAddress`'s failure verbatim |
| `validateTxHash` | `(hash: string) => TxHashValidation` | classifies EVM / UTXO / Solana hash shapes |
### Failure messages
- Empty / null / non-string `address` → `"Address must be a non-empty string"`
- No pattern matches → `"Address does not match any known format"`
- Address validates but not for the requested chain → `` `Address is not valid for ${chainId}` ``
- Empty / null / non-string `hash` → `"Hash must be a non-empty string"`
- No hash pattern matches → `"Hash does not match any known format"`
## Implementation Notes
- The `ADDRESS_PATTERNS` table is the single source of truth for address detection. Adding a chain means adding one entry to the table and one entry to the `ChainId` union — no other code changes required.
- The EIP-55 checksum implementation is **NOT** RFC-correct. It is a simplified positional algorithm (`even-index alpha → uppercase, odd-index alpha → lowercase`) preserved bit-for-bit from upstream so consumers depending on its output keep working. The audit (`enterprise/cryptocurrency.md` L57-58) flags this gap; the swap to keccak256 lands in a follow-up change gated on `@suaveplan/crypto-hash` being wired in.
- The transaction-hash classifier returns plain arrays (not the readonly tuples it stores internally) so callers can freely mutate the result without affecting subsequent calls.
- All regex patterns are anchored (`^...$`) to prevent partial-string matches.
- The validators never throw — the only `throw` pattern in the entire module would be unreachable, so the public contract is "data in, structured data out, no exceptions."

View File

@@ -0,0 +1,62 @@
# chain-data
## Purpose
Holds the canonical `CHAINS` constant: a `Record<ChainId, ChainInfo>` containing one entry per supported mainnet network. This is the single source of truth for every chain-related lookup in the package — the synchronous helpers in `registry.ts`, the default seed for `MemoryChainRegistry`, and the `validateAddress` chain-detection table all derive their data from here.
The constant is shipped as a plain object literal rather than being constructed at runtime, so consumers' bundlers can dead-code-eliminate the entries they don't use when importing via the `./chains/chain-data` subpath export.
## Features
- Exactly 15 entries: bitcoin, ethereum, solana, polygon, arbitrum, optimism, avalanche, bsc, base, tron, litecoin, dogecoin, cardano, polkadot, cosmos.
- EVM chains carry the canonical mainnet `evmChainId` (Ethereum 1, BSC 56, Polygon 137, Optimism 10, Base 8453, Arbitrum 42161, Avalanche 43114).
- Non-EVM chains omit `evmChainId`.
- Every entry declares `id`, `name`, `nativeSymbol`, `addressType`, `explorerUrl`, `decimals`, `testnet: false`.
- Explorer URLs are base templates — the synchronous helpers in `registry.ts` and the async `ChainRegistry` append `/address/<addr>` or `/tx/<hash>` to produce concrete URLs.
- Pure data export — no runtime initialisation, no side effects.
## Usage: Basic
```ts
import { CHAINS } from "@suaveplan/cryptocurrency";
const eth = CHAINS.ethereum;
// { id: "ethereum", name: "Ethereum", evmChainId: 1, decimals: 18, ... }
```
## Usage: Advanced
```ts
import { CHAINS } from "@suaveplan/cryptocurrency";
import type { ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
function evmChainsByDecimals(): Map<number, ChainInfo[]> {
const out = new Map<number, ChainInfo[]>();
for (const chain of Object.values(CHAINS)) {
if (chain.evmChainId === undefined) continue;
const bucket = out.get(chain.decimals) ?? [];
bucket.push(chain);
out.set(chain.decimals, bucket);
}
return out;
}
const grouped = evmChainsByDecimals();
// Map { 18 => [ethereum, polygon, arbitrum, optimism, avalanche, bsc, base] }
```
Iterating `Object.values(CHAINS)` is the canonical way to enumerate the registry; the `ChainId` union and the `Record` constraint guarantee TypeScript sees every key as a literal string.
## API Reference
| Export | Type | Notes |
|---|---|---|
| `CHAINS` | `Record<ChainId, ChainInfo>` | 15-entry mainnet registry |
## Implementation Notes
- The constant is `const` but **not** `as const` — the entries are mutable to allow consumers to spread-merge their own overrides (`{ ...CHAINS, custom: { ... } }`). If callers want a frozen view they can wrap with `Object.freeze` themselves.
- The object literal layout is alphabetised by mnemonic within each group (Bitcoin / EVM / Solana / others) for readability; the iteration order matters for `validateAddress`'s chain-detection table because the EVM regex matches a superset of single-EVM-chain addresses.
- Adding a new mainnet chain requires four edits: extend the `ChainId` union in `types/crypto.types.ts`, add a `CHAINS` entry here, add a row to the `ADDRESS_PATTERNS` table in `address/validator.ts`, and extend the chain-data test's `EXPECTED_IDS` list.
- Testnet support is deliberately scoped out of this migration — testnets land in `add-cryptocurrency-testnets` per `enterprise/cryptocurrency.md` §4.
- The `explorerUrl` values are base hosts only; the trailing path segments (`/address/...`, `/tx/...`) are added by `registry.ts` so they can be swapped without rewriting the table.

View File

@@ -0,0 +1,126 @@
# chain-registry
## Purpose
Provides the async chain-registry façade for callers that need dynamic chain data — custom mainnet sets, runtime registrations, DB-backed or HTTP-backed adapters. `MemoryChainRegistry` is the default in-memory implementation of the `ChainRegistryAdapter` contract; `ChainRegistry` is a thin façade over an injected adapter that adds an `LoggerLike` integration and raises typed `CryptocurrencyError` for missing-chain explorer-URL lookups.
Use this module when the synchronous `registry.ts` helpers (which read from the built-in `CHAINS` constant) are insufficient — for example, when callers want to register their own chain mid-process, when chain data is loaded from a backend, or when typed-error dispatch is preferred over `undefined` checks.
## Features
- `MemoryChainRegistry implements ChainRegistryAdapter` — `Map`-backed in-memory storage; preloads built-in `CHAINS` or accepts a custom `ChainInfo[]` seed.
- `ChainRegistry` — façade over any `ChainRegistryAdapter`; defaults to a fresh `MemoryChainRegistry` when no adapter is provided.
- Optional `LoggerLike` injection on `ChainRegistry` constructor — no-op default.
- Operational events: `chain.registered` (info) on `registerChain`; `chain.lookup.miss` (debug) when an explorer URL is requested for an unknown chain.
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by `getExplorerAddressUrl` / `getExplorerTxUrl` for unknown chains — typed dispatch instead of `undefined`.
- Universal — no `node:*` / `bun:*` / `fs` / `path` / `crypto` imports.
## Usage: Basic
```ts
import { ChainRegistry } from "@suaveplan/cryptocurrency";
const registry = new ChainRegistry();
const eth = await registry.getChain("ethereum");
const url = await registry.getExplorerTxUrl("ethereum", "0xabc...");
```
## Usage: Advanced
```ts
import {
ChainRegistry,
type ChainInfo,
type ChainRegistryAdapter,
MemoryChainRegistry,
} from "@suaveplan/cryptocurrency";
import type { LoggerLike } from "@suaveplan/types";
// Wrap an HTTP-backed source as a ChainRegistryAdapter
class HttpChainAdapter implements ChainRegistryAdapter {
public constructor(private readonly baseUrl: string) {}
public async getChain(id: ChainInfo["id"]): Promise<ChainInfo | undefined> {
const res = await fetch(`${this.baseUrl}/chains/${id}`);
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
}
public async getAllChains(): Promise<ChainInfo[]> {
const res = await fetch(`${this.baseUrl}/chains`);
return res.json() as Promise<ChainInfo[]>;
}
public async getChainByEvmId(id: number): Promise<ChainInfo | undefined> {
const res = await fetch(`${this.baseUrl}/chains/evm/${id}`);
return res.ok ? (res.json() as Promise<ChainInfo>) : undefined;
}
public async registerChain(): Promise<void> {
throw new Error("read-only adapter");
}
}
const logger: LoggerLike = {
debug: (m, c) => console.log("[debug]", m, c),
info: (m, c) => console.log("[info]", m, c),
warn: (m, c) => console.warn("[warn]", m, c),
error: (m, c) => console.error("[error]", m, c),
};
const remote = new ChainRegistry(
new HttpChainAdapter("https://chains.example.com"),
logger
);
try {
await remote.getExplorerTxUrl("unsupported" as ChainInfo["id"], "0xabc");
} catch (err) {
// CryptocurrencyError { code: "CRYPTOCURRENCY.UNKNOWN_CHAIN", chainId: "unsupported" }
}
// MemoryChainRegistry also accepts a custom seed for tests
const empty = new MemoryChainRegistry([]);
```
## API Reference
### `MemoryChainRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(initialChains?: ChainInfo[])` | preloads built-in `CHAINS` when no seed is provided |
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | |
| `getAllChains` | `() => Promise<ChainInfo[]>` | returns a snapshot array |
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | |
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | upsert by `chain.id` |
### `ChainRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(adapter?: ChainRegistryAdapter, logger?: LoggerLike)` | defaults to fresh `MemoryChainRegistry` + no-op logger |
| `getChain` | `(id: ChainId) => Promise<ChainInfo | undefined>` | forwards to adapter |
| `getAllChains` | `() => Promise<ChainInfo[]>` | forwards to adapter |
| `getChainByEvmId` | `(evmChainId: number) => Promise<ChainInfo | undefined>` | forwards to adapter |
| `registerChain` | `(chain: ChainInfo) => Promise<void>` | forwards to adapter; logs `chain.registered` (info) |
| `getEvmChains` | `() => Promise<ChainInfo[]>` | filter of `getAllChains()` |
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => Promise<string>` | raises `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains; logs `chain.lookup.miss` (debug) |
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => Promise<string>` | same error/logger behaviour as above |
### Error contract
- `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN, chainId })` raised by both explorer URL methods when the adapter returns `undefined`.
### Logger events
- `chain.registered` — info — `{ chainId, evmChainId }` — emitted on every successful `registerChain` call.
- `chain.lookup.miss` — debug — `{ chainId }` — emitted before raising `UNKNOWN_CHAIN`.
## Implementation Notes
- `MemoryChainRegistry` stores entries in a `Map<string, ChainInfo>` keyed by `chain.id`. The key type is `string` (not `ChainId`) so callers can register chains whose ids are not in the built-in `ChainId` union — useful for sidechains or testnets registered at runtime. Reading via `getChain(id)` still requires a `ChainId` because that's the contract; consumers wanting to look up runtime-registered ids should cast (`"sepolia" as ChainId`) and accept the typing risk.
- `ChainRegistry`'s constructor accepts the adapter and logger as positional optional parameters in that order. This mirrors the pattern used by sibling packages (`SeedRunner(registry, store, logger?)`) so reviewers can spot the wiring quickly.
- The default `NOOP_LOGGER` has empty method bodies, not `() => undefined`, so the V8 inliner can eliminate the call entirely under `-O3` profiles.
- `getExplorerAddressUrl` and `getExplorerTxUrl` are intentionally duplicated rather than refactored to a shared helper — the duplication is two lines, the call sites are the entire reason the typed error exists, and extracting would obscure the error's discriminant context.
- The `chain.lookup.miss` debug event is emitted **before** the throw so structured-log consumers can correlate the miss with the error. The throw is unconditional; consumers wanting silent-undefined behaviour should use the synchronous `registry.ts` helpers instead, which return `undefined` from `getChain` directly.
- Tier purity: only `@suaveplan/types` (types-only) and `@suaveplan/error` (universal) imports — no `node:*`, no `bun:*`.

View File

@@ -0,0 +1,71 @@
# registry (synchronous chain getters)
## Purpose
Exposes the synchronous fast-path over the built-in `CHAINS` constant. Every helper here is a pure function that reads from `CHAINS` and returns either the matching `ChainInfo` or a derived value (filtered list, concatenated explorer URL). Use this module when you only need read access to the static mainnet registry — it has zero allocation overhead beyond the returned arrays.
For dynamic registries (custom chain sets, DB-backed lookups, runtime registrations) use `ChainRegistry` from `./chain-registry` instead.
## Features
- `getChain(id)` — direct lookup against the built-in `CHAINS` constant.
- `getAllChainIds()` — returns all 15 ids in registry order.
- `getEvmChains()` — filtered view of EVM-compatible chains.
- `getChainByEvmId(evmChainId)` — reverse lookup by canonical EVM chain id.
- `getExplorerAddressUrl(chainId, address)` — `${explorerUrl}/address/${address}`.
- `getExplorerTxUrl(chainId, txHash)` — `${explorerUrl}/tx/${txHash}`.
- Pure functions — no internal state, no side effects, safe to call from any context.
## Usage: Basic
```ts
import { getChain, getExplorerTxUrl } from "@suaveplan/cryptocurrency";
const eth = getChain("ethereum");
const url = getExplorerTxUrl("ethereum", "0xabc...");
```
## Usage: Advanced
```ts
import {
getChainByEvmId,
getEvmChains,
getExplorerAddressUrl,
} from "@suaveplan/cryptocurrency";
function buildEvmDashboard(evmChainId: number, address: string) {
const chain = getChainByEvmId(evmChainId);
if (!chain) return null;
return {
chain: chain.name,
symbol: chain.nativeSymbol,
explorer: getExplorerAddressUrl(chain.id, address),
siblings: getEvmChains()
.filter((c) => c.id !== chain.id)
.map((c) => c.name),
};
}
const dashboard = buildEvmDashboard(137, "0xabc...");
// { chain: "Polygon", symbol: "POL", explorer: "...", siblings: ["Ethereum", "Arbitrum One", ...] }
```
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `getChain` | `(id: ChainId) => ChainInfo` | direct, total — no `undefined` because `ChainId` is a closed union over the built-in keys |
| `getAllChainIds` | `() => ChainId[]` | order is the `CHAINS` literal's declaration order |
| `getEvmChains` | `() => ChainInfo[]` | filtered by `evmChainId !== undefined` |
| `getChainByEvmId` | `(evmChainId: number) => ChainInfo | undefined` | reverse lookup; returns `undefined` for unknown ids |
| `getExplorerAddressUrl` | `(chainId: ChainId, address: string) => string` | template + `/address/<addr>` |
| `getExplorerTxUrl` | `(chainId: ChainId, txHash: string) => string` | template + `/tx/<hash>` |
## Implementation Notes
- `getChain` is total because `ChainId` is a closed union over the keys of `CHAINS`. Passing a string asserted as `ChainId` (`"unknown" as ChainId`) bypasses the type check and yields `undefined` at runtime — callers responsible for the cast are responsible for the consequence.
- `getAllChainIds` returns a fresh array on each call so callers can mutate it (push/sort/filter) without affecting the registry.
- `getEvmChains` allocates a fresh array each call — for hot paths, cache the result.
- The explorer URL helpers do **not** URL-encode the address or tx-hash argument. Hex addresses and hashes are safe in URLs by definition; if the caller passes a value containing reserved characters, the resulting URL will be malformed. This is intentional — encoding would be lossy for hex inputs (e.g. converting `0x` to `0x` is a no-op but obscures intent).
- For dynamic chain registries (custom mainnet sets, DB-backed adapters, runtime additions), use `ChainRegistry` and `MemoryChainRegistry` from `./chain-registry` — they implement the async `ChainRegistryAdapter` contract and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CHAIN })` on missing chains.

View File

@@ -0,0 +1,94 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the stable, namespaced error registry for `@suaveplan/cryptocurrency` and the typed `CryptocurrencyError` class that wraps every failure path. The registry uses `defineCodes("CRYPTOCURRENCY", [...])` to produce a frozen, literal-typed map (`{ readonly UNKNOWN_CHAIN: "CRYPTOCURRENCY.UNKNOWN_CHAIN", ... }`) so consumers can dispatch programmatically on `err.code === CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN` with full TypeScript narrowing.
Centralising the registry guarantees the error contract is single-sourced — every throw site in the package picks from the same five-code set, and the exposed `CryptocurrencyErrorCode` union travels with consumers' code so a future code addition triggers an exhaustiveness check at every switch site.
## Features
- Five codes covering every distinct failure mode in the migrated surface: `UNKNOWN_CHAIN`, `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT`, `INVALID_ADDRESS`, `INVALID_HASH`.
- `CryptocurrencyError extends SuaveplanError<CryptocurrencyErrorCode>` with optional `chainId` / `currency` / `unit` discriminants for diagnostics.
- `CryptocurrencyErrorCode` literal union exported for consumer narrowing.
- `CryptocurrencyErrorOptions` extends `SuaveplanErrorOptions` so `cause`, `context`, `traceId`, and `clock` are all available.
- The registry is `Object.freeze`d so consumers cannot mutate the code map.
## Usage: Basic
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
} from "@suaveplan/cryptocurrency";
throw new CryptocurrencyError(
CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN,
"Unknown chain: ethereum",
{ chainId: "ethereum" }
);
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
type CryptocurrencyErrorCode,
} from "@suaveplan/cryptocurrency";
function describeFailure(err: unknown): string {
if (!(err instanceof CryptocurrencyError)) return "non-crypto error";
const code: CryptocurrencyErrorCode = err.code;
switch (code) {
case CRYPTOCURRENCY_CODES.UNKNOWN_CHAIN:
return `unknown chain: ${err.chainId ?? "<unset>"}`;
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
return `unknown currency: ${err.currency ?? "<unset>"}`;
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
return `unknown unit '${err.unit}' for ${err.currency}`;
case CRYPTOCURRENCY_CODES.INVALID_ADDRESS:
return `invalid address${err.chainId ? ` for ${err.chainId}` : ""}`;
case CRYPTOCURRENCY_CODES.INVALID_HASH:
return `invalid hash${err.chainId ? ` for ${err.chainId}` : ""}`;
}
}
try {
// some package call
} catch (err) {
console.error(describeFailure(err));
}
```
The exhaustive switch above will fail to compile if a new code is added without a matching arm — that is the point of exporting `CryptocurrencyErrorCode`.
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `CRYPTOCURRENCY_CODES` | const | frozen `{ readonly <REASON>: "CRYPTOCURRENCY.<REASON>" }` map |
| `CryptocurrencyErrorCode` | type | union of every registered code's string literal |
| `CryptocurrencyError` | class | `extends SuaveplanError<CryptocurrencyErrorCode>`; constructor `(code, message, options?)` |
| `CryptocurrencyErrorOptions` | interface | `extends SuaveplanErrorOptions`; adds optional `chainId`, `currency`, `unit` |
### `CryptocurrencyError` discriminants
- `chainId?: ChainId` — set on `UNKNOWN_CHAIN`, `INVALID_ADDRESS`, `INVALID_HASH` raises.
- `currency?: string` — set on `UNKNOWN_CURRENCY`, `UNKNOWN_UNIT` raises.
- `unit?: string` — set on `UNKNOWN_UNIT` raises.
All discriminants are read-only and `undefined` when not supplied — consumers should narrow with `if (err.chainId)` rather than asserting non-null.
## Implementation Notes
- `INVALID_ADDRESS` and `INVALID_HASH` are exposed in the registry even though the package's own validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing. The codes are there for application-tier consumers that want to wrap a failed validation in a typed throw. They are exercised by direct construction in `error-codes.test.ts`, satisfying the conformance rule (§13 step 4 in `AGENTS.md`) that every registered code must be reachable from a call site somewhere in `src/`.
- `defineCodes` produces a `Object.freeze`d record — mutating `CRYPTOCURRENCY_CODES` throws in strict mode. Consumers must rely on the literal types, not runtime extension.
- `CryptocurrencyError` extends `SuaveplanError` (rule §63 of `CLAUDE.md`) so every error in the package participates in cause-chain traversal, OTel span events, redaction, and i18n message resolution.
- The error's `name` field is automatically set to `"CryptocurrencyError"` by `SuaveplanError`'s use of `new.target.name`.
- The optional discriminants are assigned via `if (options.X !== undefined)` so the field stays `undefined` (and serialises away from JSON) when not provided.

View File

@@ -0,0 +1,119 @@
# gas-estimator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides EIP-1559 gas-estimation primitives for Ethereum-style chains. Every helper accepts and returns `bigint` values so callers never lose precision at the JavaScript `number` boundary — gas costs in wei routinely exceed `Number.MAX_SAFE_INTEGER` (2^53 - 1) so passing them as `number` is a bug waiting to happen. The module covers the four canonical operations: estimating a full `GasEstimate` from a single set of parameters, computing the EIP-1559 ceiling (`2 * baseFee + priority`), computing the effective gas price (`min(maxFee, baseFee + priority)`), and formatting wei values as human-readable gwei or ETH decimal strings.
This module is consumed by wallet UIs, transaction-builder libraries, and analytics dashboards. The bigint-everywhere contract is the headline guarantee.
## Features
- `estimateGas(params)` — full EIP-1559 estimator returning `GasEstimate` (gasLimit, gasPrice, maxFeePerGas, maxPriorityFeePerGas, totalCost). Honours an optional `legacyGasPrice` override for non-EIP-1559 transactions.
- `calculateMaxFeePerGas(baseFee, priorityFee)` — EIP-1559 ceiling formula.
- `calculateEffectiveGasPrice(baseFee, maxFee, priorityFee)` — `min(maxFee, baseFee + priorityFee)`.
- `formatGwei(weiValue)` — sign-preserving decimal gwei string with trailing-zero trim.
- `estimateTxCostEth(gasLimit, gasPriceWei)` — decimal ETH string (18-decimal contraction with trim).
- 100% `bigint` arithmetic — no `Number` conversion anywhere on the hot path.
- Universal — no `node:*` / `bun:*` / `fs` / `crypto` imports.
## Usage: Basic
```ts
import { estimateGas, formatGwei } from "@suaveplan/cryptocurrency";
const estimate = estimateGas({
gasLimit: 21000n,
baseFeePerGas: 30000000000n, // 30 gwei
maxPriorityFeePerGas: 2000000000n, // 2 gwei
});
console.log(formatGwei(estimate.gasPrice)); // "32"
console.log(estimate.totalCost); // 672000000000000n wei = 0.000672 ETH
```
## Usage: Advanced
```ts
import {
calculateEffectiveGasPrice,
calculateMaxFeePerGas,
estimateGas,
estimateTxCostEth,
} from "@suaveplan/cryptocurrency";
interface BlockHeader {
baseFeePerGas: bigint;
}
interface UserPreferences {
priorityFee: bigint;
gasLimit: bigint;
}
function quoteUserTransaction(header: BlockHeader, prefs: UserPreferences) {
// Cap our max-fee at 2x base + priority so we don't get gouged on
// sudden base-fee spikes
const maxFee = calculateMaxFeePerGas(header.baseFeePerGas, prefs.priorityFee);
const effective = calculateEffectiveGasPrice(
header.baseFeePerGas,
maxFee,
prefs.priorityFee
);
const estimate = estimateGas({
gasLimit: prefs.gasLimit,
baseFeePerGas: header.baseFeePerGas,
maxPriorityFeePerGas: prefs.priorityFee,
});
return {
...estimate,
capMaxFee: maxFee,
effectivePrice: effective,
humanCost: `${estimateTxCostEth(prefs.gasLimit, effective)} ETH`,
};
}
const quote = quoteUserTransaction(
{ baseFeePerGas: 50000000000n },
{ priorityFee: 2000000000n, gasLimit: 21000n }
);
// { ..., humanCost: "0.000021 * effectivePrice / 10^18 ETH" }
```
## API Reference
| Export | Signature | Returns |
|---|---|---|
| `estimateGas` | `(params: GasEstimateParams) => GasEstimate` | full estimate |
| `calculateMaxFeePerGas` | `(baseFee: bigint, priorityFee: bigint) => bigint` | `2 * base + priority` |
| `calculateEffectiveGasPrice` | `(baseFee: bigint, maxFee: bigint, priorityFee: bigint) => bigint` | `min(maxFee, baseFee + priorityFee)` |
| `formatGwei` | `(weiValue: bigint) => string` | decimal gwei, signed, trimmed |
| `estimateTxCostEth` | `(gasLimit: bigint, gasPriceWei: bigint) => string` | decimal ETH, trimmed |
### `GasEstimate` shape
```ts
interface GasEstimate {
gasLimit: bigint;
gasPrice: bigint; // legacy override or maxFeePerGas
maxFeePerGas: bigint; // baseFee + priorityFee
maxPriorityFeePerGas: bigint;
totalCost: bigint; // gasLimit * gasPrice
}
```
## Implementation Notes
- `estimateGas`'s `maxFeePerGas` is computed as `baseFee + priorityFee` rather than the EIP-1559 ceiling (`2 * base + priority`) — the ceiling is computed separately via `calculateMaxFeePerGas` because some workflows want the conservative target (`base + priority`) while others want the worst-case ceiling. Callers wanting the ceiling can pass it via `legacyGasPrice` to override `gasPrice`.
- `formatGwei` divides by `GWEI = 10^9n` and uses `padStart(9, "0")` to render the fractional part. Trailing zeros are trimmed with `/0+$/` so `1100000000n` becomes `"1.1"`, not `"1.100000000"`.
- `estimateTxCostEth` divides by `10^18n` and uses `padStart(18, "0")` for the fractional ETH part. The same trailing-zero trim applies.
- Negative wei values in `formatGwei` are supported — the absolute value drives the math and the sign is reapplied at the end.
- `estimateTxCostEth` does not currently support negative inputs (gas refunds are out of scope for this primitive); the upstream library's behaviour is preserved bit-for-bit.
- EIP-4844 blob-gas helpers are **not** included — `calculateBlobGasPrice` and friends land in `add-cryptocurrency-blob-gas` as called out in the enterprise audit (`enterprise/cryptocurrency.md` §3).
- All arithmetic is `bigint`-pure; `formatGwei` and `estimateTxCostEth` are the only places where strings are produced, and even those use `BigInt.prototype.toString` exclusively.

View File

@@ -0,0 +1,64 @@
# token-data
## Purpose
Provides the `COMMON_TOKENS` constant: a curated list of eight well-known ERC-20 tokens on Ethereum mainnet — USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC. The list is used as the default seed for `TokenRegistry` when constructed with `preload = true` (the default). Consumers that want a richer registry can construct `new TokenRegistry(false)` and call `addToken` themselves, or build on top of `COMMON_TOKENS` by spreading the array.
This module exists as a static export so consumers can dead-code-eliminate it when they don't need a token registry; the entries here are byte-identical to upstream so any consumer relying on the addresses or decimals keeps working.
## Features
- Eight well-known mainnet ERC-20s: USDT, USDC, DAI, WBTC, WETH, LINK, UNI, MATIC.
- Every entry carries `address`, `symbol`, `decimals`, `chainId` (always `"ethereum"`), and `name`.
- Stablecoins use 6 decimals (USDT, USDC), WBTC uses 8, the rest use 18.
- Pure data export — no runtime initialisation, no side effects.
- Mutable array — consumers can spread it to build their own seeded registries.
## Usage: Basic
```ts
import { COMMON_TOKENS } from "@suaveplan/cryptocurrency";
const usdt = COMMON_TOKENS.find((t) => t.symbol === "USDT");
// { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", symbol: "USDT", decimals: 6, ... }
```
## Usage: Advanced
```ts
import {
COMMON_TOKENS,
type TokenInfo,
TokenRegistry,
} from "@suaveplan/cryptocurrency";
const myTokens: TokenInfo[] = [
...COMMON_TOKENS,
{
address: "0x111111111117dC0aa78b770fA6A738034120C302",
symbol: "1INCH",
decimals: 18,
chainId: "ethereum",
name: "1inch",
},
];
const registry = new TokenRegistry(false);
for (const token of myTokens) registry.addToken(token);
const oneInch = registry.getToken("ethereum", "0x111111111117dC0aa78b770fA6A738034120C302");
```
## API Reference
| Export | Type | Notes |
|---|---|---|
| `COMMON_TOKENS` | `TokenInfo[]` | 8 entries; mainnet ERC-20s only |
## Implementation Notes
- The array is `const`-bound but **not** `as const` — entries are mutable so callers can spread-merge their own overrides without TypeScript widening complaints.
- All addresses are EIP-55-checksummed (the canonical upstream representation) — `TokenRegistry.getToken` normalises lookups to lowercase, so consumers can pass any casing.
- Adding a new token to `COMMON_TOKENS` requires no other code changes — the token is automatically picked up by `TokenRegistry`'s default preload.
- The list is intentionally short. A more comprehensive registry (Uniswap token list, CoinGecko export) belongs in a downstream `@suaveplan/cryptocurrency-tokens` package; keeping the core curated avoids shipping a 200KB JSON blob in the universal package.
- All entries are on `ethereum` chainId; multi-chain entries (USDC on Polygon, USDT on BSC, etc.) are deliberately out of scope for the core list — they belong to chain-specific catalogues.

View File

@@ -0,0 +1,99 @@
# token-registry
## Purpose
Provides the `TokenRegistry` class for adding, retrieving, and searching ERC-20 / token information across chains, plus the `formatTokenBalance` helper for converting raw bigint balances into human-readable decimal strings. The registry is a thin `Map`-backed in-memory store keyed by `${chainId}:${address.toLowerCase()}`, so lookups are case-insensitive on the address — callers don't have to pre-normalise.
`TokenRegistry` is intentionally store-agnostic in its API but in-memory in its implementation. An RPC-backed `OnChainTokenRegistry` that dynamically fetches `decimals()` / `symbol()` from chain via an injected RPC client is called out in the enterprise audit (`enterprise/cryptocurrency.md` §4) but lands in a follow-up change.
## Features
- `addToken(token)` — upsert by chain + address.
- `getToken(chainId, address)` — case-insensitive lookup.
- `getTokensByChain(chainId)` — filtered list of all tokens on a chain.
- `searchBySymbol(query)` — case-insensitive substring match on `symbol`.
- `searchByName(query)` — case-insensitive substring match on `name`.
- `getAllTokens()` — snapshot array of every registered token.
- `size` accessor for fast count.
- Constructor accepts `preload = true` (default) to seed with `COMMON_TOKENS`; `false` starts empty.
- `formatTokenBalance(rawBalance, decimals)` — bigint → decimal string with sign + trailing-zero trim.
## Usage: Basic
```ts
import { formatTokenBalance, TokenRegistry } from "@suaveplan/cryptocurrency";
const registry = new TokenRegistry();
const usdt = registry.getToken(
"ethereum",
"0xdAC17F958D2ee523a2206206994597C13D831ec7"
);
const balance = formatTokenBalance(1500000n, usdt?.decimals ?? 6);
// "1.5"
```
## Usage: Advanced
```ts
import {
formatTokenBalance,
type TokenInfo,
TokenRegistry,
} from "@suaveplan/cryptocurrency";
class PortfolioView {
private readonly registry = new TokenRegistry();
public addCustomTokens(tokens: TokenInfo[]) {
for (const t of tokens) this.registry.addToken(t);
}
public formatHolding(
chainId: TokenInfo["chainId"],
address: string,
rawBalance: bigint
): string {
const token = this.registry.getToken(chainId, address);
if (!token) return `${rawBalance} (unknown token)`;
const balance = formatTokenBalance(rawBalance, token.decimals);
return `${balance} ${token.symbol}`;
}
public stablecoinHoldings(chainId: TokenInfo["chainId"]) {
return this.registry
.getTokensByChain(chainId)
.filter((t) => ["USDT", "USDC", "DAI"].includes(t.symbol));
}
}
```
## API Reference
### `TokenRegistry`
| Method | Signature | Notes |
|---|---|---|
| constructor | `(preload?: boolean = true)` | `false` starts empty |
| `addToken` | `(token: TokenInfo) => void` | upsert by `${chainId}:${address.toLowerCase()}` |
| `getToken` | `(chainId: ChainId, address: string) => TokenInfo | undefined` | case-insensitive address |
| `getTokensByChain` | `(chainId: ChainId) => TokenInfo[]` | filtered snapshot |
| `searchBySymbol` | `(query: string) => TokenInfo[]` | case-insensitive substring |
| `searchByName` | `(query: string) => TokenInfo[]` | case-insensitive substring |
| `getAllTokens` | `() => TokenInfo[]` | snapshot of every entry |
| `size` | accessor `number` | fast count |
### `formatTokenBalance`
| Signature | Returns |
|---|---|
| `(rawBalance: bigint, decimals: number) => string` | decimal string, signed, trimmed |
## Implementation Notes
- The internal `Map` key includes both `chainId` and `address.toLowerCase()` so multi-chain tokens with the same address (USDC on ethereum + USDC on polygon — wait, these have different addresses, but the pattern matters for sidechains) don't collide. The lowercase normalisation means callers never need to pre-checksum.
- `addToken` is an upsert — adding a token with an `(chainId, address)` pair that already exists replaces the entry. This is the contract the test suite pins.
- `searchBySymbol` matches against the uppercased symbol; `searchByName` matches against the lowercased name. Both are substring matches via `String.prototype.includes`, not regex, so the queries are literal — callers cannot pass regex metacharacters and expect them to be honoured.
- `formatTokenBalance` short-circuits to `rawBalance.toString()` when `decimals === 0` (no decimal point needed). For non-zero decimals it pads the fractional part with leading zeros, trims trailing zeros, and re-applies the sign. The `|| "0"` fallback handles the case where the fractional part is entirely zero after trimming.
- `formatTokenBalance` mirrors `formatGwei` and `estimateTxCostEth` in `gas-estimator.ts` — same bigint-only arithmetic, same trim algorithm. The duplication is intentional; refactoring to a shared helper would obscure the per-call-site decimal constant.
- Token-data updates (adding `LINK`, removing `MATIC`) are isolated to `token-data.ts`; this module never references the constants directly.
- An on-chain RPC-backed registry is scoped out per the proposal — it lands in `add-cryptocurrency-on-chain-tokens` once an `RpcClient` abstraction lands in `@suaveplan/types`.

View File

@@ -0,0 +1,108 @@
# tx-decoder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides naive transaction-hash classification and 4-byte function-selector decoding for EVM calldata. `decodeTxHash` classifies a transaction hash into one of `"evm" | "utxo" | "solana" | "unknown"` and returns the set of chains the hash could plausibly belong to. `extractSelector`, `lookupSelector`, and `decodeCalldata` work together to identify a function call from raw calldata when the called function is in the curated `KNOWN_SELECTORS` table (eight well-known ERC-20 / ERC-721 functions).
This module is a deliberate MVP: it does not decode argument bytes (ABI-aware decoding is scoped out per `enterprise/cryptocurrency.md` §3) and does not consult an online selector database (`4byte.directory` / `openchain.xyz`). Both gaps land in the `add-cryptocurrency-abi` follow-up change.
## Features
- `decodeTxHash(hash)` — classifies EVM (0x + 64 hex), UTXO (64 hex), Solana (base58 87-88 char) hashes and returns `TxMetadata { hash, chains, valid, hashType, hashHex? }`.
- `extractSelector(calldata)` — extracts the first 10 characters of the trimmed lowercased calldata when it looks like a function call; returns `undefined` otherwise.
- `lookupSelector(selector)` — case-insensitive lookup against the `KNOWN_SELECTORS` table.
- `decodeCalldata(calldata)` — composition of `extractSelector` then `lookupSelector`.
- `KNOWN_SELECTORS` — curated list of 8 well-known ERC-20 / ERC-721 function signatures.
- All inputs are trimmed; all matching is case-insensitive on hex.
## Usage: Basic
```ts
import {
decodeCalldata,
decodeTxHash,
extractSelector,
} from "@suaveplan/cryptocurrency";
const tx = decodeTxHash(`0x${"a".repeat(64)}`);
// { hash: "0xaa...", chains: ["ethereum", ...], valid: true, hashType: "evm", hashHex: "aa..." }
const sel = extractSelector("0xa9059cbb000000000000000000000000...");
// "0xa9059cbb"
const fn = decodeCalldata("0xa9059cbb000000000000000000000000...");
// { selector: "0xa9059cbb", name: "transfer", signature: "transfer(address,uint256)" }
```
## Usage: Advanced
```ts
import {
decodeCalldata,
type FunctionSignature,
KNOWN_SELECTORS,
lookupSelector,
} from "@suaveplan/cryptocurrency";
// Extend the curated table with project-specific signatures via in-memory composition
function makeSelectorResolver(extras: FunctionSignature[]) {
const merged = new Map<string, FunctionSignature>();
for (const s of [...KNOWN_SELECTORS, ...extras]) {
merged.set(s.selector.toLowerCase(), s);
}
return (selectorOrCalldata: string): FunctionSignature \| undefined => {
const baseline = decodeCalldata(selectorOrCalldata) ??
lookupSelector(selectorOrCalldata);
if (baseline) return baseline;
// Try the merged table as fallback for the project-specific extras
const normalised = selectorOrCalldata.toLowerCase().slice(0, 10);
return merged.get(normalised);
};
}
const resolve = makeSelectorResolver([
{
selector: "0xdeadbeef",
name: "myCustom",
signature: "myCustom(bytes32,address)",
},
]);
```
This pattern keeps the package's curated `KNOWN_SELECTORS` immutable while letting projects layer their own signatures on top — no need for a mutable global registry inside the package.
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `KNOWN_SELECTORS` | `FunctionSignature[]` | 8 well-known selectors (transfer, approve, transferFrom, balanceOf, allowance, totalSupply, safeTransferFrom, setApprovalForAll) |
| `decodeTxHash` | `(hash: string) => TxMetadata` | classifies EVM / UTXO / Solana hashes |
| `lookupSelector` | `(selector: string) => FunctionSignature \| undefined` | case-insensitive on selector |
| `extractSelector` | `(calldata: string) => string \| undefined` | first 10 chars of `0x` + ≥ 8 hex chars |
| `decodeCalldata` | `(calldata: string) => FunctionSignature \| undefined` | composition of extract + lookup |
### `TxMetadata` shape
```ts
interface TxMetadata {
hash: string;
chains: ChainId[];
valid: boolean;
hashType: "evm" | "utxo" | "solana" | "unknown";
hashHex?: string; // populated for evm / utxo paths
}
```
## Implementation Notes
- The hash matchers are anchored regexes (`^...$`) to prevent partial matches. EVM (`0x` + 64 hex) is checked first because it would also match the UTXO pattern if the `0x` prefix were optional. The Solana base58 pattern excludes ambiguous characters (`0`, `O`, `I`, `l`) so it cannot collide with the hex patterns.
- `hashHex` is omitted for the Solana path (base58 is not hex). For the EVM path, the prefix is stripped; for the UTXO path, the whole input is lowercased.
- `extractSelector` requires `0x` + at least 8 hex characters — calldata containing only a selector (`"0xa9059cbb"`, 10 chars) qualifies; anything shorter is rejected. The slice always takes 10 chars (`"0x"` + 8 hex), even if more are present.
- `KNOWN_SELECTORS` is a mutable array (not `as const`) so consumers can spread it into custom collections. Sharing the array reference with callers that mutate it would corrupt the package's behaviour, but that is a misuse not worth defending against — `Object.freeze` would lose the spread-friendly mutability without preventing reassignment of indexed entries' fields anyway.
- The curated selector list is deliberately small. A comprehensive registry (4byte.directory export, openchain.xyz dump) belongs in a downstream `@suaveplan/cryptocurrency-selectors` package; keeping the core curated avoids shipping a ~50MB JSON blob.
- ABI-aware argument decoding (dynamic types, tuples, nested arrays) is **not** in this module. It lands in `add-cryptocurrency-abi` along with the pluggable online resolver interface — see `enterprise/cryptocurrency.md` §3.
- The `hashType: "unknown"` branch returns `chains: []` and `valid: false` — callers should dispatch on `valid` first, then on `hashType` for the success cases.

View File

@@ -0,0 +1,95 @@
# crypto.types
## Purpose
This module is the type-only foundation of `@suaveplan/cryptocurrency`. It declares every cross-cutting type the package exposes — the `ChainId` union of supported networks, the `ChainInfo` metadata record, the `ChainRegistryAdapter` async contract, validation result shapes for addresses and transaction hashes, gas-estimation inputs and outputs, token-registry records, transaction metadata, and the `FunctionSignature` calldata-decoder record. Importing from `@suaveplan/cryptocurrency/types/crypto.types` keeps consumers' bundles free of runtime code when they only need typings.
The types here drive the entire public surface of the package: every helper, every class, every error path references one or more of them. Keeping them in a single, dependency-free module guarantees there is one canonical home for the contract — no duplicate `ChainInfo` shapes drifting between modules, no fork between the validators and the registry.
## Features
- `ChainId` union covering the 15 mainnet networks the package validates against.
- `AddressType` discriminant (`"base58" | "bech32" | "hex" | "bech32m" | "ss58"`).
- `ChainInfo` metadata record with optional `evmChainId` distinguishing EVM from non-EVM chains.
- `ChainRegistryAdapter` async contract for pluggable chain storage.
- Validation result shapes (`AddressValidation`, `TxHashValidation`) returning structured `valid` / `chains` / `error` data rather than throwing.
- Gas-estimation contract (`GasEstimate`, `GasEstimateParams`) with `bigint` throughout to avoid number-precision pitfalls.
- Token-registry record (`TokenInfo`) and transaction metadata (`TxMetadata`) with `hashType` discriminant.
- `FunctionSignature` 4-byte selector record for the calldata decoder.
- Zero runtime code — pure `.d.ts` after build.
## Usage: Basic
```ts
import type { ChainId, ChainInfo } from "@suaveplan/cryptocurrency/types/crypto.types";
const id: ChainId = "ethereum";
const info: ChainInfo = {
id,
name: "Ethereum",
nativeSymbol: "ETH",
evmChainId: 1,
addressType: "hex",
explorerUrl: "https://etherscan.io",
decimals: 18,
testnet: false,
};
```
## Usage: Advanced
```ts
import type {
ChainInfo,
ChainRegistryAdapter,
} from "@suaveplan/cryptocurrency/types/crypto.types";
class DbBackedRegistry implements ChainRegistryAdapter {
public constructor(private readonly db: { query: (sql: string) => Promise<ChainInfo[]> }) {}
public async getChain(id: ChainInfo["id"]) {
const rows = await this.db.query(`select * from chains where id = '${id}'`);
return rows[0];
}
public async getAllChains() {
return this.db.query("select * from chains");
}
public async getChainByEvmId(evmChainId: number) {
const rows = await this.db.query(`select * from chains where evm_chain_id = ${evmChainId}`);
return rows[0];
}
public async registerChain(chain: ChainInfo) {
await this.db.query(`insert into chains values (${JSON.stringify(chain)})`);
}
}
```
The adapter contract is async by design so callers can plug in IndexedDB, SurrealDB, or HTTP-backed implementations without changing the consumer-facing API.
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `ChainId` | union type | 15 mainnet chain ids |
| `AddressType` | union type | discriminant on `ChainInfo.addressType` |
| `ChainInfo` | interface | chain metadata; `evmChainId` optional, `testnet` always `false` for built-ins |
| `CryptoUnit` | interface | unit definition (`name`, `decimals`, `symbol`) |
| `AddressValidation` | interface | `{ valid, normalized?, chains, error? }` |
| `TxHashValidation` | interface | `{ valid, chains, error? }` |
| `TokenInfo` | interface | ERC-20 record; `address` keys are normalised case-insensitively by `TokenRegistry` |
| `GasEstimate` | interface | bigint fields only |
| `GasEstimateParams` | interface | inputs to `estimateGas`; `legacyGasPrice` optional |
| `TxMetadata` | interface | `hashType: "evm" \| "utxo" \| "solana" \| "unknown"` |
| `FunctionSignature` | interface | calldata-selector record |
| `ChainRegistryAdapter` | interface | async chain-storage contract |
## Implementation Notes
- No inline object shapes in interface bodies — every nested type is a named declaration (CLAUDE.md rule §11).
- `AddressType` is a separate union so consumers can dispatch on it without re-declaring the variants.
- `ChainInfo.evmChainId` is optional rather than `number | null` to keep the JSON serialisation clean and match upstream behaviour.
- The validators return structured `AddressValidation` / `TxHashValidation` records rather than throwing — callers that prefer typed throws can wrap the result and raise `CryptocurrencyError({ code: CRYPTOCURRENCY.INVALID_ADDRESS | INVALID_HASH })` themselves.
- Every interface field is documented inline so the generated `.d.ts` carries JSDoc to consumers' IDEs.

View File

@@ -0,0 +1,97 @@
# converter (units)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides generic and convenience unit conversion for cryptocurrency denominations. The generic `convertUnits` operates over the three built-in currency registries (ETH: wei / kwei / mwei / gwei / microether / milliether / ether; BTC: satoshi / microbitcoin / millibitcoin / bitcoin; SOL: lamport / sol) and uses `bigint` arithmetic end-to-end so callers never lose precision at the `Number` boundary. Convenience wrappers (`weiToEther`, `etherToWei`, `gweiToEther`, `satoshiToBitcoin`, `bitcoinToSatoshi`, `lamportToSol`) are thin specialisations for the common operations. `getUnits(currency)` returns the full registry for a currency so UIs can enumerate denominations.
Errors are typed: `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_CURRENCY })` for currencies outside ETH/BTC/SOL, `CryptocurrencyError({ code: CRYPTOCURRENCY.UNKNOWN_UNIT })` for unrecognised denominations.
## Features
- Generic `convertUnits(value, fromUnit, toUnit, currency)` across ETH / BTC / SOL.
- Six convenience wrappers for common conversions.
- `getUnits(currency)` for enumerating registered denominations.
- Bigint-pure arithmetic — no `Number` boundary on the hot path.
- Case-insensitive currency tags.
- Typed `CryptocurrencyError` with `currency` / `unit` discriminants for programmatic dispatch.
- Universal — no `node:*` / `bun:*` / `crypto` imports.
## Usage: Basic
```ts
import {
convertUnits,
etherToWei,
satoshiToBitcoin,
} from "@suaveplan/cryptocurrency";
convertUnits("1", "ether", "wei", "ETH"); // "1000000000000000000"
etherToWei("1"); // "1000000000000000000"
satoshiToBitcoin("100000000"); // "1"
```
## Usage: Advanced
```ts
import {
CRYPTOCURRENCY_CODES,
CryptocurrencyError,
convertUnits,
getUnits,
} from "@suaveplan/cryptocurrency";
function safeConvert(
value: string,
fromUnit: string,
toUnit: string,
currency: string
): { ok: true; value: string } | { ok: false; reason: string } {
try {
return { ok: true, value: convertUnits(value, fromUnit, toUnit, currency) };
} catch (err) {
if (err instanceof CryptocurrencyError) {
switch (err.code) {
case CRYPTOCURRENCY_CODES.UNKNOWN_CURRENCY:
return { ok: false, reason: `unsupported currency: ${err.currency}` };
case CRYPTOCURRENCY_CODES.UNKNOWN_UNIT:
return { ok: false, reason: `unsupported unit '${err.unit}' for ${err.currency}` };
}
}
throw err;
}
}
const supportedEth = getUnits("ETH");
// [{ name: "wei", decimals: 0, symbol: "wei" }, ..., { name: "ether", decimals: 18, symbol: "ETH" }]
```
## API Reference
| Export | Signature | Notes |
|---|---|---|
| `convertUnits` | `(value: string \| bigint, fromUnit: string, toUnit: string, currency: string) => string` | throws typed `CryptocurrencyError` for unknown currency / unit |
| `weiToEther` | `(wei: string \| bigint) => string` | convenience wrapper |
| `etherToWei` | `(ether: string \| bigint) => string` | convenience wrapper |
| `gweiToEther` | `(gwei: string \| bigint) => string` | convenience wrapper |
| `satoshiToBitcoin` | `(satoshi: string \| bigint) => string` | convenience wrapper |
| `bitcoinToSatoshi` | `(btc: string \| bigint) => string` | convenience wrapper |
| `lamportToSol` | `(lamport: string \| bigint) => string` | convenience wrapper |
| `getUnits` | `(currency: string) => CryptoUnit[]` | returns `[]` for unknown currencies (no throw) |
### Error contract
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_CURRENCY", currency })` — currency not in ETH / BTC / SOL (case-insensitive comparison after `toUpperCase`).
- `CryptocurrencyError({ code: "CRYPTOCURRENCY.UNKNOWN_UNIT", currency, unit })` — unit not in the currency's registry.
## Implementation Notes
- The `parseToBigInt` helper takes the integer part of a decimal string input via `String.prototype.split(".")[0]`. Fractional sub-units are not representable in `bigint`, and the converter's input semantics are unit-relative ("the value is already in `fromUnit`"), so dropping the fractional part matches the upstream contract.
- The conversion math distinguishes three cases: same decimals (no-op), coarser-to-finer (multiply by `10^delta`), finer-to-coarser (divide via `formatBigIntWithDecimals`). The third case is the only one that produces a fractional result string.
- `formatBigIntWithDecimals` mirrors the same trim-trailing-zeros algorithm used by `formatGwei`, `estimateTxCostEth`, and `formatTokenBalance`. The duplication is intentional — extracting a shared helper would couple modules that have no other relationship.
- `convertUnits` uppercases the currency tag before lookup, so `"btc"`, `"BTC"`, `"Btc"` all resolve to the same registry. The `currency` discriminant on the thrown error uses the **original** caller-supplied string for `UNKNOWN_CURRENCY` (since the lookup hadn't succeeded yet) and the **normalised** uppercase form for `UNKNOWN_UNIT` (since by then the currency is known good).
- `getUnits` returns `[]` rather than throwing for unknown currencies — this matches the upstream contract and lets UIs render "no denominations" without try-catch ceremony.
- The Greek `µ` symbol in the `microether` / `microbitcoin` definitions is the actual Unicode `MICRO SIGN` (U+00B5) — the file's UTF-8 encoding preserves it. Bundlers that don't handle UTF-8 source will mangle the symbol, but every modern toolchain handles it correctly.

View File

@@ -0,0 +1,84 @@
# @suaveplan/currency-provider-binance - Universal Binance public-ticker crypto-rates provider for @suaveplan/currency. Sibling provider package: implements the CurrencyProvider contract from @suaveplan/currency on top of the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). No API key required.
Binance public-ticker crypto-rates provider for `@suaveplan/currency`.
Sibling package in the foundation-with-sibling-plugins shape: implements
the `CurrencyProvider` contract on top of the slim
`@suaveplan/currency-providers` core (`httpRequest`,
`CurrencyProviderError`, `NoKeyConfig`). Universal — runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers. No API key
required.
<!-- begin:auto-docs -->
## Documentation tree
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
- [binance](./src/binance/binance.md) — `BinanceProvider` is the Binance sibling implementation of the `CurrencyProvider` contract from `@suaveplan/currency`.…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-binance @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const registry = new CurrencyProviderRegistry();
registry.register(new BinanceProvider());
const converter = new CurrencyConverter({
provider: registry.get("binance"),
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
```
Direct instantiation without the registry works exactly the same:
```ts
const converter = new CurrencyConverter({
provider: new BinanceProvider(),
});
```
## Features
- Public Binance spot ticker endpoint (`/api/v3/ticker/price`) — free,
no API key, no signature, no OAuth
- Default base `USDT`; any suffix-style base supported by Binance works
(`EUR`, `BTC`, `ETH`, etc.) by passing `base` to `fetchRates`
- Optional `targets` filter raises typed `UNKNOWN_CURRENCY` for any
target absent from the upstream listing — surfaces missing pairs
eagerly rather than silently dropping them
- Sanitised pricing: zero, negative, and non-finite ticker prices are
skipped (delisted-but-published symbols stay out of the cache)
- Rate inversion: every rate stored as `1 / price` so the cache answers
"1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters;
optional `timeoutMs` flows through to the shared `httpRequest` helper
- Typed errors via `CurrencyProviderError` carrying one of `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — no plain
`Error` ever escapes
- Universal; explicit named barrel; one subpath export beyond the
root; 100/100/100/100 istanbul coverage
## Modules
- [`src/binance/binance.md`](./src/binance/binance.md) — `BinanceProvider`
class, `BINANCE_PROVIDER_NAME`, `BINANCE_DEFAULT_BASE_URL`
## Status
`0.1.0` (pre-graduation). Follow-up to the 2026-05-17 slim refactor of
`@suaveplan/currency-providers` — see
the design proposal
for the per-package change record and
the design document for the
cross-cutting sibling-shape architecture.

View File

@@ -0,0 +1,159 @@
# BinanceProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BinanceProvider` is the Binance sibling implementation of the
`CurrencyProvider` contract from `@suaveplan/currency`. It fetches the
public Binance spot ticker (`GET /api/v3/ticker/price`) — a free,
key-less endpoint that returns the latest mid-price for every listed
trading pair — and decodes it into a `RateFetchResult` ready for the
`CurrencyConverter`'s cache. The provider is universal with a
shape that mirrors every other `@suaveplan/currency-provider-<name>`
sibling: a `NoKeyConfig`-shaped constructor with optional `baseUrl`
override and optional injectable `FetchLike`, a `fetchRates` method that
delegates HTTP transport to the slim core's `httpRequest` helper, and
typed `CurrencyProviderError`s for every failure mode (no plain
`Error`).
## Features
- Public Binance ticker endpoint — no API key, no OAuth, no signature
- Default base `USDT`; any other suffix-style base supported by Binance
(e.g. `EUR`, `BTC`, `ETH`) works by passing `base` to `fetchRates`
- Optional `targets` filter raises `UNKNOWN_CURRENCY` for any target the
upstream does not list against the requested base — surfaces missing
pairs eagerly rather than silently dropping them
- Suffix matching: a ticker `BTCUSDT` becomes `BTC` when base is `USDT`;
symbols equal to the suffix (`USDTUSDT` quirks) are defensively skipped
- Sanitised pricing: zero, negative, and non-finite prices are skipped
- Rate inversion: every rate is stored as `1 / price` so the cache
answers "1 unit of base = N units of crypto"
- Injectable `FetchLike` for tests, proxies, and edge-runtime adapters
- Optional `timeoutMs` flows through to `httpRequest` and derives an
`AbortSignal.timeout(ms)` composed with the caller's `signal`
- Typed errors via `CurrencyProviderError` + the 8-code
`CURRENCY_PROVIDER_*` registry — `HTTP_ERROR` (non-2xx), `PARSE_ERROR`
(non-JSON body), `UPSTREAM_ERROR` (unexpected shape),
`UNKNOWN_CURRENCY` (target absent from upstream listing)
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { BinanceProvider } from "@suaveplan/currency-provider-binance";
const provider = new BinanceProvider();
const converter = new CurrencyConverter({
provider,
staleDurationMs: 30_000,
});
const rate = await converter.getRate("USDT", "BTC");
console.log(rate);
```
## Usage: Advanced
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import {
BinanceProvider,
BINANCE_PROVIDER_NAME,
} from "@suaveplan/currency-provider-binance";
import type { FetchLike } from "@suaveplan/types";
// Wire a constrained FetchLike (corporate proxy, retry harness, etc.).
const proxiedFetch: FetchLike = async (url, init) => {
return await fetch(`https://proxy.example.com/${String(url)}`, init);
};
const binance = new BinanceProvider({
fetch: proxiedFetch,
baseUrl: "https://api.binance.com/api/v3",
timeoutMs: 8_000,
});
const registry = new CurrencyProviderRegistry();
registry.register(binance);
// Fetch only the pairs we care about — UNKNOWN_CURRENCY surfaces any
// upstream listing changes immediately.
const result = await registry
.get(BINANCE_PROVIDER_NAME)
.fetchRates({ base: "USDT", targets: ["BTC", "ETH", "SOL"] });
console.log(result.rates);
```
## API Reference
### `BINANCE_PROVIDER_NAME: "binance"`
Stable provider identifier — surfaced through `provider.name` and used
as the registry key.
### `BINANCE_DEFAULT_BASE_URL: "https://api.binance.com/api/v3"`
Default REST base URL. Overridable via the constructor `baseUrl` for
staging or proxy deployments.
### `class BinanceProvider implements CurrencyProvider`
Constructor: `new BinanceProvider(config?: NoKeyConfig & { timeoutMs?: number })`.
- `config.baseUrl` — overrides `BINANCE_DEFAULT_BASE_URL`
- `config.fetch` — optional injectable `FetchLike`; defaults to
`globalThis.fetch`
- `config.timeoutMs` — optional per-request timeout in milliseconds; when
set, derives an `AbortSignal.timeout` composed with the caller's
`signal`
Properties:
- `name === "binance"` (literal)
- `supportsCrypto === true`
- `supportsFiat === false`
Method: `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`.
- `options.base` — base currency suffix (default `"USDT"`)
- `options.targets` — optional list of target currencies; raises
`UNKNOWN_CURRENCY` for any target absent from the upstream listing
- `options.signal` — optional `AbortSignal` forwarded to `httpRequest`
Throws `CurrencyProviderError` with one of:
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response from Binance
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not valid JSON
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — payload is not the expected
`Array<{ symbol: string; price: string }>` shape
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — a requested target is not
listed against the requested base
## Implementation Notes
- Suffix matching: `endsWith(base)` plus a length check rejects symbols
whose entire name equals the suffix (`USDTUSDT`-style edge cases),
which would otherwise decode to an empty `""` rate key
- Pricing: `Number.parseFloat` plus `Number.isFinite && price > 0` is
the canonical "is this a usable mid-price" filter — zero, negative,
and `NaN` values are silently skipped (not surfaced as errors) because
Binance occasionally returns those for delisted-but-still-published
symbols
- Rate inversion: storing `1 / price` keeps the `RateFetchResult.rates`
map answering "1 unit of base = N units of asset", matching the
convention used by the rest of `@suaveplan/currency`
- Transport: HTTP is delegated entirely to the slim core's
`httpRequest` so every sibling provider raises identical `HTTP_ERROR`
payloads, supports the same abort/timeout composition, and benefits
from any future improvements (retry, circuit breaker) made there
- JSON parsing: `response.json()` is wrapped so non-JSON bodies become
`PARSE_ERROR` rather than leaking a runtime `SyntaxError`; payload
shape validation is a typed structural check (no runtime Zod cost in
the universal core)
- IP-based rate limiting still applies upstream; the provider is
intentionally rate-limit-agnostic — wrap it in the future
`RateLimitedProvider` when that ships

View File

@@ -0,0 +1,79 @@
# @suaveplan/currency-provider-coingecko - Universal CoinGecko sibling for @suaveplan/currency-providers. Exposes CoinGeckoProvider (free public tier + optional Pro-tier x-cg-pro-api-key) and the getCoinGeckoId symbol lookup; routes every request through the shared httpRequest helper and raises typed CurrencyProviderError on HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY.
Universal CoinGecko sibling for
[`@suaveplan/currency-providers`](../currency-providers/README.md). Implements
the `CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey` support
attached as `x-cg-pro-api-key` on every outbound request. Both free and
Pro tiers share the same URL shape, so upgrading is a single-field
config change.
<!-- begin:auto-docs -->
## Documentation tree
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coin-ids](./src/coin-ids/coin-ids.md) — Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to the opaque CoinGecko coin id (`bitcoin`, `et…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
- [coingecko](./src/coingecko/coingecko.md) — CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/curr…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-coingecko
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
// Free public tier — no API key required.
const free = new CoinGeckoProvider();
// Pro tier — pass apiKey; the same provider attaches x-cg-pro-api-key.
const pro = new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
});
const registry = new CurrencyProviderRegistry();
registry.register(pro);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000,
});
const rates = await registry
.get("coingecko")
.fetchRates({ base: "USD", targets: ["BTC", "ETH"] });
```
## Features
- `CoinGeckoProvider implements CurrencyProvider` — drops directly into
any `@suaveplan/currency` converter or `CurrencyProviderRegistry`.
- Truly optional `apiKey` — free tier requires no config; Pro tier
attaches `x-cg-pro-api-key` automatically.
- 16 curated cryptocurrencies (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA,
DOGE, DOT, AVAX, MATIC, LINK, UNI, ATOM, LTC).
- Routes every request through the shared `httpRequest()` helper from
`@suaveplan/currency-providers` for abort-signal composition,
per-call timeouts, and typed HTTP failures.
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
- Universal — zero browser / Node-only APIs. Works in Node 18+, Bun,
modern browsers, and Web Workers.
## Modules
- [`src/coingecko/coingecko.md`](./src/coingecko/coingecko.md) —
`CoinGeckoProvider` class + `CoinGeckoConfig` type.
- [`src/coin-ids/coin-ids.md`](./src/coin-ids/coin-ids.md) — `COIN_IDS`
curated map + `getCoinGeckoId(symbol)` lookup.

View File

@@ -0,0 +1,88 @@
# coin-ids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the curated mapping from uppercase ticker symbol (`BTC`, `ETH`, …) to
the opaque CoinGecko coin id (`bitcoin`, `ethereum`, …) that the
`/simple/price` endpoint requires. Without this translation step the
provider would have to either fall back to CoinGecko's heavyweight
`/coins/list` discovery endpoint (~10k entries, defeats tree-shaking) or
ask every caller to learn CoinGecko's id convention — neither is
acceptable for a slim sibling.
## Features
- Frozen `COIN_IDS` map covering the top 16 cryptos by market cap at
landing (BTC, ETH, SOL, USDT, USDC, BNB, XRP, ADA, DOGE, DOT, AVAX,
MATIC, LINK, UNI, ATOM, LTC).
- Case-insensitive `getCoinGeckoId(symbol)` lookup returning `string | undefined`.
- Zero runtime dependencies; pure data + a one-line lookup function.
## Usage: Basic
```ts
import { getCoinGeckoId } from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
getCoinGeckoId("BTC"); // "bitcoin"
getCoinGeckoId("eth"); // "ethereum" — case-insensitive
getCoinGeckoId("ZZZ"); // undefined — unknown symbol
```
## Usage: Advanced
The provider uses `getCoinGeckoId` as the proactive `UNKNOWN_CURRENCY`
gate. Consumers can mirror the same pattern when composing their own
filters before hitting the registry:
```ts
import {
COIN_IDS,
getCoinGeckoId,
} from "@suaveplan/currency-provider-coingecko/coin-ids/coin-ids";
function partitionSupported(symbols: readonly string[]): {
supported: string[];
unknown: string[];
} {
const supported: string[] = [];
const unknown: string[] = [];
for (const symbol of symbols) {
if (getCoinGeckoId(symbol) !== undefined) {
supported.push(symbol);
} else {
unknown.push(symbol);
}
}
return { supported, unknown };
}
// Iterate the full map when generating a UI dropdown:
const everyKnown = Object.keys(COIN_IDS);
```
## API Reference
### `COIN_IDS: Readonly<Record<string, string>>`
Frozen mapping from uppercase ticker (key) to CoinGecko coin id (value).
Keys are always uppercase; mutation throws because the object is frozen.
### `getCoinGeckoId(symbol: string): string | undefined`
Returns the CoinGecko coin id for the given symbol. The input is
upper-cased before the lookup, so `"btc"` and `"BTC"` both resolve to
`"bitcoin"`. Returns `undefined` for any symbol not present in
`COIN_IDS`. Does not throw.
## Implementation Notes
The map is intentionally hand-curated rather than fetched at runtime: a
sibling that issues a discovery call before every batch would (a) add a
network round trip every consumer pays for once, (b) bloat the
in-memory cache, and (c) couple this package's reliability to a second
upstream endpoint whose response shape can drift. Adding a new symbol is
a one-line PR; the trade-off is heavily skewed toward "type it in".

View File

@@ -0,0 +1,114 @@
# CoinGeckoProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
CoinGecko sibling for `@suaveplan/currency-providers`. Implements the
`CurrencyProvider` contract from `@suaveplan/currency` against the
public `/simple/price` endpoint, with optional Pro-tier `apiKey`
support that attaches the `x-cg-pro-api-key` header to every outbound
request. The free public tier (no key, 10–30 calls/min) and the Pro
tier share the same URL shape, so upgrading from free to Pro is a
single-field config change with no other wiring impact.
## Features
- `CoinGeckoProvider implements CurrencyProvider` — `name = "coingecko"`,
`supportsCrypto = true`, `supportsFiat = true`.
- Optional `apiKey` config (`x-cg-pro-api-key` header attachment).
- Optional `baseUrl` and `fetch` config (default
`https://api.coingecko.com/api/v3` and `globalThis.fetch`).
- Caller `AbortSignal` propagation via the shared `httpRequest()` helper
(composes with timeouts from the helper).
- Proactive `UNKNOWN_CURRENCY` gate — unknown `options.targets` reject
before the HTTP call is issued.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`.
## Usage: Basic
```ts
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const provider = new CoinGeckoProvider();
const result = await provider.fetchRates({ base: "USD" });
// result.rates.BTC = 0.0000149… (1 USD ≈ 0.0000149 BTC at ~$67k/BTC)
```
## Usage: Advanced
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko/coingecko/coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(
new CoinGeckoProvider({
apiKey: process.env.COINGECKO_PRO_KEY,
baseUrl: "https://pro-api.coingecko.com/api/v3",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("coingecko"),
staleDurationMs: 60_000, // 1 minute — crypto rates move fast
});
// Targets are validated against the curated COIN_IDS map BEFORE the
// HTTP call — passing an unknown target raises UNKNOWN_CURRENCY
// without making a network round trip.
const controller = new AbortController();
const rates = await registry.get("coingecko").fetchRates({
base: "EUR",
targets: ["BTC", "ETH"],
signal: controller.signal,
});
```
## API Reference
### `class CoinGeckoProvider implements CurrencyProvider`
- `constructor(config?: CoinGeckoConfig)` — config is fully optional.
- `readonly name: "coingecko"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: true`
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
- Issues `GET ${baseUrl}/simple/price?ids=<curated-ids>&vs_currencies=<base.toLowerCase()>`.
- Forwards `options.signal` to the shared `httpRequest()` helper.
- Pre-validates `options.targets` against `COIN_IDS`; an unknown entry
raises `CurrencyProviderError(UNKNOWN_CURRENCY)` before any network
activity.
- Inverts upstream "1 crypto = X fiat" prices into the converter's
"1 base = X crypto" convention.
- Throws `CurrencyProviderError` with `code` ∈ {`HTTP_ERROR`,
`PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`} carrying
`providerName === "coingecko"`.
### `interface CoinGeckoConfig extends NoKeyConfig`
- `readonly baseUrl?: string` — overrides `https://api.coingecko.com/api/v3`.
- `readonly fetch?: FetchLike` — overrides `globalThis.fetch`.
- `readonly apiKey?: string` — Pro-tier key, attached as
`x-cg-pro-api-key` header when present.
## Implementation Notes
- Pre-validating `options.targets` against `COIN_IDS` (not against the
upstream response) means the rejection message names the bad symbol
rather than swallowing it with a missing-key in the result map. This
is the difference between "your `ZZZ` symbol is not in our curated
set" (actionable) and "your rate result has no `ZZZ` field" (silent
defect).
- The shape guard (`isResponseShape`) accepts any value matching
`Record<string, Record<string, number>>` rather than asserting the
exact `bitcoin`/`ethereum` keys CoinGecko currently returns. The
upstream may add new coins or rename old ones, and a strict assertion
would treat that as a wire-format break instead of a benign superset.
- Headers are constructed once at instantiation time and frozen so the
per-request `init` spread is the only mutable copy — defends against
accidental key leakage into a downstream debugger.

View File

@@ -0,0 +1,91 @@
# @suaveplan/currency-provider-coinmarketcap - CoinMarketCap crypto-rates provider for @suaveplan/currency — sibling of @suaveplan/currency-providers. Authenticates via X-CMC_PRO_API_KEY header against the /cryptocurrency/quotes/latest endpoint. Universal (no node:/bun:/DOM imports).
CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One
of the nine `@suaveplan/currency-provider-<name>` sibling packages spun
out of the 2026-05-17 slim refactor of `@suaveplan/currency-providers`.
Consumes the slim core's `CurrencyProviderError` registry, `ApiKeyConfig`
type, and shared `httpRequest()` HTTP helper; talks to
`pro-api.coinmarketcap.com` via the `X-CMC_PRO_API_KEY` header.
<!-- begin:auto-docs -->
## Documentation tree
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
- [coinmarketcap](./src/coinmarketcap/coinmarketcap.md) — `CoinMarketCapProvider` is the `@suaveplan/currency-providers` sibling that fetches cryptocurrency spot prices from the…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-coinmarketcap @suaveplan/currency-providers @suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const registry = new CurrencyProviderRegistry();
registry.register(
new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" })
);
const converter = new CurrencyConverter({
provider: registry.get("coinmarketcap"),
});
const rate = await converter.getRate("BTC", "USD");
```
Direct instantiation works equally well:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const converter = new CurrencyConverter({
provider: new CoinMarketCapProvider({ apiKey: process.env.CMC_API_KEY ?? "" }),
});
```
## Features
- Implements `CurrencyProvider` (`name = "coinmarketcap"`,
`supportsCrypto = true`, `supportsFiat = false`)
- `ApiKeyConfig` constructor — `apiKey` required, `baseUrl` optional
(defaults to `https://pro-api.coinmarketcap.com/v1`), injectable
`FetchLike` from `@suaveplan/types`
- Authenticated via the upstream `X-CMC_PRO_API_KEY` header; `Accept`
set to `application/json` on every request
- Default target list covers the top 10 crypto by market cap
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); override via
`options.targets`
- Default `base` is `USD`; any fiat conversion currency works via
`options.base`
- Prices are inverted (`1 / price`) into the converter's expected
`1 fiat = X crypto` form
- Typed errors via `CurrencyProviderError` —
`MISSING_CONFIG` (empty `apiKey`), `HTTP_ERROR` (non-2xx),
`PARSE_ERROR` (invalid JSON / missing data), `UPSTREAM_ERROR`
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no symbol
resolved). Every error carries `providerName: "coinmarketcap"`
- HTTP plumbing (abort-signal composition, timeout, non-2xx mapping)
delegated to the slim core's `httpRequest()` — uniform with every
other sibling provider
- Universal (`sideEffects: false`); 100/100/100/100 istanbul
coverage; 2 subpath exports
## Modules
- [`src/coinmarketcap/coinmarketcap.md`](./src/coinmarketcap/coinmarketcap.md)
— `CoinMarketCapProvider`
## Status
`0.1.0` (pre-graduation). Sibling provider landed alongside the
2026-05-17 slim refactor of `@suaveplan/currency-providers`. See
the design proposal for
the change record.

View File

@@ -0,0 +1,160 @@
# `CoinMarketCapProvider` — crypto-rates sibling provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CoinMarketCapProvider` is the
`@suaveplan/currency-providers` sibling that fetches cryptocurrency
spot prices from the CoinMarketCap professional REST API
(`pro-api.coinmarketcap.com`). It implements `CurrencyProvider` from
`@suaveplan/currency` so it slots directly into `CurrencyConverter`
either by direct instantiation (`new CurrencyConverter({ provider: new
CoinMarketCapProvider(...) })`) or by registration with
`CurrencyProviderRegistry`. The class exists so consumers that need
CoinMarketCap as a primary or fallback rate source install exactly one
provider package instead of pulling in the eight other sibling
providers they do not wire in.
The class is crypto-only — `supportsCrypto = true`, `supportsFiat =
false` — because the upstream `/cryptocurrency/quotes/latest` endpoint
is a crypto quotes feed; the request still accepts any fiat conversion
currency via the `convert` query parameter, so a USD-base, EUR-base, or
GBP-base call is equally valid. Consumers that need fiat rates should
register a fiat sibling alongside (`currency-provider-ecb`,
`currency-provider-fixer`, `currency-provider-open-exchange-rates`).
## Features
- Implements `CurrencyProvider` with stable identity (`name =
"coinmarketcap"`, `supportsCrypto = true`, `supportsFiat = false`).
- Constructor accepts `ApiKeyConfig` (`apiKey`, optional `baseUrl`,
optional injectable `FetchLike`); empty / whitespace `apiKey`
immediately raises `CurrencyProviderError(MISSING_CONFIG)`.
- `X-CMC_PRO_API_KEY` + `Accept: application/json` headers carried on
every request through the shared `httpRequest()` helper, so abort
signals, timeouts, and HTTP error mapping are uniform with every
other sibling provider.
- Default target list covers the top 10 cryptocurrencies by market cap
(`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`); callers can override
via `options.targets` to query any supported symbol set.
- Default `base` is `USD`; any fiat conversion currency supported by
CoinMarketCap is accepted via `options.base`.
- Prices are inverted from CoinMarketCap's `1 crypto = X fiat` form to
the converter's expected `1 fiat = (1 / X) crypto` form before being
written into `RateFetchResult.rates`.
- Typed error mapping — `HTTP_ERROR` (non-2xx), `PARSE_ERROR` (invalid
JSON / missing `data` field / non-object data), `UPSTREAM_ERROR`
(`status.error_code` populated), `UNKNOWN_CURRENCY` (no requested
symbol resolved), `MISSING_CONFIG` (empty `apiKey`) — every error
carries `providerName: "coinmarketcap"`.
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers via the
injectable `FetchLike` seam.
## Usage: Basic
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_API_KEY ?? "",
});
const converter = new CurrencyConverter({ provider });
const rate = await converter.getRate("BTC", "USD");
```
## Usage: Advanced
Inject a `FetchLike` for testing, point at the CoinMarketCap sandbox,
restrict the target list, and forward an `AbortSignal` for cancellation:
```ts
import type { FetchLike } from "@suaveplan/types";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CoinMarketCapProvider } from "@suaveplan/currency-provider-coinmarketcap";
const recordingFetch: FetchLike = async (url, init) => {
console.log("→", url);
return await fetch(url, init);
};
const provider = new CoinMarketCapProvider({
apiKey: process.env.CMC_SANDBOX_KEY ?? "",
baseUrl: "https://sandbox-api.coinmarketcap.com/v1",
fetch: recordingFetch,
});
const registry = new CurrencyProviderRegistry();
registry.register(provider);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const result = await registry.get("coinmarketcap").fetchRates({
base: "EUR",
targets: ["BTC", "ETH", "SOL"],
signal: controller.signal,
});
console.log(result.rates);
} finally {
clearTimeout(timer);
}
```
## API Reference
### `class CoinMarketCapProvider implements CurrencyProvider`
- `readonly name: "coinmarketcap"`
- `readonly supportsCrypto: true`
- `readonly supportsFiat: false`
- `constructor(config: ApiKeyConfig)` — `config.apiKey` is required;
whitespace-only and empty strings raise
`CurrencyProviderError(MISSING_CONFIG)`. `config.baseUrl` defaults to
`https://pro-api.coinmarketcap.com/v1`. `config.fetch` defaults to
`globalThis.fetch`.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues `GET <baseUrl>/cryptocurrency/quotes/latest?symbol=<csv>&convert=<base>`
through `@suaveplan/currency-providers`'s `httpRequest()`. Returns
`{ base, rates, timestamp }` with rates inverted into base-per-unit
form. Forwards `options.signal` to the underlying fetch.
### Error codes (all from `CURRENCY_PROVIDER_CODES`)
- `MISSING_CONFIG` — empty / whitespace `apiKey` at construction.
- `HTTP_ERROR` — non-2xx HTTP response (raised by `httpRequest()`).
- `PARSE_ERROR` — invalid JSON, non-object payload, or missing `data`.
- `UPSTREAM_ERROR` — payload `status.error_code` populated and non-zero.
- `UNKNOWN_CURRENCY` — none of the requested symbols resolved.
## Implementation Notes
- The default target list (`BTC,ETH,SOL,USDT,USDC,BNB,XRP,ADA,DOGE,DOT`)
mirrors the upstream `../suaveplan/...coinmarketcap.ts` source —
these are the top ten cryptocurrencies by market cap and are the
same set every consumer of the upstream package received by default.
- Price inversion (`1 / price`) is applied per-symbol so a symbol with
a missing, non-numeric, or non-positive `price` is silently dropped
from the result. If the upstream returns `{ data: {} }` or every
symbol fails the numeric guard, the whole call raises
`UNKNOWN_CURRENCY` — the converter then falls back to its next
registered provider rather than caching a zero-entry result.
- `status.error_code` is treated as "present and non-zero" — many
CoinMarketCap success responses carry `status.error_code: 0`, which
is not a real error. The dedicated `assertNoUpstreamError` guard
preserves that distinction.
- The `parsePayload` step is intentionally lenient about the order of
validation: when both `status.error_code` and a missing `data` field
are present, the upstream error wins so consumers see the actionable
`UPSTREAM_ERROR` (and `upstreamMessage`) rather than a generic
`PARSE_ERROR`.
- All HTTP plumbing — abort-signal composition, timeout derivation,
non-2xx mapping to `HTTP_ERROR` — is delegated to
`@suaveplan/currency-providers`'s `httpRequest()`. The provider
never calls the injected `FetchLike` directly, so timeout / abort
behaviour is identical to every other sibling.

View File

@@ -0,0 +1,75 @@
# @suaveplan/currency-provider-cryptocompare - Universal CryptoCompare crypto-rates provider for @suaveplan/currency. Sibling provider package on the slim @suaveplan/currency-providers core (httpRequest, defineCurrencyProvider, CurrencyProviderError, NoKeyConfig). Optional API key — free tier works keyless; key boosts the rate limit and is sent via `authorization: Apikey <key>` header.
Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`.
Sibling provider package on top of the slim `@suaveplan/currency-providers`
core (`httpRequest`, `CurrencyProviderError`, `NoKeyConfig`). Optional API
key — CryptoCompare's free tier works fully keyless; supplying a key boosts
the upstream rate limit and is sent on every request via the
`authorization: Apikey <key>` header.
<!-- begin:auto-docs -->
## Documentation tree
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
- [cryptocompare](./src/cryptocompare/cryptocompare.md) — `CryptoCompareProvider` implements the `CurrencyProvider` contract from `@suaveplan/currency` on top of CryptoCompare's…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-cryptocompare \
@suaveplan/currency \
@suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY, // optional
});
const converter = new CurrencyConverter({
provider,
staleDurationMs: 60_000,
});
const conversion = await converter.convert(amount, "BTC");
```
Or register through the slim-core registry:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(new CryptoCompareProvider());
const provider = registry.get("cryptocompare");
```
## Modules
- [`cryptocompare/cryptocompare`](./src/cryptocompare/cryptocompare.md) —
`CryptoCompareProvider` class, `CryptoCompareConfig` interface, error
mapping for `HTTP_ERROR` / `PARSE_ERROR` / `UPSTREAM_ERROR` /
`UNKNOWN_CURRENCY`.
## Errors
Every failure path raises `CurrencyProviderError` from the slim core:
| Code | Raised when |
|---|---|
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx HTTP response (shared `httpRequest`) |
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body not valid JSON, or payload not a JSON object |
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Payload carries `Response: "Error"` |
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | No requested symbol produced a finite positive quote |
Dispatch on `error.code`, not the message string. The instance also
carries `providerName: "cryptocompare"`, optional `status` (for HTTP_ERROR),
and optional `upstreamMessage` (for UPSTREAM_ERROR).

View File

@@ -0,0 +1,146 @@
# `cryptocompare` — CryptoCompare crypto-rates provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CryptoCompareProvider` implements the `CurrencyProvider` contract from
`@suaveplan/currency` on top of CryptoCompare's public
`min-api.cryptocompare.com/data/pricemulti` endpoint. It returns inverted
spot prices (`1 / price`) for a configurable set of crypto symbols against
a fiat or crypto base currency, suitable for direct registration with
`CurrencyProviderRegistry` or instantiation into `CurrencyConverter`.
The provider is sibling to the slim `@suaveplan/currency-providers` core:
all HTTP plumbing (timeout / abort composition / typed-error mapping) lives
in the shared `httpRequest` helper, the typed error class and code registry
come from `CurrencyProviderError` + `CURRENCY_PROVIDER_CODES`, and the
config shape extends `NoKeyConfig` with one extra optional field. The
provider is universal — no Node, DOM, or framework dependencies — so
it runs untouched in browsers, Workers, Bun, and Node 18+.
## Features
- Sibling crypto-rates provider — `name: "cryptocompare"`,
`supportsCrypto: true`, `supportsFiat: false`
- Optional `apiKey` — free tier works fully keyless; key boosts the
upstream rate limit and is sent via `authorization: Apikey <key>` header
- 15 sensible default crypto symbols (BTC, ETH, SOL, USDT, USDC, BNB, XRP,
ADA, DOGE, DOT, AVAX, LINK, UNI, ATOM, LTC) when callers omit `targets`
- Injectable `FetchLike` from `@suaveplan/types` — tests pass a controlled
closure without `vi.stubGlobal('fetch', ...)`
- Typed errors via the slim core registry: `HTTP_ERROR`, `PARSE_ERROR`,
`UPSTREAM_ERROR`, `UNKNOWN_CURRENCY` — every failure path carries
`providerName: "cryptocompare"` for log dispatch
- Caller `AbortSignal` from `RateFetchOptions.signal` is composed through
`httpRequest`; whitespace-only `apiKey` strings are treated as absent
## Usage: Basic Example
```ts
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const provider = new CryptoCompareProvider();
const result = await provider.fetchRates({ base: "USD" });
console.log(result.rates.BTC); // 1 / spot price of BTC in USD
```
## Usage: Advanced Example
Register the provider with `CurrencyProviderRegistry`, use the keyed
upstream for higher rate limits, and dispatch on typed errors:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
CurrencyProviderRegistry,
} from "@suaveplan/currency-providers";
import { CryptoCompareProvider } from "@suaveplan/currency-provider-cryptocompare";
const registry = new CurrencyProviderRegistry();
registry.register(
new CryptoCompareProvider({
apiKey: process.env.CRYPTOCOMPARE_API_KEY,
baseUrl: "https://min-api.cryptocompare.com/data",
}),
);
const converter = new CurrencyConverter({
provider: registry.get("cryptocompare"),
staleDurationMs: 60_000,
});
try {
const conversion = await converter.convert(amount, "BTC");
console.log(conversion);
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR
) {
console.warn("CryptoCompare upstream:", error.upstreamMessage);
} else {
throw error;
}
}
```
## API Reference
- `interface CryptoCompareConfig extends NoKeyConfig`
- `apiKey?: string` — optional API key. Sent on every request via
`authorization: Apikey <key>` when present and non-whitespace.
Whitespace-only strings are treated as absent.
- `baseUrl?: string` — overrides the default
`https://min-api.cryptocompare.com/data` endpoint.
- `fetch?: FetchLike` — injectable WHATWG-shape fetch. Defaults to
`globalThis.fetch`.
- `class CryptoCompareProvider implements CurrencyProvider`
- `name: "cryptocompare"`, `supportsCrypto: true`,
`supportsFiat: false`.
- `constructor(config?: CryptoCompareConfig)` — every config field is
optional. Constructing without a config wires `globalThis.fetch`, the
default base URL, and no API key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
issues a single `GET <baseUrl>/pricemulti?fsyms=<targets>&tsyms=<base>`,
inverts each price, and returns `{ base, rates, timestamp }`.
`options.base` defaults to `"USD"`; `options.targets` defaults to the
15 built-in symbols when omitted or empty; `options.signal` is
forwarded into the shared `httpRequest` helper.
- Raised `CurrencyProviderError` codes:
- `HTTP_ERROR` — non-2xx response (raised by shared `httpRequest`).
- `PARSE_ERROR` — body is not valid JSON, or the parsed payload is not
a JSON object.
- `UPSTREAM_ERROR` — payload carries `Response: "Error"`; the
`Message` field (or `"unknown upstream error"` when absent) is copied
to `error.upstreamMessage`.
- `UNKNOWN_CURRENCY` — no requested symbol produced a finite positive
quote in the requested base currency.
## Implementation Notes
- CryptoCompare's `pricemulti` endpoint returns HTTP 200 even for
per-symbol upstream errors — the payload carries `Response: "Error"` and
a human-readable `Message`. The provider distinguishes that from a
successful (possibly empty) quote map and surfaces it as
`UPSTREAM_ERROR` rather than masquerading as `HTTP_ERROR`.
- The free tier permits ~100,000 calls per month without a key; supplying
an `apiKey` boosts that quota. The provider authenticates via the
`authorization: Apikey <key>` header per CryptoCompare's documentation
(Apikey scheme, not Bearer). Empty or whitespace-only keys are silently
treated as absent so callers can pass `process.env.CRYPTOCOMPARE_API_KEY`
directly without a guard.
- Prices are inverted (`1 / price`) so the result map answers "one unit of
`base` is worth X units of `symbol`" — consistent with every other
sibling provider. Non-finite or non-positive quotes are skipped.
- The default symbol list covers the top 15 crypto assets by market cap at
package-publish time; callers wanting a different set always pass
`targets` explicitly. The defaults exist only as a developer-friendly
fallback for ad-hoc usage.
- The provider does not wrap retry, backoff, or rate-limit semantics —
compose `@suaveplan/retry` / future `RateLimitedProvider` wrappers at a
higher layer, exactly the same way as every other sibling provider.

View File

@@ -0,0 +1,66 @@
# @suaveplan/currency-provider-ecb - Universal @suaveplan/currency provider for the European Central Bank (ECB) daily exchange-rates XML feed. Free, no API key. Implements the slim @suaveplan/currency-providers contract: typed CurrencyProviderError, injectable FetchLike from @suaveplan/types, shared httpRequest helper, mathematical conversion to any non-EUR base, AbortSignal propagation, target filtering, PARSE_ERROR / UNSUPPORTED_BASE / UNKNOWN_CURRENCY / HTTP_ERROR coverage.
Universal `@suaveplan/currency` rate provider for the European Central
Bank's daily exchange-rates XML feed. Free, no API key required. Built on the
slim `@suaveplan/currency-providers` plugin core: typed
`CurrencyProviderError` on every failure path, shared `httpRequest()` helper
for HTTP wrapping + abort-signal composition, injectable `FetchLike` from
`@suaveplan/types`, mathematical conversion to any non-EUR base, target
filtering with `UNKNOWN_CURRENCY` guarding, and `PARSE_ERROR` protection
against silently caching an empty rate map.
<!-- begin:auto-docs -->
## Documentation tree
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
- [ecb](./src/ecb/ecb.md) — `EcbProvider` is the `@suaveplan/currency` rate-source implementation for the European Central Bank's daily reference-r…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-ecb @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({ provider: new EcbProvider() });
const rate = await converter.getRate("EUR", "USD");
```
Or via the slim core's registry, alongside other sibling provider packages:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
const converter = new CurrencyConverter({ provider: registry.get("ecb") });
```
## Features
- Free, no API key.
- ~30 fiat currencies, EUR-quoted upstream; non-EUR bases derived
arithmetically.
- Daily refresh (~16:00 CET); weekends / holidays return the last published
snapshot.
- `RateFetchOptions.signal` forwarded through the slim core's HTTP helper.
- Typed `CurrencyProviderError` codes: `HTTP_ERROR`, `PARSE_ERROR`,
`UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY`.
- Universal (browser / Web Worker / Node 18+ / Bun).
- Zero runtime deps beyond `@suaveplan/currency`,
`@suaveplan/currency-providers`, `@suaveplan/types`, `tslib`.
## Modules
- [`src/ecb/ecb.md`](./src/ecb/ecb.md) — `EcbProvider` class, configuration,
error matrix, advanced examples.

View File

@@ -0,0 +1,143 @@
# EcbProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EcbProvider` is the `@suaveplan/currency` rate-source implementation for the
European Central Bank's daily reference-rates XML feed. It is the lowest-
friction fiat provider in the Suaveplan catalogue: free, no API key required,
universal (runs in browsers, Web Workers, Node.js, and Bun), and built on
the slim `@suaveplan/currency-providers` plugin core so every error path,
abort-signal composition, and HTTP wrapping is shared with every other sibling
provider in the family.
The package exposes a single class, `EcbProvider`, with stable identity flags
(`name === "ecb"`, `supportsFiat === true`, `supportsCrypto === false`). It is
not auto-registered with any registry — consumers wire it through either
`new CurrencyConverter({ provider: new EcbProvider() })` or
`registry.register(new EcbProvider())` against a
`CurrencyProviderRegistry` from `@suaveplan/currency-providers`.
## Features
- Free, no authentication — public ECB daily XML feed.
- ~30 fiat currencies (every major plus many minor European / global pairs).
- Updates around 16:00 CET each business day; weekends and bank holidays
return the last published snapshot.
- Default base is EUR; any non-EUR base is synthesised by inverting through
the EUR rates so callers never have to triangulate manually.
- Optional `targets` filter so consumers iterating a small basket avoid
caching the full 30-currency table.
- Typed `CurrencyProviderError` on every failure path: `HTTP_ERROR` for non-
2xx upstream responses, `PARSE_ERROR` for an unparseable body,
`UNSUPPORTED_BASE` when the requested base is missing from the EUR-quoted
table, `UNKNOWN_CURRENCY` when every requested target is absent.
- `AbortSignal` propagation — `RateFetchOptions.signal` is forwarded through
the slim core's `httpRequest()` so callers can cancel in-flight requests.
- Injectable `FetchLike` (from `@suaveplan/types`) — tests pass a recording
closure; production accepts the default `globalThis.fetch`.
- Zero runtime dependencies beyond `@suaveplan/currency-providers` /
`@suaveplan/currency` / `@suaveplan/types` / `tslib`.
## Usage
### Basic Example
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const converter = new CurrencyConverter({
provider: new EcbProvider(),
});
const rate = await converter.getRate("EUR", "USD");
console.log(`1 EUR = ${rate} USD`);
```
### Advanced Example
Wiring through the slim core's registry with a custom `baseUrl` (e.g. an
internal proxy that caches the upstream XML at the edge) and a caller-side
`AbortSignal` so a UI cancellation aborts the in-flight HTTP request.
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
const registry = new CurrencyProviderRegistry();
registry.register(
new EcbProvider({
baseUrl: "https://cdn.internal.example.com/ecb/daily.xml",
}),
);
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("user navigated away")), 5_000);
const provider = registry.get("ecb");
const result = await provider.fetchRates({
base: "USD",
targets: ["EUR", "GBP", "JPY"],
signal: controller.signal,
});
console.log(result.base, result.rates, result.timestamp);
```
## API Reference
### `class EcbProvider implements CurrencyProvider`
#### `constructor(config?: NoKeyConfig)`
- `config.baseUrl?: string` — override the default ECB endpoint
(`https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml`).
- `config.fetch?: FetchLike` — injectable `fetch` implementation; defaults to
`globalThis.fetch` resolved at call time.
#### Read-only fields
- `name: "ecb"` — stable identifier surfaced as `CurrencyProvider.name`.
- `supportsFiat: true`
- `supportsCrypto: false`
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Fetches and returns the latest ECB rates. `options.base` defaults to `"EUR"`;
when set to any other currency the result is synthesised by inverting through
the EUR-quoted feed. `options.targets` filters the returned map.
`options.signal` is forwarded to the underlying HTTP call.
**Throws** `CurrencyProviderError` with one of:
- `HTTP_ERROR` — non-2xx response (raised by the slim core's `httpRequest`).
- `PARSE_ERROR` — response body contained no parseable currency tags.
- `UNSUPPORTED_BASE` — caller asked for a non-EUR base that is not in the
EUR-quoted feed.
- `UNKNOWN_CURRENCY` — every entry in `options.targets` was absent from the
resolved rate map.
## Implementation Notes
- The ECB endpoint always publishes EUR-based rates. Non-EUR bases are
derived purely arithmetically — there is no second HTTP call. This is
the same algorithm the upstream `@suaveplan/currency-providers` shipped
before the 2026-05-17 slim refactor split it into siblings.
- XML parsing uses a regex (`currency='([A-Z]+)'\s+rate='([\d.]+)'`) rather
than a full XML parser to keep the package zero-runtime-dep beyond the
slim core and `@suaveplan/currency`. The ECB feed shape is stable; if the
upstream format changes, the failure surfaces immediately as
`PARSE_ERROR`.
- Rates are not available on weekends or ECB bank holidays; the feed returns
the last published snapshot. Consumers that need same-day freshness should
layer `@suaveplan/currency`'s `staleDurationMs` on top.
- `globalThis.fetch` is resolved at call time, not constructor time, so test
setups that replace the global between construction and the first
`fetchRates()` call still work.
- The package declares `"sideEffects": false` and ships explicit named barrels
per AGENTS.md §6a; tree-shaking unused exports is safe.

View File

@@ -0,0 +1,89 @@
# @suaveplan/currency-provider-fixer - Fixer.io fiat-rates CurrencyProvider sibling of @suaveplan/currency-providers. API-key required; 170+ fiat currencies; maps upstream error codes (105 → UNSUPPORTED_BASE, 201/202 → UNKNOWN_CURRENCY, other success:false → UPSTREAM_ERROR) to the slim core's typed CurrencyProviderError. Universal, FetchLike-injectable.
[Fixer.io](https://fixer.io/) fiat-rates sibling provider for the slim
`@suaveplan/currency-providers` core. Implements the `CurrencyProvider`
contract from `@suaveplan/currency`, requires an API key, and maps the
upstream `{ success, error: { code, info } }` envelope to typed
`CurrencyProviderError` codes — including the free-tier
`base_currency_access_restricted` case (`UNSUPPORTED_BASE`).
<!-- begin:auto-docs -->
## Documentation tree
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
- [fixer](./src/fixer/fixer.md) — `FixerProvider` is the Fixer.io sibling provider for the slim `@suaveplan/currency-providers` core. It implements the `…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-fixer \
@suaveplan/currency-providers \
@suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const registry = new CurrencyProviderRegistry();
registry.register(new FixerProvider({ apiKey: process.env.FIXER_KEY! }));
const converter = new CurrencyConverter({
provider: registry.get("fixer"),
staleDurationMs: 3_600_000,
});
const rate = await converter.getRate("EUR", "GBP");
```
Direct instantiation without the registry is equally supported:
```ts
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
const result = await provider.fetchRates();
```
## Features
- Implements `CurrencyProvider` (`name = "fixer"`, `supportsFiat = true`,
`supportsCrypto = false`).
- Constructor takes the slim core's shared `ApiKeyConfig`; an empty
`apiKey` raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously
before any network call (no wasted quota).
- HTTP transport via the slim core's `httpRequest` — `AbortSignal`
composition, non-2xx → typed `HTTP_ERROR`, `providerName` / `status`
populated on the raised error.
- Typed upstream-code mapping: `105` → `UNSUPPORTED_BASE`, `201` / `202`
→ `UNKNOWN_CURRENCY`, any other `success: false` → `UPSTREAM_ERROR`
with `upstreamMessage` carrying the upstream `error.info`.
- `PARSE_ERROR` for malformed JSON, non-object JSON, and successful
envelopes missing required fields.
- Universal — runs unchanged under Node 18+, Bun, modern browsers,
and Web Workers.
- One subpath export, one feature module; explicit named barrels;
100/100/100/100 istanbul coverage.
## Modules
- [`src/fixer/fixer.md`](./src/fixer/fixer.md) — `FixerProvider` class,
envelope handling, error-code mapping table.
## Sibling packages
Part of the foundation-with-sibling-plugins family established by
the design document. Other concrete
providers (ECB, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap,
CryptoCompare, Kraken, Virtual) ship as separate
`@suaveplan/currency-provider-<name>` packages.
## Status
`0.1.0` (pre-graduation). New native sibling landed on
2026-05-17 against the slimmed core. See
the design proposal
for the proposal, design, and parity checklist.

View File

@@ -0,0 +1,154 @@
# FixerProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FixerProvider` is the [Fixer.io](https://fixer.io/) sibling provider for
the slim `@suaveplan/currency-providers` core. It implements the
`CurrencyProvider` contract from `@suaveplan/currency`, fetches the
latest fiat exchange rates from the Fixer `/latest` REST endpoint, and
maps the upstream JSON envelope into the canonical `RateFetchResult`
shape the converter expects.
Fixer.io requires an API key on every request. Free-tier accounts are
restricted to `base = "EUR"` and a fixed monthly request quota; paid plans
unlock arbitrary base currencies and higher throughput. The provider
surfaces the free-tier restriction as a typed `UNSUPPORTED_BASE` error so
consumers can dispatch on the discriminator rather than parsing the
upstream message string.
## Features
- Implements `CurrencyProvider` with stable identity flags
(`name = "fixer"`, `supportsFiat = true`, `supportsCrypto = false`).
- Constructor takes the slim core's shared `ApiKeyConfig` (`apiKey`,
optional `baseUrl`, optional injectable `fetch`). An empty `apiKey`
raises `CURRENCY_PROVIDER.MISSING_CONFIG` synchronously before any
network call is issued, preventing wasted quota.
- Delegates HTTP transport to the slim core's `httpRequest` helper —
`AbortSignal` composition, non-2xx → typed `HTTP_ERROR` mapping, and
`providerName` / `status` carry on the raised error.
- Maps the upstream `{ success: false, error: { code, info } }` envelope
to typed codes: `105` → `UNSUPPORTED_BASE`, `201` / `202` →
`UNKNOWN_CURRENCY`, any other code → `UPSTREAM_ERROR`. The upstream
`error.info` is preserved on `upstreamMessage` so diagnostics survive.
- Distinguishes parse failures (non-JSON, non-object JSON, success
envelope missing required fields) under a single `PARSE_ERROR` code.
- Universal — no `node:` / `bun:` / DOM imports; runs unchanged
under Node 18+, Bun, modern browsers, and Web Workers.
## Usage: Basic
```ts
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const provider = new FixerProvider({ apiKey: process.env.FIXER_KEY! });
// Free-tier: base is implicitly EUR.
const result = await provider.fetchRates();
console.log(result.base, result.rates.GBP, result.timestamp);
```
## Usage: Advanced
Wire the provider through the slim core's `CurrencyProviderRegistry` and
use it as the primary source for a `CurrencyConverter`. The injected
`FetchLike` makes the provider trivial to unit-test against a fixture
fetch closure:
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { FixerProvider } from "@suaveplan/currency-provider-fixer";
const registry = new CurrencyProviderRegistry();
registry.register(
new FixerProvider({
apiKey: process.env.FIXER_KEY!,
// Optional: proxy through your own gateway for caching / auditing.
baseUrl: "https://rates.internal/fixer/api",
// Optional: inject a wrapped fetch for retries or logging.
fetch: async (url, init) => {
const response = await globalThis.fetch(url, init);
return response;
},
}),
);
const converter = new CurrencyConverter({
provider: registry.get("fixer"),
staleDurationMs: 3_600_000,
});
// Free-tier accounts MUST keep base = "EUR" — supplying any other base
// raises CurrencyProviderError(UNSUPPORTED_BASE) via the typed mapping
// from upstream error code 105.
const rate = await converter.getRate("EUR", "GBP");
```
## API Reference
### `class FixerProvider implements CurrencyProvider`
- `readonly name = "fixer"`
- `readonly supportsCrypto = false`
- `readonly supportsFiat = true`
#### `constructor(config: ApiKeyConfig)`
Validates `config.apiKey` is non-empty (throws
`CurrencyProviderError(MISSING_CONFIG)` otherwise) and caches
`config.baseUrl ?? "https://data.fixer.io/api"` and
`config.fetch ?? globalThis.fetch`.
**Throws**: `CurrencyProviderError(MISSING_CONFIG)` when `apiKey === ""`.
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Builds `?access_key=…&base=…[&symbols=…]`, delegates to the slim core's
`httpRequest`, parses the JSON envelope, and returns the canonical
`RateFetchResult` with `timestamp` converted from Unix seconds to
milliseconds.
**Parameters** (all optional): `base` (default `"EUR"`), `targets`
(array of currency codes — joined into `symbols`), `signal`
(`AbortSignal` forwarded to the underlying fetch).
**Returns**: `{ base, rates, timestamp }` where `timestamp` is
milliseconds since epoch.
**Throws** (all `CurrencyProviderError`):
- `MISSING_CONFIG` — at construction when `apiKey` is empty.
- `HTTP_ERROR` — non-2xx upstream response (via `httpRequest`).
- `PARSE_ERROR` — body is not JSON, parsed value is not an object, or
a `success: true` envelope is missing `base` / `rates` / `timestamp`.
- `UNSUPPORTED_BASE` — upstream code `105` (free-tier base restriction).
- `UNKNOWN_CURRENCY` — upstream code `201` (`invalid_base_currency`) or
`202` (`invalid_currency_codes`).
- `UPSTREAM_ERROR` — any other `success: false` envelope, including the
case where `error` is absent.
## Implementation Notes
- Fixer.io returns HTTP 200 even for application-level errors; the
provider must therefore parse every body and discriminate on
`success`. The non-2xx path (`HTTP_ERROR`) is reserved for genuine
network / gateway failures.
- The upstream `error.code` integer is the discriminator, not
`error.type` — the integer is one comparison and avoids string
allocation. The human-readable `error.info` is preserved on
`upstreamMessage` for diagnostics.
- The `apiKey` non-empty guard at construction is suaveplan-native — the
upstream skeleton allowed an empty string and let the API reject on the
wire, wasting the request against the monthly quota.
- `fetchRates` forwards `options.signal` through `httpRequest` so the
caller can cancel an in-flight request from outside the provider; this
too is suaveplan-native (upstream did not propagate the signal).
- The package is universal and free of side effects (`sideEffects:
false`). Importing the package never mutates any global registry — the
caller decides whether to register the instance with
`CurrencyProviderRegistry`.

View File

@@ -0,0 +1,87 @@
# @suaveplan/currency-provider-kraken - Universal Kraken public-ticker rate provider for @suaveplan/currency. Wraps Kraken's free no-API-key /0/public/Ticker endpoint into a CurrencyProvider with the slim core's shared httpRequest helper, typed CurrencyProviderError (HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY), and the 12-symbol BTC/ETH/SOL/USDT/DOT/ADA/LINK/AVAX/ATOM/LTC/DOGE/XRP coverage matched against Kraken's non-standard pair names.
Universal Kraken public-ticker rate provider for `@suaveplan/currency`.
Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a
`CurrencyProvider` so a `CurrencyConverter` can price the major crypto
pairs against USD without callers ever seeing Kraken's non-standard pair
names (`XXBTZUSD`, `XETHZUSD`, `XDGUSD`, …).
Concrete sibling of the slim `@suaveplan/currency-providers` core — uses
the shared `httpRequest()` helper, raises the shared `CurrencyProviderError`
codes (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`),
and accepts the shared `NoKeyConfig` (optional `baseUrl`, optional injectable
`FetchLike`) plus an optional `extraSymbols` map for caller-supplied
extensions.
<!-- begin:auto-docs -->
## Documentation tree
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-kraken @suaveplan/currency @suaveplan/currency-providers
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
const provider = new KrakenProvider();
const converter = new CurrencyConverter({ provider });
const btcPerUsd = await converter.getRate("USD", "BTC"); // e.g. 0.0000149
const ethPerUsd = await converter.getRate("USD", "ETH"); // e.g. 0.0002857
```
Or register through the slim core's registry:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
const registry = new CurrencyProviderRegistry();
registry.register(new KrakenProvider());
const provider = registry.get("kraken");
```
## Features
- No API key — Kraken's public endpoint is free (per-IP rate-limited)
- 12 high-volume symbols out of the box: BTC, ETH, SOL, USDT, DOT, ADA,
LINK, AVAX, ATOM, LTC, DOGE, XRP
- Optional `extraSymbols` for newly-listed pairs without forking
- Reciprocal-of-USD rate model — `rates[X] = 1 / lastTradeClosedPriceUsd`
- Single-round-trip batch fetch (Kraken supports a comma-separated `pair=`
query)
- Uses the slim core's `httpRequest()` so HTTP errors, timeouts, and caller
`AbortSignal` propagation are uniform across providers
- Maps wire-level failure modes to typed `CurrencyProviderError`:
`HTTP_ERROR`, `UPSTREAM_ERROR`, `PARSE_ERROR`, `UNKNOWN_CURRENCY`
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS policy), Web
Workers
## Modules
- [`src/provider/provider.md`](./src/provider/provider.md) —
`KrakenProvider` class, `KrakenProviderConfig`, `DEFAULT_KRAKEN_BASE_URL`
- [`src/symbols/symbols.md`](./src/symbols/symbols.md) —
`KRAKEN_SYMBOL_MAP`, `KRAKEN_PAIR_TO_SYMBOL`, `mergeSymbolMap`
## Status
`0.1.0` (pre-graduation). native sibling of the slim
`@suaveplan/currency-providers` core landed by
the design document. See
the design proposal for
the migration parity table and source mapping.

View File

@@ -0,0 +1,130 @@
# `provider` — KrakenProvider class
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`KrakenProvider` is the universal `CurrencyProvider` implementation
for Kraken's free `/0/public/Ticker` endpoint. It fetches real-time
last-trade-closed prices for a fixed set of high-volume crypto pairs and
returns them as reciprocal-of-USD rates, so a downstream `CurrencyConverter`
can translate `USD → BTC` (and friends) without callers ever seeing
Kraken's non-standard pair names.
The class is the canonical sibling-package surface defined by the slim
`@suaveplan/currency-providers` core: it wraps a single upstream endpoint,
maps every documented failure mode to a typed `CurrencyProviderError`, and
exposes nothing that consumers can't already obtain from the shared core
helpers.
## Features
- No API key required — Kraken's public endpoint is free and rate-limited
per IP
- 12 high-volume symbols out of the box (BTC, ETH, SOL, USDT, DOT, ADA,
LINK, AVAX, ATOM, LTC, DOGE, XRP); extensible via constructor
`extraSymbols`
- Reciprocal-of-USD rate model — `1 USD = (1 / krakenLastTradePrice)` of the
target crypto, matching the shape every `CurrencyConverter` consumer expects
- Uses the slim core's `httpRequest()` helper so HTTP failures, timeouts,
and caller abort-signal propagation are handled uniformly
- Maps the four wire-level failure modes to typed `CurrencyProviderError`:
`HTTP_ERROR` (non-2xx), `UPSTREAM_ERROR` (Kraken `error[]` array),
`PARSE_ERROR` (malformed JSON / shape mismatch), `UNKNOWN_CURRENCY` (all
requested targets unmapped)
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS), Web Workers
- Optional injectable `FetchLike` (defaults to `globalThis.fetch`) for
recording tests, proxying, or staging URLs
## Usage: Basic
```ts
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
const provider = new KrakenProvider();
const result = await provider.fetchRates({ base: "USD" });
console.log(result.rates.BTC); // e.g. 0.0000149 (≈ 1 / 67000)
console.log(result.rates.ETH); // e.g. 0.0002857 (≈ 1 / 3500)
```
## Usage: Advanced
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
// Use the slim core's registry — wire Kraken plus any other sibling
// providers your app supports. The Kraken instance extends coverage with
// a newly-listed pair via `extraSymbols`, and reuses a custom fetch
// closure that adds a proxy header.
const registry = new CurrencyProviderRegistry();
registry.register(
new KrakenProvider({
baseUrl: "https://kraken.proxy.example.com/0/public",
fetch: async (url, init) =>
fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
"x-proxy-token": process.env.PROXY_TOKEN ?? "",
},
}),
extraSymbols: { MATIC: "MATICUSD" },
}),
);
const converter = new CurrencyConverter({
provider: registry.get("kraken"),
});
const btcRate = await converter.getRate("USD", "BTC");
const maticRate = await converter.getRate("USD", "MATIC");
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `DEFAULT_KRAKEN_BASE_URL` | `string` | `"https://api.kraken.com/0/public"` — overridable via `config.baseUrl`. |
| `KrakenProviderConfig` | `interface` extends `NoKeyConfig` | Optional `baseUrl`, optional `fetch` (`FetchLike`), optional `extraSymbols` (`Record<string, string>`). |
| `KrakenProvider` | `class implements CurrencyProvider` | Fixed `name = "kraken"`, `supportsCrypto = true`, `supportsFiat = false`. Constructor takes an optional `KrakenProviderConfig`. |
| `KrakenProvider.fetchRates(options?)` | `(RateFetchOptions?) => Promise<RateFetchResult>` | Issues one `GET /Ticker?pair=<csv>` via the injected fetch. Returns `{ base, rates, timestamp }`. Throws `CurrencyProviderError` on every documented failure path. |
### Error codes raised
| Code | When |
|---|---|
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx response (raised by the shared `httpRequest`). Carries `status`. |
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Kraken response carries a non-empty `error[]` array. Carries `upstreamMessage`. |
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body fails JSON-parse, OR the parsed body does not match the `{ error: string[], result: object }` shape. Carries `cause` for the JSON-parse case. |
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | Caller supplied `options.targets` but none of the requested symbols resolved to a mapped Kraken pair. |
## Implementation Notes
- The pair list sent in the `pair=` query is the value list of the merged
symbol map — Kraken supports a comma-separated batch so the whole supported
set is fetched in a single round trip. This is more efficient than one
request per symbol AND matches what Kraken's documentation recommends.
- The rate model is `1 / Number.parseFloat(ticker.c[0])` where `c[0]` is
Kraken's "last trade closed" price. Entries whose price does not parse
to a positive finite number are skipped (not raised) — Kraken occasionally
returns `"0.00"` for freshly-listed pairs. If the entire mapped set drops,
`options.targets` filtering will subsequently raise `UNKNOWN_CURRENCY`
on the caller's behalf.
- `options.signal` is forwarded into `httpRequest()` so the caller's
`AbortController` composes with any future timeout the helper applies.
- The provider does **not** retry, cache, or rate-limit. Those concerns
belong in wrapper providers (the slim core's `## Out of scope` defers
them) — keeping this class focused on Kraken's wire protocol keeps the
failure surface auditable.
- All HTTP error mapping flows through the shared `httpRequest()` helper
exported from `@suaveplan/currency-providers/http/http`, so the response
is never read on a non-2xx path — Kraken's body on a 5xx is typically
HTML and would otherwise raise a confusing `PARSE_ERROR`.
- Tier purity: no `node:` imports, no DOM imports, no `setTimeout`. The
helper uses `AbortSignal.timeout` for timeouts (universal in Node 17.3+,
Bun, modern browsers).

View File

@@ -0,0 +1,90 @@
# `symbols` — Kraken symbol-mapping table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD`
for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUSD` for DOGE/USD — using the
asset-prefix convention (`X` for native crypto assets, `Z` for fiat) inherited
from its early-2014 instrument schema. Every consumer of the `KrakenProvider`
wants to address rates by the Suaveplan-standard symbol (`BTC`, `ETH`,
`DOGE`), so the provider needs a stable bidirectional lookup table that
normalises both directions in O(1).
This module owns that table. It exports the frozen forward map
(`KRAKEN_SYMBOL_MAP`), the derived frozen reverse map
(`KRAKEN_PAIR_TO_SYMBOL`), and a `mergeSymbolMap` helper for callers who
need to extend coverage without forking the package.
## Features
- 12 canonical Suaveplan symbols mapped to their Kraken pair names: BTC,
ETH, SOL, USDT, DOT, ADA, LINK, AVAX, ATOM, LTC, DOGE, XRP
- Forward + reverse maps both `Object.freeze`-d for identity stability and
cheap memoised lookups in the hot path
- `mergeSymbolMap(extras)` for caller-supplied extensions — returns a fresh
forward + reverse pair without mutating the canonical maps
- Zero runtime dependencies, universal
## Usage: Basic
```ts
import {
KRAKEN_PAIR_TO_SYMBOL,
KRAKEN_SYMBOL_MAP,
} from "@suaveplan/currency-provider-kraken/symbols/symbols";
console.log(KRAKEN_SYMBOL_MAP.BTC); // "XXBTZUSD"
console.log(KRAKEN_PAIR_TO_SYMBOL.XXBTZUSD); // "BTC"
```
## Usage: Advanced
```ts
import { mergeSymbolMap } from "@suaveplan/currency-provider-kraken/symbols/symbols";
// Extend with a pair Kraken added after this release shipped.
const { forward, reverse } = mergeSymbolMap({
MATIC: "MATICUSD",
APT: "APTUSD",
});
console.log(forward.MATIC); // "MATICUSD"
console.log(reverse.MATICUSD); // "MATIC"
// Original maps are unchanged — `mergeSymbolMap` never mutates them.
console.log("MATIC" in KRAKEN_SYMBOL_MAP); // false
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `KRAKEN_SYMBOL_MAP` | `Readonly<Record<string, string>>` | Frozen forward map: 12 Suaveplan symbols → Kraken pair name. |
| `KRAKEN_PAIR_TO_SYMBOL` | `Readonly<Record<string, string>>` | Frozen reverse map derived at module load from the forward map. |
| `MergedSymbolMaps` | `interface` | Shape returned by `mergeSymbolMap` — `{ forward, reverse }`, both `Readonly<Record<string, string>>`. |
| `mergeSymbolMap(extras?)` | `(Readonly<Record<string, string>> \| undefined) => MergedSymbolMaps` | Returns the canonical maps when `extras` is empty/undefined; otherwise returns fresh forward + reverse maps with the extras merged on top. |
## Implementation Notes
- The Kraken instrument schema mixes legacy `X<base>Z<quote>` pairs (`XXBTZUSD`)
with flat `<base>USD` pairs (`SOLUSD`, `DOTUSD`) added after the schema
redesign. The map captures both forms so callers don't have to.
- The forward map is intentionally small. Kraken lists hundreds of pairs;
the 12 included here are the high-volume subset that match the upstream
9-provider bundle's coverage. Callers needing more symbols use the
`extraSymbols` constructor field on `KrakenProvider` (which calls
`mergeSymbolMap` internally) or call `mergeSymbolMap` directly when they
want the maps for their own logic.
- `mergeSymbolMap` is a hot-path-aware fast path: when `extras` is empty or
undefined, it returns the canonical maps by reference without allocating.
- Both canonical maps are frozen, so `Object.assign(KRAKEN_SYMBOL_MAP, …)`
silently fails in non-strict mode and throws in strict mode — making
accidental mutation impossible.
- Determinism: iteration order of `Object.entries(KRAKEN_SYMBOL_MAP)` is
property-insertion order per the ES2015 spec, so derived structures
(including the reverse map and merged outputs) have deterministic ordering
for golden-file tests.

View File

@@ -0,0 +1,67 @@
# @suaveplan/currency-provider-open-exchange-rates - Universal @suaveplan/currency provider for openexchangerates.org. API-key (app_id) required. Free tier limited to USD base; paid tiers opt in via allowNonUsdBase. Routes every request through @suaveplan/currency-providers httpRequest() and raises typed CurrencyProviderError for HTTP / parse / upstream / config / base / currency failures.
Universal `@suaveplan/currency` provider for
[openexchangerates.org](https://openexchangerates.org). Routes `/latest.json`
through the slim-core `httpRequest()` helper, validates the upstream payload,
and raises typed `CurrencyProviderError` for every failure mode.
<!-- begin:auto-docs -->
## Documentation tree
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
- [provider](./src/provider/provider.md) — `OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for openexchangerates.org. It wraps the `/latest.json…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-open-exchange-rates
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const oxr = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID!,
// allowNonUsdBase: true, // paid plans only
});
const converter = new CurrencyConverter({ provider: oxr });
const usdToEur = await converter.getRate({ from: "USD", to: "EUR" });
console.log(usdToEur);
```
## Configuration
| Field | Type | Default | Notes |
| ----------------- | ----------- | --------------------------------------- | ---------------------------------------------------------------- |
| `apiKey` | `string` | — | OXR `app_id`. Empty string raises `MISSING_CONFIG`. |
| `baseUrl` | `string?` | `https://openexchangerates.org/api` | Override for staging / proxy fixtures. |
| `fetch` | `FetchLike?`| `globalThis.fetch` | Injectable for testing. |
| `allowNonUsdBase` | `boolean?` | `false` | Set `true` on paid plans to forward non-USD `base` requests. |
## Error codes
Every failure mode raises `CurrencyProviderError` from
`@suaveplan/currency-providers` with one of:
- `CURRENCY_PROVIDER.MISSING_CONFIG` — empty `apiKey`.
- `CURRENCY_PROVIDER.UNSUPPORTED_BASE` — non-USD base on free tier.
- `CURRENCY_PROVIDER.HTTP_ERROR` — non-2xx response (raised by `httpRequest()`).
- `CURRENCY_PROVIDER.PARSE_ERROR` — body is not JSON or missing required fields.
- `CURRENCY_PROVIDER.UPSTREAM_ERROR` — 2xx with OXR `{ error: true, … }` body.
- `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` — `options.targets` contains a code absent from the upstream rates map.
## Modules
- [`src/provider/provider.md`](./src/provider/provider.md) — `OpenExchangeRatesProvider` class and config.
## Tier
Universal — runs in Node 18+, Bun, browsers, and Web Workers without any
runtime polyfill.

View File

@@ -0,0 +1,148 @@
# `OpenExchangeRatesProvider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`OpenExchangeRatesProvider` is the `@suaveplan/currency` provider for
[openexchangerates.org](https://openexchangerates.org). It wraps the
`/latest.json` bulk-fetch endpoint, validates the upstream payload, and
surfaces every failure mode through the typed `CurrencyProviderError`
hierarchy from `@suaveplan/currency-providers`. The provider is universal
— it relies only on `globalThis.fetch` (or an injected `FetchLike`) and the
slim-core `httpRequest()` helper, so it runs unchanged in Node, Bun, browsers,
and Web Workers.
## Features
- Implements `CurrencyProvider` with `name === "open-exchange-rates"`,
`supportsFiat === true`, `supportsCrypto === false`.
- Constructor takes the OXR `app_id` as `apiKey`. Empty string raises
`CurrencyProviderError(MISSING_CONFIG)` synchronously.
- Free-tier safe by default: any `RateFetchOptions.base !== "USD"` raises
`CurrencyProviderError(UNSUPPORTED_BASE)` before any network call. Paid-plan
callers opt in with `allowNonUsdBase: true`.
- Routes every request through `@suaveplan/currency-providers` `httpRequest()`
so non-2xx responses surface `CurrencyProviderError(HTTP_ERROR)` with
`status` and `providerName` populated. Caller `AbortSignal` from
`RateFetchOptions.signal` is forwarded.
- Validates the upstream `{ base, rates, timestamp }` shape. Mismatches raise
`CurrencyProviderError(PARSE_ERROR)`.
- Detects the OXR 2xx error-payload shape (`{ error: true, status, message }`)
and raises `CurrencyProviderError(UPSTREAM_ERROR)` with `upstreamMessage`
carrying the upstream `message` field.
- Filters the rates map to `RateFetchOptions.targets`. Missing target codes
raise `CurrencyProviderError(UNKNOWN_CURRENCY)`.
- Converts the OXR Unix-seconds timestamp to milliseconds.
- Injectable `FetchLike` for offline testing; defaults to `globalThis.fetch`.
## Usage: Basic Example
```typescript
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const provider = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID ?? "",
});
const result = await provider.fetchRates({ base: "USD" });
console.log(result.base, result.rates.EUR, result.timestamp);
```
## Usage: Advanced Example
Wiring the provider into `CurrencyConverter` with a paid-tier app_id, custom
`baseUrl` for staging, and `AbortSignal`-driven cancellation:
```typescript
import { CurrencyConverter } from "@suaveplan/currency";
import { OpenExchangeRatesProvider } from "@suaveplan/currency-provider-open-exchange-rates";
const oxr = new OpenExchangeRatesProvider({
apiKey: process.env.OXR_APP_ID!,
allowNonUsdBase: true,
baseUrl: process.env.OXR_BASE_URL ?? "https://openexchangerates.org/api",
});
const converter = new CurrencyConverter({
provider: oxr,
staleDurationMs: 30 * 60 * 1000, // 30 minutes
});
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("timeout")), 5000);
const rates = await oxr.fetchRates({
base: "EUR",
targets: ["USD", "GBP", "JPY"],
signal: controller.signal,
});
```
The slim-core `CurrencyProviderRegistry` is also supported when the consumer
wires multiple providers behind a single lookup surface — see
`@suaveplan/currency-providers` README.
## API Reference
### `OpenExchangeRatesProviderConfig`
Extends `ApiKeyConfig` from `@suaveplan/currency-providers`:
- `apiKey: string` — required. The OXR `app_id`.
- `baseUrl?: string` — defaults to `"https://openexchangerates.org/api"`.
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
- `allowNonUsdBase?: boolean` — defaults to `false`. When `false`, any
non-USD base raises `UNSUPPORTED_BASE` before any network call.
### `class OpenExchangeRatesProvider`
#### Properties
- `readonly name: "open-exchange-rates"`
- `readonly supportsFiat: true`
- `readonly supportsCrypto: false`
#### `constructor(config: OpenExchangeRatesProviderConfig)`
Throws `CurrencyProviderError(MISSING_CONFIG)` when `config.apiKey === ""`.
#### `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>`
Issues `GET ${baseUrl}/latest.json?app_id=<apiKey>&base=<base>` via
`httpRequest()`. Returns `{ base, rates, timestamp }` with `timestamp` in
milliseconds.
Throws:
- `MISSING_CONFIG` — from the constructor when `apiKey === ""`.
- `UNSUPPORTED_BASE` — when `options.base !== "USD"` and `allowNonUsdBase`
is not set to `true`.
- `HTTP_ERROR` — via `httpRequest()` for any non-2xx response.
- `PARSE_ERROR` — when the response body is not JSON, not an object, or is
missing one of `base` / `rates` / `timestamp` in the expected shape.
- `UPSTREAM_ERROR` — when the response is 2xx but the body is the OXR error
shape (`{ error: true, status, message }`).
- `UNKNOWN_CURRENCY` — when `options.targets` contains a code not present in
the upstream `rates` map.
## Implementation Notes
- The OXR free tier restricts the base currency to USD; the provider enforces
this client-side so the failure surfaces as a typed `UNSUPPORTED_BASE`
rather than a generic upstream error. Paid plans pass `allowNonUsdBase:
true` to opt out.
- OXR returns `timestamp` in Unix seconds; the provider multiplies by 1000 so
every `CurrencyProvider` answer is in milliseconds, matching
`RateFetchResult.timestamp`'s contract.
- OXR's error-payload shape is returned with a 2xx HTTP status, which would
otherwise pass `httpRequest()`'s non-2xx check silently. The provider
detects `{ error: true, … }` after parsing and surfaces `UPSTREAM_ERROR`
with `status` and `upstreamMessage` populated.
- The provider does not retry, dedupe, or rate-limit by itself — the slim
core's `## Out of scope` defers those concerns to a cross-cutting follow-up
wave so every sibling provider gains them in lockstep.
- `apiKey` is `encodeURIComponent`-wrapped in the query string to prevent
injection when callers pass keys containing reserved characters.

View File

@@ -0,0 +1,92 @@
# @suaveplan/currency-provider-virtual - Universal in-memory virtual / game currency provider for @suaveplan/currency. Programmable rate table (setRate / removeRate / clear) with optional Clock injection for deterministic timestamps. No HTTP, no API key, no FetchLike. Throws typed CurrencyProviderError(UNKNOWN_CURRENCY) when the requested base is absent. Sibling of the @suaveplan/currency-providers slim core.
In-memory, programmable virtual / game currency provider for
`@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers`
slim plugin core. No HTTP, no API key, no `FetchLike` — purely an
operator-controlled rate table backed by a nested `Map`.
Use it for game economies, loyalty points, internal test rates, or any
rate source you want to seed and mutate deterministically in process.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code that `@suaveplan/currency-provider-virtual` r…
- [error-codes](./src/error-codes/error-codes.md) — This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code that `@suaveplan/currency-provider-virtual` r…
- [provider](./src/provider/provider.md) — `VirtualCurrencyProvider` is an in-memory, programmable rate-source implementation of `@suaveplan/currency`'s `Currency…
- [provider](./src/provider/provider.md) — `VirtualCurrencyProvider` is an in-memory, programmable rate-source implementation of `@suaveplan/currency`'s `Currency…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-provider-virtual @suaveplan/currency-providers @suaveplan/currency
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
const virtual = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const registry = new CurrencyProviderRegistry();
registry.register(virtual);
const converter = new CurrencyConverter({ provider: registry.get("virtual-currency") });
const rate = await converter.getRate("USD", "GOLD"); // 100
// Operators can mutate at any time.
virtual.setRate("USD", "GOLD", 110);
virtual.removeRate("USD", "GEMS");
```
## Features
- Programmable rate table: `setRate(from, to, rate)` (upsert),
`removeRate(from, to)` (idempotent), `clear()`,
`hasRate(from, to)`, `getRate(from, to)`,
`listCurrencies(): readonly string[]` (sorted snapshot).
- `fetchRates(options?)` returns directed rates stored under the
requested `base`, optionally filtered by `options.targets`.
- Typed `CurrencyProviderError(UNKNOWN_CURRENCY)` raised when the
requested base has no rates stored (no triangulation; the table can
be empty).
- Optional `Clock` injection (`@suaveplan/types`) for deterministic
`RateFetchResult.timestamp` values under `FakeClock`-driven tests.
- Implements the `CurrencyProvider` contract from `@suaveplan/currency`
with `supportsVirtual: true`, `supportsFiat: false`,
`supportsCrypto: false`, `name: "virtual-currency"`.
- Universal — runs unchanged on Node 18+, Bun, browsers, and Web
Workers. Zero browser/Node/DOM APIs in production source.
- `sideEffects: false` — the constructor never mutates global state
(no `@suaveplan/money` `registerCurrency` integration).
- Explicit named barrels; two subpath exports; 100/100/100/100
istanbul coverage.
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`VIRTUAL_CURRENCY_PROVIDER_CODES`, `VIRTUAL_PROVIDER_NAME`, and the
`makeUnknownCurrencyError(base)` helper.
- [`src/provider/provider.md`](./src/provider/provider.md) —
`VirtualCurrencyProvider` class + `VirtualCurrencyProviderConfig` and
`VirtualRateEntry` types.
## Status
`0.1.0` (pre-graduation). Sibling of the
`@suaveplan/currency-providers` slim core landed on 2026-05-17. See
the design proposal
for the migration record. The cross-cutting architecture for the
`@suaveplan/currency-provider-*` family is documented in
the design document.

View File

@@ -0,0 +1,104 @@
# error-codes — typed error helpers for the virtual provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module surfaces the single `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code
that `@suaveplan/currency-provider-virtual` raises, plus a tiny
`makeUnknownCurrencyError(base)` builder that constructs the typed
`CurrencyProviderError` with the canonical message format and
`providerName: "virtual-currency"` discriminant pre-populated. The full
8-code registry and the `CurrencyProviderError` class itself live in the
`@suaveplan/currency-providers` slim core — this module never adds new
codes, it only re-narrows the surface the virtual provider actually uses.
The `VIRTUAL_PROVIDER_NAME` constant is the stable identifier exposed as
`provider.name` and stamped onto every emitted error's `providerName`
discriminant, so consumer assertions can pin to a single source of truth.
## Features
- `VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY` — re-export of the
slim-core `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` code (string-identical).
- `VIRTUAL_PROVIDER_NAME` — `"virtual-currency"`.
- `makeUnknownCurrencyError(base)` — returns a `CurrencyProviderError`
with the canonical message and `providerName` discriminant set.
## Usage: Basic
```ts
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
const error = makeUnknownCurrencyError("NOPE");
console.log(error.code); // "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"
console.log(error.providerName); // "virtual-currency"
```
## Usage: Advanced
```ts
import { CurrencyProviderError } from "@suaveplan/currency-providers";
import {
makeUnknownCurrencyError,
VIRTUAL_CURRENCY_PROVIDER_CODES,
} from "@suaveplan/currency-provider-virtual/error-codes/error-codes";
function isUnknownVirtualCurrency(err: unknown): err is CurrencyProviderError {
return (
err instanceof CurrencyProviderError &&
err.code === VIRTUAL_CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY &&
err.providerName === "virtual-currency"
);
}
try {
throw makeUnknownCurrencyError("GHOST");
} catch (err) {
if (isUnknownVirtualCurrency(err)) {
// Fall back to the next provider in the chain.
} else {
throw err;
}
}
```
## API Reference
### `VIRTUAL_CURRENCY_PROVIDER_CODES`
Frozen record. Contains exactly one key — `UNKNOWN_CURRENCY` — whose
value is the namespaced string `"CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`
imported from the slim core. Reference-equal with the slim-core
constant; comparisons via `===` work as expected.
### `VIRTUAL_PROVIDER_NAME`
`"virtual-currency"`. Stable identifier used as `provider.name` and as
the `providerName` discriminant on every emitted error.
### `makeUnknownCurrencyError(base: string): CurrencyProviderError`
Builds the canonical error for a missing-base lookup.
- `base` — the unknown base currency code; included verbatim in the
message.
- Returns a `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"`,
`providerName === "virtual-currency"`, and message format
`"VirtualCurrencyProvider: no rates stored for base \"<base>\""`.
## Implementation Notes
- This module owns zero codes — it re-exports one from the slim core.
Adding a new code requires an OpenSpec change against
`@suaveplan/currency-providers` (which holds the canonical registry),
not against this package.
- The helper is `const`-only — no top-level state, no I/O, no closures
captured — so it remains tree-shake-friendly under
`"sideEffects": false`.

View File

@@ -0,0 +1,160 @@
# provider — VirtualCurrencyProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VirtualCurrencyProvider` is an in-memory, programmable rate-source
implementation of `@suaveplan/currency`'s `CurrencyProvider` contract.
It serves operator-defined virtual / game currencies (and any internal
test rates) without ever issuing an HTTP request or consuming a
`FetchLike`. Rates are stored as a nested `Map<from, Map<to, number>>`
and mutated at runtime through `setRate`, `removeRate`, and `clear`.
The provider sets `supportsVirtual: true` and is the only sibling in
the `@suaveplan/currency-provider-*` family that does not depend on the
slim core's `httpRequest` helper. Use it for game economies, loyalty
points, internal test fixtures, or any rate table you want to control
deterministically.
## Features
- `setRate(from, to, rate)` — upsert a directed rate; replaces an
existing pair without throwing.
- `removeRate(from, to): boolean` — idempotent removal; returns whether
anything was actually removed. Auto-cleans the inner map when the
last rate for a `from` is dropped.
- `clear()` — empty the table in one call.
- `hasRate(from, to): boolean`, `getRate(from, to): number | undefined`,
`listCurrencies(): readonly string[]` — read-only helpers.
- `fetchRates(options?)` — returns the rates stored under
`options.base` (default `defaultBase`), optionally filtered by an
`options.targets` inclusion list. Throws
`CurrencyProviderError(CURRENCY_PROVIDER.UNKNOWN_CURRENCY)` when the
requested base is not in the table.
- Optional `Clock` injection for deterministic `timestamp` values.
- Universal — zero browser / Node / DOM APIs, `sideEffects: false`.
## Usage: Basic
```ts
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
const provider = new VirtualCurrencyProvider({
defaultBase: "USD",
initialRates: [
{ from: "USD", to: "GOLD", rate: 100 },
{ from: "USD", to: "GEMS", rate: 500 },
],
});
const result = await provider.fetchRates();
// { base: "USD", rates: { GOLD: 100, GEMS: 500 }, timestamp: <Date.now()> }
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
import { VirtualCurrencyProvider } from "@suaveplan/currency-provider-virtual";
import { CURRENCY_PROVIDER_CODES, CurrencyProviderError } from "@suaveplan/currency-providers";
// Deterministic timestamps + partial-graph rates (no triangulation).
const fakeClock = new FakeClock({ startTime: 1_700_000_000_000 });
const provider = new VirtualCurrencyProvider({
defaultBase: "GOLD",
clock: () => fakeClock.now(),
});
provider.setRate("GOLD", "GEMS", 5);
provider.setRate("GOLD", "USD", 0.01);
const golden = await provider.fetchRates({ base: "GOLD", targets: ["GEMS"] });
// { base: "GOLD", rates: { GEMS: 5 }, timestamp: 1_700_000_000_000 }
fakeClock.advance(1000);
const stale = await provider.fetchRates({ base: "GOLD" });
// { base: "GOLD", rates: { GEMS: 5, USD: 0.01 }, timestamp: 1_700_000_001_000 }
// Operator updates a rate at runtime — next fetchRates picks it up.
provider.setRate("GOLD", "GEMS", 6);
// Unknown base raises the typed error.
try {
await provider.fetchRates({ base: "GHOST" });
} catch (err) {
if (
err instanceof CurrencyProviderError &&
err.code === CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY
) {
// expected
}
}
```
## API Reference
### `class VirtualCurrencyProvider implements CurrencyProvider`
Constructor: `new VirtualCurrencyProvider(config?: VirtualCurrencyProviderConfig)`.
Static identity:
- `name === "virtual-currency"`
- `supportsCrypto === false`
- `supportsFiat === false`
- `supportsVirtual === true`
Methods:
- `setRate(from: string, to: string, rate: number): void` — upsert. No
throw on duplicate pair.
- `removeRate(from: string, to: string): boolean` — idempotent. Never
throws.
- `clear(): void` — empties the table.
- `hasRate(from: string, to: string): boolean`
- `getRate(from: string, to: string): number | undefined`
- `listCurrencies(): readonly string[]` — sorted snapshot of every
`from` key.
- `fetchRates(options?: RateFetchOptions): Promise<RateFetchResult>` —
rejects with `CurrencyProviderError(UNKNOWN_CURRENCY)` when the
requested base is not stored; otherwise resolves to the rate map for
that base, filtered by `options.targets` when provided, with
`timestamp = clock()`.
### `interface VirtualCurrencyProviderConfig`
- `defaultBase?: string` — base used when `fetchRates()` omits
`options.base`. Defaults to `"USD"`.
- `initialRates?: readonly VirtualRateEntry[]` — entries upserted at
construction. Duplicates within the array are last-write-wins.
- `clock?: Clock` — `() => number` (ms since epoch). Defaults to
`() => Date.now()`.
### `interface VirtualRateEntry`
`{ from: string; to: string; rate: number }`.
## Implementation Notes
- The rate table is `Map<from, Map<to, number>>` — O(1) lookup,
O(n) enumeration over the inner map. The flat alternative
(`Map<"${from}->${to}", number>`) was rejected because currency codes
can contain `-` (e.g. `USDC-GOLD`, `GAME-1-COIN`) which would create
ambiguous keys.
- `removeRate` deletes the empty inner map after the last pair under a
`from` is removed, so `listCurrencies()` stays accurate without a
separate compaction pass.
- The `fetchRates` "no I/O" promise wrapping satisfies the
`CurrencyProvider` async contract without introducing a microtask
delay observable to callers; the work is purely synchronous.
- `Clock` is invoked once per `fetchRates` call, at the moment of the
call (not construction). This matches the wall-clock semantics
every other provider in the family uses.
- The provider is intentionally side-effect-free in its constructor
(unlike the upstream form which called `@suaveplan/money`
`registerCurrency`) — keeping `"sideEffects": false` honest and
tree-shake safe.

View File

@@ -0,0 +1,126 @@
# @suaveplan/currency-providers - Universal slim core for currency providers: CurrencyProviderRegistry, defineCurrencyProvider factory, shared httpRequest/composeAbortSignal helper, typed CurrencyProviderError + 8-code registry, and the ApiKeyConfig / NoKeyConfig / HttpFetchOptions / ProviderConfigBase shared types. Concrete providers (ECB, Fixer, Open Exchange Rates, Binance, CoinGecko, CoinMarketCap, CryptoCompare, Kraken, Virtual) ship as sibling @suaveplan/currency-provider-<name> packages.
Slim plugin core for `@suaveplan/currency` rate providers. Ships the
`CurrencyProviderRegistry` (name-keyed lookup), the
`defineCurrencyProvider` factory, the shared `httpRequest` /
`composeAbortSignal` helper, the typed `CurrencyProviderError` with the
8-code `CURRENCY_PROVIDER_*` registry, and the four shared config types
(`ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`, `ProviderConfigBase`)
that every sibling provider extends.
The concrete provider classes (ECB, Fixer, Open Exchange Rates, Binance,
CoinGecko, CoinMarketCap, CryptoCompare, Kraken, Virtual) live in
sibling `@suaveplan/currency-provider-<name>` packages — install only the
ones you wire in.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CURRENCY_PROVIDER_CODES` registry and the `CurrencyProviderError` class every provider in `@suavepl…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `CURRENCY_PROVIDER_CODES` registry and the `CurrencyProviderError` class every provider in `@suavepl…
- [factory](./src/factory/factory.md) — Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling `@suaveplan/currency-provider-<name>` packages…
- [factory](./src/factory/factory.md) — Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling `@suaveplan/currency-provider-<name>` packages…
- [http](./src/http/http.md) — Exposes the two HTTP utilities every sibling `@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an a…
- [http](./src/http/http.md) — Exposes the two HTTP utilities every sibling `@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an a…
- [registry](./src/registry/registry.md) — Defines `CurrencyProviderRegistry`, the in-memory registry that sibling `@suaveplan/currency-provider-<name>` packages…
- [registry](./src/registry/registry.md) — Defines `CurrencyProviderRegistry`, the in-memory registry that sibling `@suaveplan/currency-provider-<name>` packages…
- [types](./src/types/configs.md) — Defines the three configuration interfaces every concrete provider in `@suaveplan/currency-providers` consumes: `ApiKey…
- [configs](./src/types/configs.md) — Defines the three configuration interfaces every concrete provider in `@suaveplan/currency-providers` consumes: `ApiKey…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/currency-providers @suaveplan/currency
# plus whichever sibling provider packages you need:
bun add @suaveplan/currency-provider-ecb @suaveplan/currency-provider-coingecko
```
## Quick start
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
registry.register(new CoinGeckoProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
fallbackProviders: [registry.get("coingecko")],
});
const rate = await converter.getRate("USD", "EUR");
```
Direct instantiation without the registry is equally supported — the
registry is a convenience for callers that wire many providers and want a
single lookup surface. Sibling packages remain `CurrencyProvider`-compatible
so `new CurrencyConverter({ provider: new EcbProvider() })` continues to work.
## Features
- `CurrencyProviderRegistry` — register / get / has / list / unregister.
Typed `PROVIDER_NOT_REGISTERED` / `PROVIDER_ALREADY_REGISTERED` errors;
`list()` returns a sorted snapshot for deterministic downstream tests
- `defineCurrencyProvider` — factory for sibling packages that prefer the
object-literal idiom over a hand-rolled class. Returns an
`Object.freeze`-d `CurrencyProvider`
- `httpRequest` — shared HTTP helper used by every sibling provider:
abort-signal composition, timeout derivation, non-2xx → `HTTP_ERROR`
mapping, returns the raw `Response`
- `composeAbortSignal` — pure four-way composition of caller + timeout
`AbortSignal`s (`undefined`, single, single, `AbortSignal.any`)
- Typed `CurrencyProviderError` extending `SuaveplanError` with 8 stable
codes (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNSUPPORTED_BASE`,
`MISSING_CONFIG`, `UNKNOWN_CURRENCY`, `PROVIDER_NOT_REGISTERED`,
`PROVIDER_ALREADY_REGISTERED`)
- Shared config types — `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`,
`ProviderConfigBase` with optional injectable `FetchLike`
- Universal — zero browser/Node APIs, runs unchanged under Node ≥ 18,
Bun, modern browsers, and Web Workers
- Five subpath exports; explicit named barrels; 100/100/100/100 istanbul
coverage
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`CURRENCY_PROVIDER_CODES` registry + `CurrencyProviderError` class
- [`src/types/configs.md`](./src/types/configs.md) — `ApiKeyConfig`,
`NoKeyConfig`, `HttpFetchOptions`, `ProviderConfigBase`
- [`src/registry/registry.md`](./src/registry/registry.md) —
`CurrencyProviderRegistry`
- [`src/factory/factory.md`](./src/factory/factory.md) —
`defineCurrencyProvider` factory + `DefineCurrencyProviderInput`
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
`composeAbortSignal` shared HTTP helpers
## Sibling provider packages
| Provider | Sibling package |
|--------------------------|-------------------------------------------------------|
| European Central Bank | `@suaveplan/currency-provider-ecb` |
| Fixer.io | `@suaveplan/currency-provider-fixer` |
| Open Exchange Rates | `@suaveplan/currency-provider-open-exchange-rates` |
| Binance | `@suaveplan/currency-provider-binance` |
| CoinGecko | `@suaveplan/currency-provider-coingecko` |
| CoinMarketCap | `@suaveplan/currency-provider-coinmarketcap` |
| CryptoCompare | `@suaveplan/currency-provider-cryptocompare` |
| Kraken | `@suaveplan/currency-provider-kraken` |
| Virtual (game / loyalty) | `@suaveplan/currency-provider-virtual` |
Each sibling depends on this slim core for the registry, factory, HTTP
helper, error class, and config types — and re-implements only its own
provider-specific logic.
## Status
`0.1.0` (pre-graduation). Slim refactor of the 2026-05-17 migration:
provider classes moved out to sibling packages; the registry / factory /
HTTP helper landed here. See
the design proposal for the
refactor record and migration notes.

View File

@@ -0,0 +1,110 @@
# `error-codes` — Typed error registry for currency providers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `CURRENCY_PROVIDER_CODES` registry and the
`CurrencyProviderError` class every provider in
`@suaveplan/currency-providers` raises. The registry guarantees that
downstream code — most importantly `CurrencyConverter`'s fallback chain —
can programmatically dispatch on the namespaced code string instead of
parsing free-form error messages. This is the suaveplan-quality replacement
for the upstream package's plain `throw new Error(...)` pattern, which
gave fallback logic no way to tell "Fixer returned 401 — try the next
provider" apart from "Fixer URL was malformed — give up".
`CurrencyProviderError` extends `SuaveplanError<CurrencyProviderErrorCode>`,
preserving the full `SuaveplanError` contract — `cause` chaining,
`toWire()` redaction, OTel span enrichment, and i18n-key dispatch — while
adding three optional discriminants relevant to provider failures:
`providerName` (which provider hit the error), `status` (the HTTP status
when the failure came from an HTTP response), and `upstreamMessage` (the
free-form error string the upstream API supplied).
## Features
- Eight stable error codes covering every provider failure mode plus the
slim-core registry dispatch: HTTP_ERROR, PARSE_ERROR, UPSTREAM_ERROR,
UNSUPPORTED_BASE, MISSING_CONFIG, UNKNOWN_CURRENCY,
PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED
- `CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>` —
full `SuaveplanError` contract preserved
- Optional `providerName` / `status` / `upstreamMessage` discriminants
- Registered via `defineCodes` so the code values are typed as the
namespaced strings (`"CURRENCY_PROVIDER.HTTP_ERROR"` etc.)
- The first six codes are reachable from sibling
`@suaveplan/currency-provider-<name>` packages (where the concrete HTTP
call sites live); the two `PROVIDER_*` codes are reachable from this
package's `CurrencyProviderRegistry`
## Usage: Basic Example
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
throw new CurrencyProviderError(
CURRENCY_PROVIDER_CODES.HTTP_ERROR,
"ECB API error: 503 Service Unavailable",
{ providerName: "ecb", status: 503 }
);
```
## Usage: Advanced Example
Use the codes to drive fallback-chain decisions in a custom converter:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
} from "@suaveplan/currency-providers";
async function fetchWithFallback(providers: readonly CurrencyProvider[]) {
let lastError: unknown;
for (const provider of providers) {
try {
return await provider.fetchRates();
} catch (error) {
lastError = error;
if (!(error instanceof CurrencyProviderError)) throw error;
// 4xx auth / config errors are not worth retrying with the next
// provider — re-throw to bubble up
if (error.code === CURRENCY_PROVIDER_CODES.MISSING_CONFIG) throw error;
if (error.status === 401 || error.status === 403) throw error;
// anything else: try next provider
}
}
throw lastError;
}
```
## API Reference
- `const CURRENCY_PROVIDER_CODES` — frozen registry mapping each code name
to its namespaced string (`"CURRENCY_PROVIDER.HTTP_ERROR"`, etc.).
- `type CurrencyProviderErrorCode` — union of the eight namespaced strings.
- `interface CurrencyProviderErrorOptions extends SuaveplanErrorOptions` —
adds optional `providerName: string`, `status: number`,
`upstreamMessage: string`.
- `class CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>`
— constructor `(code, message, options?)`. Read-only `providerName`,
`status`, `upstreamMessage` properties.
## Implementation Notes
- The discriminants are surfaced as top-level read-only properties (rather
than fields on `context`) to make destructured `try/catch` handling
ergonomic — `if (error instanceof CurrencyProviderError && error.status === 429)`
reads naturally.
- Codes that don't have a natural HTTP shape (e.g. `MISSING_CONFIG`,
`UNKNOWN_CURRENCY`) simply omit `status` and `upstreamMessage`. The
optional fields are populated only when meaningful.
- `defineCodes` enforces namespace prefix at runtime — `CURRENCY_PROVIDER.X`
cannot collide with `CURRENCY.X` from `@suaveplan/currency` or any other
package's registry.

View File

@@ -0,0 +1,132 @@
# `factory` — defineCurrencyProvider factory helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `defineCurrencyProvider`, the low-ceremony factory that sibling
`@suaveplan/currency-provider-<name>` packages use to declare a provider as
a plain object literal instead of a hand-rolled class. The factory accepts a
`DefineCurrencyProviderInput` (the static identity flags plus a `fetchRates`
function) and returns an `Object.freeze`-d `CurrencyProvider` that
`CurrencyProviderRegistry` and `CurrencyConverter` accept identically to a
class-based provider.
Two idioms are equally supported across the ecosystem — `class
EcbProvider implements CurrencyProvider` for sibling packages that prefer
the OO shape, and `defineCurrencyProvider({ ... })` for packages that prefer
the functional shape. Neither is privileged; the factory exists so sibling
authors do not need to write the `name` / `supportsCrypto` / `supportsFiat`
boilerplate by hand when a closure over a config object is the natural
expression.
## Features
- Returns an `Object.frozen` `CurrencyProvider` — identity stable, no
accidental mutation
- Forwards every `RateFetchOptions` field unchanged to the supplied
`fetchRates` function
- Preserves the optional `supportsVirtual` flag only when explicitly
provided (matches the `CurrencyProvider` contract from
`@suaveplan/currency`)
- Named `DefineCurrencyProviderInput` interface (no inline object literals
in the factory signature) — satisfies AGENTS.md §11
## Usage: Basic Example
```ts
import { defineCurrencyProvider } from "@suaveplan/currency-providers";
const provider = defineCurrencyProvider({
name: "static-eur",
supportsCrypto: false,
supportsFiat: true,
async fetchRates() {
return {
base: "EUR",
rates: { USD: 1.08, GBP: 0.86 },
timestamp: Date.now(),
};
},
});
console.log(provider.name); // "static-eur"
```
## Usage: Advanced Example
Compose the factory with the shared `httpRequest` helper to author a real
provider with three lines of HTTP wiring:
```ts
import {
defineCurrencyProvider,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
const DEFAULT_URL = "https://api.example.com/v1/rates";
export function createExampleProvider(config?: NoKeyConfig) {
const baseUrl = config?.baseUrl ?? DEFAULT_URL;
const fetchImpl = config?.fetch ?? globalThis.fetch;
return defineCurrencyProvider({
name: "example",
supportsCrypto: false,
supportsFiat: true,
async fetchRates(options) {
const response = await httpRequest({
url: baseUrl,
fetch: fetchImpl,
providerName: "example",
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
},
});
}
```
## API Reference
- `interface DefineCurrencyProviderInput` — input contract.
- `name: string` — stable identifier surfaced as `provider.name`.
- `supportsCrypto: boolean`
- `supportsFiat: boolean`
- `supportsVirtual?: boolean` — optional. When omitted, the returned
provider does not declare the property at all (matches the
`CurrencyProvider` contract).
- `fetchRates: (options?: RateFetchOptions) => Promise<RateFetchResult>`
— the user-supplied implementation. Called directly; the factory
inserts no wrapping behaviour.
- `function defineCurrencyProvider(input: DefineCurrencyProviderInput): CurrencyProvider`
— returns an `Object.freeze`-d `CurrencyProvider`. Identity-stable; safe
to compare with `===`.
## Implementation Notes
- The factory deliberately does **not** apply HTTP error mapping, abort
composition, or schema validation — those concerns live in the
`httpRequest` helper and in each sibling provider's own implementation.
`defineCurrencyProvider` is a thin shape adapter, not a framework.
- `Object.freeze` is shallow — the `name` / `supportsCrypto` /
`supportsFiat` / `supportsVirtual` properties are immutable, but the
closure variables that `fetchRates` captures are owned by the caller and
remain mutable if the caller chose to make them so.
- The `supportsVirtual` field is conditionally included so that providers
that do not deal with virtual currencies surface `undefined` via
`provider.supportsVirtual === undefined` (the contract's "optional"
semantics), rather than always being literally present as `false`.

View File

@@ -0,0 +1,155 @@
# `http` — Shared HTTP helper for sibling provider packages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the two HTTP utilities every sibling
`@suaveplan/currency-provider-<name>` package repeats: `httpRequest` (an
async request through the injected `FetchLike` with typed-error mapping and
abort-signal composition) and `composeAbortSignal` (a pure helper that
merges a caller `AbortSignal` with an optional timeout-derived one).
The previous shape of `@suaveplan/currency-providers` had every provider
re-implement the same `await this.fetchImpl(url); if (!response.ok) throw
new CurrencyProviderError(HTTP_ERROR, ...)` block by hand. That repetition
is exactly the signal that the helper belongs in the slim core: every
sibling now `import { httpRequest } from "@suaveplan/currency-providers"`
and the HTTP_ERROR call site lives in one place, exercised by one test
file.
## Features
- `httpRequest` — single async helper covering the full HTTP lifecycle:
abort-signal composition, timeout derivation, fetch invocation, non-2xx
→ `CurrencyProviderError(HTTP_ERROR)` mapping
- `composeAbortSignal` — pure four-way composition: `undefined`, caller
only, timeout only, both (via `AbortSignal.any`)
- Returns the raw `Response` so callers handle `.json()` / `.text()` /
`.arrayBuffer()` according to their own payload shape — no premature
body-format assumption
- Universal — uses only WHATWG `fetch`, `AbortSignal.timeout`, and
`AbortSignal.any` (all available in Node 20+, Bun, and modern browsers)
- Typed error carries `providerName` and `status` discriminants so audit
log handlers can dispatch without parsing the message string
## Usage: Basic Example
```ts
import { httpRequest } from "@suaveplan/currency-providers";
const response = await httpRequest({
url: "https://api.example.com/v1/rates",
fetch: globalThis.fetch,
providerName: "example",
});
const payload = await response.json();
```
## Usage: Advanced Example
Wire `httpRequest` into a sibling provider with both a timeout and the
caller's `RateFetchOptions.signal`, then handle `HTTP_ERROR` against the
typed code rather than the message:
```ts
import {
CURRENCY_PROVIDER_CODES,
CurrencyProviderError,
httpRequest,
type NoKeyConfig,
} from "@suaveplan/currency-providers";
import type { RateFetchOptions, RateFetchResult } from "@suaveplan/currency";
const DEFAULT_URL = "https://api.example.com/v1/rates";
const NAME = "example";
export class ExampleProvider {
public readonly name = NAME;
public readonly supportsCrypto = false;
public readonly supportsFiat = true;
private readonly baseUrl: string;
private readonly fetchImpl;
constructor(config?: NoKeyConfig) {
this.baseUrl = config?.baseUrl ?? DEFAULT_URL;
this.fetchImpl = config?.fetch ?? globalThis.fetch;
}
async fetchRates(options?: RateFetchOptions): Promise<RateFetchResult> {
try {
const response = await httpRequest({
url: this.baseUrl,
fetch: this.fetchImpl,
providerName: NAME,
signal: options?.signal,
timeoutMs: 10_000,
});
const payload = (await response.json()) as {
base: string;
rates: Record<string, number>;
timestamp: number;
};
return {
base: payload.base,
rates: payload.rates,
timestamp: payload.timestamp * 1000,
};
} catch (error) {
if (
error instanceof CurrencyProviderError &&
error.code === CURRENCY_PROVIDER_CODES.HTTP_ERROR
) {
// optionally: backoff, fallback, audit-log etc.
}
throw error;
}
}
}
```
## API Reference
- `interface HttpRequestInput`
- `url: string` — fully-resolved request URL (query string already
appended at the call site).
- `fetch: FetchLike` — injected `FetchLike` (the sibling provider
defaults to `globalThis.fetch` at construction time).
- `providerName: string` — carried on the raised
`CurrencyProviderError.providerName`.
- `init?: RequestInit` — optional WHATWG `RequestInit` (method, headers,
body, etc.). The helper preserves caller-supplied `headers` and
`method`; it only inserts `signal` when an abort signal needs to be
attached.
- `signal?: AbortSignal` — caller's abort signal (usually from
`RateFetchOptions.signal`).
- `timeoutMs?: number` — when set, the helper derives
`AbortSignal.timeout(timeoutMs)` and composes it with the caller signal.
- `function httpRequest(input: HttpRequestInput): Promise<Response>` —
resolves to the raw `Response` on 2xx, rejects with
`CurrencyProviderError(HTTP_ERROR)` on non-2xx.
- `function composeAbortSignal(caller, timeout): AbortSignal | undefined`
— pure four-way composition. Returns the single input verbatim when only
one is provided (no wrapping cost). Returns
`AbortSignal.any([caller, timeout])` when both are present.
## Implementation Notes
- The helper does **not** apply retry, backoff, or rate-limit semantics —
those wrappers compose at a higher layer (see the deferred surfaces in
the 2026-05-17 archive's `## Out of scope`).
- `AbortSignal.timeout` was chosen over a manual `setTimeout +
AbortController` to keep the helper universal (no `setTimeout`
scheduling owned by the helper) and to avoid the cleanup foot-gun of a
pending timer outliving a fast-success request.
- `composeAbortSignal` deliberately returns the **single input** unchanged
when only one is provided. Wrapping a single signal through
`AbortSignal.any([s])` would still produce a valid signal but would
break identity-comparison invariants downstream consumers might rely on.
- The non-2xx error message intentionally uses `response.statusText` (the
HTTP reason phrase) so the human-readable form survives audit log
redaction; programmatic dispatch should use `error.code` and
`error.status` instead.

View File

@@ -0,0 +1,128 @@
# `registry` — Name-keyed lookup of CurrencyProvider instances
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `CurrencyProviderRegistry`, the in-memory registry that sibling
`@suaveplan/currency-provider-<name>` packages register their providers
with and that consumers (the `CurrencyConverter` wiring, future
`AggregatorProvider` / `RateLimitedProvider` wrappers, admin UIs that toggle
providers on and off) use to resolve providers by name without tracking
concrete classes themselves.
The registry is **opt-in convenience**, not a required indirection:
`new CurrencyConverter({ provider: new EcbProvider() })` continues to work
without a registry. The registry exists for callers that wire many providers
from configuration, that need a single canonical instance of each provider,
or that want a uniform "look up by name" surface to feed into wrappers.
## Features
- Five-method API — `register`, `get`, `has`, `list`, `unregister`
- Typed `CurrencyProviderError` on every failure mode (no plain `Error`s)
- `register` throws `PROVIDER_ALREADY_REGISTERED` on duplicate names —
explicit `unregister` + `register` required to replace
- `get` throws `PROVIDER_NOT_REGISTERED` on unknown names; `has` is a
non-throwing probe for the same condition
- `list()` returns a **sorted snapshot** so equal sets of providers
produce identical listings regardless of insertion order — keeps
downstream golden-file tests deterministic
- `unregister` is idempotent — returns `true` when something was removed,
`false` otherwise, never throws
- Providers stored by reference (no cloning / freezing / defensive copy)
## Usage: Basic Example
```ts
import { CurrencyConverter } from "@suaveplan/currency";
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import { EcbProvider } from "@suaveplan/currency-provider-ecb";
import { CoinGeckoProvider } from "@suaveplan/currency-provider-coingecko";
const registry = new CurrencyProviderRegistry();
registry.register(new EcbProvider());
registry.register(new CoinGeckoProvider());
const converter = new CurrencyConverter({
provider: registry.get("ecb"),
fallbackProviders: [registry.get("coingecko")],
});
```
## Usage: Advanced Example
Wire providers from a config file, swap them at runtime, and enumerate them
deterministically for a status dashboard:
```ts
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
import type { CurrencyProvider } from "@suaveplan/currency";
interface ProviderModule {
readonly create: () => CurrencyProvider;
}
function wire(
modules: Readonly<Record<string, ProviderModule>>,
enabled: readonly string[]
): CurrencyProviderRegistry {
const registry = new CurrencyProviderRegistry();
for (const key of enabled) {
const mod = modules[key];
if (mod !== undefined) registry.register(mod.create());
}
return registry;
}
// Swap "ecb" for a different ECB instance with a custom fetch
const registry = wire(modules, ["ecb", "coingecko", "kraken"]);
registry.unregister("ecb");
registry.register(new EcbProvider({ fetch: instrumentedFetch }));
// Dashboard: always shows providers alphabetically
for (const name of registry.list()) {
console.log(`provider: ${name}`);
}
```
## API Reference
- `class CurrencyProviderRegistry` — exported class. Holds a `Map<string,
CurrencyProvider>` internally.
- `register(provider: CurrencyProvider): void` — store the provider under
`provider.name`. Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and
`providerName === provider.name` when the name is already in use.
- `get(name: string): CurrencyProvider` — return the registered provider.
Throws `CurrencyProviderError` with
`code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and
`providerName === name` when nothing is registered under `name`.
- `has(name: string): boolean` — non-throwing probe.
- `list(): readonly string[]` — sorted snapshot of currently registered
names.
- `unregister(name: string): boolean` — `true` when something was
removed, `false` otherwise. Never throws.
## Implementation Notes
- The registry is **single-tenant**: there is no global / singleton
instance. Callers construct their own registry. Multiple registries can
coexist (e.g. one per tenant in a multi-tenant SaaS), and a provider
instance may be registered with more than one registry simultaneously.
- `register` is idempotent only at the identity level: registering the same
instance under the same name twice still throws
`PROVIDER_ALREADY_REGISTERED`, exactly as registering two different
instances under the same name throws. Idempotent "replace if newer"
semantics belong in a wrapper, not the core, so the contract here stays
unambiguous.
- `list()` returns a fresh `string[]` on every call (the sort happens
in-place on a copy). Callers may treat the result as immutable, but the
registry does not freeze it — a `readonly string[]` type hint conveys the
intent at the TypeScript boundary.
- Both `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` carry
`providerName` on the thrown `CurrencyProviderError`, so callers writing
cross-cutting error handlers (audit logs, telemetry) can structure on the
discriminant rather than parsing the message string.

View File

@@ -0,0 +1,98 @@
# `configs` — Provider configuration types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the three configuration interfaces every concrete provider in
`@suaveplan/currency-providers` consumes: `ApiKeyConfig` for providers that
require an API key (Fixer, Open Exchange Rates, CoinMarketCap),
`NoKeyConfig` for providers that talk to a public endpoint (ECB, Binance,
CoinGecko, CryptoCompare, Kraken), and `HttpFetchOptions` for the per-call
HTTP knobs callers can set on top. All three are named interfaces — never
inline object literals in provider constructor signatures — so consumers
can import the type, spread it into their own config bag, and let
TypeScript narrow on the discriminating fields.
Every config inherits from `ProviderConfigBase`, which adds the two
universal knobs every provider honours: an optional `baseUrl` (for proxy /
staging / fixture endpoints) and an optional `fetch: FetchLike` (the
injectable WHATWG-shape closure that lets tests pass a controlled stub
without `vi.stubGlobal('fetch', ...)`). The `FetchLike` type comes from
`@suaveplan/types` — `globalThis.fetch` is directly assignable to it.
## Features
- `ApiKeyConfig` — `apiKey: string` (required) + `baseUrl?` + `fetch?`
- `NoKeyConfig` — `baseUrl?` + `fetch?` (no API key field)
- `HttpFetchOptions` — `timeoutMs?` + `headers?` per-call knobs (reserved for
the retry / timeout wrapper landing under `## Out of scope`)
- All interfaces use `readonly` fields so config bags are immutable
## Usage: Basic Example
```ts
import { FixerProvider } from "@suaveplan/currency-providers";
import type { ApiKeyConfig } from "@suaveplan/currency-providers";
const config: ApiKeyConfig = { apiKey: process.env.FIXER_KEY! };
const fixer = new FixerProvider(config);
```
## Usage: Advanced Example
Inject a custom `fetch` for hermetic tests:
```ts
import { EcbProvider } from "@suaveplan/currency-providers";
import type { FetchLike } from "@suaveplan/types";
const fakeFetch: FetchLike = async (input) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/eurofxref-daily.xml")) {
return new Response(
`<?xml version="1.0"?><Cube><Cube currency='USD' rate='1.087'/></Cube>`,
{ status: 200 }
);
}
return new Response("not found", { status: 404 });
};
const ecb = new EcbProvider({
baseUrl: "https://staging.example.com/eurofxref-daily.xml",
fetch: fakeFetch,
});
const result = await ecb.fetchRates({ base: "EUR" });
```
## API Reference
- `interface ProviderConfigBase` — `baseUrl?: string`, `fetch?: FetchLike`.
Internal base shape every concrete config extends.
- `interface ApiKeyConfig extends ProviderConfigBase` — adds
`apiKey: string`. Providers that consume it raise
`CURRENCY_PROVIDER.MISSING_CONFIG` if `apiKey` is empty.
- `interface NoKeyConfig extends ProviderConfigBase` — empty extension; type
alias name signals "no key required". Allows future per-no-key fields
without touching the base.
- `interface HttpFetchOptions` — `timeoutMs?: number`,
`headers?: Readonly<Record<string, string>>`. Reserved for the retry /
timeout wrapper.
## Implementation Notes
- The base interface intentionally accepts `FetchLike` (structural,
three-argument or two-argument doesn't matter) instead of the global
`typeof fetch`. This lets callers pass tighter stubs — e.g. a closure
that only handles a single URL pattern.
- `HttpFetchOptions` exists in the public surface today even though no
provider consumes it yet, because it is exported by the upstream
`@suaveplan/currency-providers` package and consumer code that imported
the type would break if it disappeared. Once `@suaveplan/retry` is wired
in, every provider's `fetchRates` will accept it as a second argument.
- Empty-extension `interface NoKeyConfig extends ProviderConfigBase {}` is
intentional and not a TypeScript anti-pattern: it gives the no-key
providers a distinct, importable type name so consumers can spread it
into their own configs without coupling to the base name.

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,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,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,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,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,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,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,120 @@
# @suaveplan/money - Universal precise currency/money arithmetic using integer minor-unit (bigint) representation: immutable Money class with full arithmetic + comparison + conversion surface, locale-aware Intl-backed formatting (formatMoney, formatMoneyCompact), fair allocators preserving the input total (allocateEvenly, allocateByRatios, allocateByPercentages), aggregators (sumMoney, minMoney, maxMoney), 30 ISO 4217 fiat + 5 cryptocurrency built-ins, custom and virtual currency registration, lossless JSON round-trip, typed MoneyError hierarchy with stable MONEY.* code registry.
Universal precise currency and money arithmetic using integer minor-unit
representation. All operations use `bigint` so consumers never hit
floating-point rounding drift; cross-currency mistakes raise typed errors;
allocations preserve the input total to the minor unit.
<!-- begin:auto-docs -->
## Documentation tree
- [allocator](./src/allocator/allocator.md) — The allocator module solves two related problems: splitting a `Money` amount across N recipients (or N weights or N per…
- [allocator](./src/allocator/allocator.md) — The allocator module solves two related problems: splitting a `Money` amount across N recipients (or N weights or N per…
- [currencies](./src/currencies/currencies.md) — This module owns the currency-metadata registry that every other part of `@suaveplan/money` consults to compute minor-u…
- [currencies](./src/currencies/currencies.md) — This module owns the currency-metadata registry that every other part of `@suaveplan/money` consults to compute minor-u…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/money` raises only typed errors. This module owns the stable `MONEY_CODES` registry (built with `defineCode…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/money` raises only typed errors. This module owns the stable `MONEY_CODES` registry (built with `defineCode…
- [formatter](./src/formatter/formatter.md) — The formatter renders `MoneyLike` values to human-readable strings using `Intl.NumberFormat(locale)`-derived separators…
- [formatter](./src/formatter/formatter.md) — The formatter renders `MoneyLike` values to human-readable strings using `Intl.NumberFormat(locale)`-derived separators…
- [money](./src/money/money.md) — `Money` is the cornerstone of the package: an immutable value object that stores monetary amounts as `bigint` minor uni…
- [money](./src/money/money.md) — `Money` is the cornerstone of the package: an immutable value object that stores monetary amounts as `bigint` minor uni…
- [types](./src/types/common.md) — `@suaveplan/money` exposes a small, stable set of types that downstream packages (`@suaveplan/currency`, `@suaveplan/pa…
- [common](./src/types/common.md) — `@suaveplan/money` exposes a small, stable set of types that downstream packages (`@suaveplan/currency`, `@suaveplan/pa…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/money
```
## Quick start
```ts
import { Money, formatMoney, allocateEvenly } from "@suaveplan/money";
const price = Money.fromMajor(29.99, "USD");
const tax = price.multiply(0.08);
const total = price.add(tax);
console.log(formatMoney(total)); // "$32.39"
console.log(total.toDecimalString()); // "32.39"
// Split a $32.39 bill among 3 — no penny lost
const { shares } = allocateEvenly(total, 3);
console.log(shares.map((s) => formatMoney(s))); // ["$10.80", "$10.80", "$10.79"]
```
## Features
- **Immutable `Money` class** with full arithmetic (`add`, `subtract`,
`multiply`, `divide`, `abs`, `negate`), comparison (`compareTo`, `equals`,
`greaterThan`, ...), and conversion (`toMajor`, `toDecimalString`, `toJSON`,
`toString`).
- **30 ISO 4217 fiat currencies** (USD, EUR, GBP, JPY, ...) plus **5
cryptocurrencies** (BTC, ETH, SOL, USDT, USDC) with accurate exponents.
- **Custom and virtual currency registration** via `registerCurrency(info)`
with `virtual` / `issuerId` flags for game-currency use cases.
- **Locale-aware formatting** through `Intl.NumberFormat`-derived separators;
`decimalSeparator` / `thousandsSeparator` overrides for locked output.
- **Compact notation** (`formatMoneyCompact`) with K / M / B suffixes.
- **Fair allocators** (`allocateEvenly`, `allocateByRatios`,
`allocateByPercentages`) that always preserve the input total.
- **Aggregators** (`sumMoney`, `minMoney`, `maxMoney`) with typed empty-input
and cross-currency guards.
- **Lossless JSON round-trip** via `Money.toJSON()` / `Money.fromJSON()`
(`bigint` encoded as decimal string).
- **Typed `MoneyError` hierarchy** extending `SuaveplanError<MoneyErrorCode>`
with stable `MONEY.*` code registry and `currency` / `otherCurrency`
discriminants.
- **Subpath exports** for every public module so consumers can tree-shake.
- **Universal** — no `node:*`, `bun:*`, DOM, or fs imports; runs unchanged
on Node ≥ 18, Bun, browsers, and Web Workers.
## Modules
- [`money/money.md`](./src/money/money.md) — `Money` class + `applyRounding`
- [`currencies/currencies.md`](./src/currencies/currencies.md) — built-in
registry, lookup, custom / virtual currency registration
- [`formatter/formatter.md`](./src/formatter/formatter.md) — `formatMoney` +
`formatMoneyCompact`
- [`allocator/allocator.md`](./src/allocator/allocator.md) — allocators +
aggregators (`sumMoney`, `minMoney`, `maxMoney`)
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`MONEY_CODES` registry + `MoneyError` class
- [`types/common.md`](./src/types/common.md) — public type contracts
## Subpath exports
```ts
import { Money } from "@suaveplan/money/money/money";
import { formatMoney } from "@suaveplan/money/formatter/formatter";
import { allocateEvenly } from "@suaveplan/money/allocator/allocator";
import { registerCurrency } from "@suaveplan/money/currencies/currencies";
import { MoneyError } from "@suaveplan/money/error-codes/error-codes";
import type { MoneyLike } from "@suaveplan/money/types/common";
```
## Error handling
Every failure path raises a typed `MoneyError` carrying a stable
`MONEY.*` code. Six codes are declared: `CURRENCY_MISMATCH`,
`UNKNOWN_CURRENCY`, `DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`,
`PARSE_ERROR`.
```ts
import { Money, MoneyError, MONEY_CODES } from "@suaveplan/money";
try {
Money.fromMinor(100n, "USD").add(Money.fromMinor(50n, "EUR"));
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.CURRENCY_MISMATCH) {
console.error(`mismatch: ${err.currency} vs ${err.otherCurrency}`);
}
}
```
## License
MIT — © Suaveplan Team

View File

@@ -0,0 +1,144 @@
# `allocator` — fair-share distribution + aggregation helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The allocator module solves two related problems: splitting a `Money` amount
across N recipients (or N weights or N percentages) without inventing or losing
minor units, and folding many `Money` values into a single extremum or sum.
The allocators (`allocateEvenly`, `allocateByRatios`, `allocateByPercentages`)
all return an `AllocationResult { shares, remainder }` where `sumMoney(shares)
=== input.amount` — this invariant holds even when integer division produces
fractional minor units, because remainders are distributed round-robin to the
leading shares. Negative amounts are handled symmetrically (the sign is
preserved on each distributed remainder). The `remainder` field is always
`0n` today; it is kept on the return shape for forward-compatibility with
allocators that might surface the unallocated portion.
The aggregators (`sumMoney`, `minMoney`, `maxMoney`) raise typed errors on
empty input (`MONEY.EMPTY_INPUT`) and on heterogeneous-currency input
(`MONEY.CURRENCY_MISMATCH`) — silent currency mixing would lead to nonsensical
totals.
## Features
- `allocateEvenly` — split into N equal parts with round-robin remainder distribution.
- `allocateByRatios` — split by relative weights; weights can be fractional.
- `allocateByPercentages` — split by exact percentages (must sum to 100 ± 0.001).
- `sumMoney` / `minMoney` / `maxMoney` — typed aggregators with cross-currency guards.
- Sum-equals-input invariant guaranteed by every allocator.
- Negative inputs handled with sign preservation.
- All errors raise typed `MoneyError` with stable `MONEY.*` codes.
## Usage: Basic Example
```ts
import {
allocateEvenly,
allocateByRatios,
allocateByPercentages,
sumMoney,
minMoney,
maxMoney,
Money,
} from "@suaveplan/money";
// Split a $10 bill among 3 people — no penny lost
const evenly = allocateEvenly({ amount: 1000n, currency: "USD" }, 3);
console.log(evenly.shares.map((s) => s.amount)); // [334n, 333n, 333n]
// Split £10,000 revenue 50/30/20
const revenue = Money.fromMajor(10000, "GBP");
const splits = allocateByRatios(revenue, [50, 30, 20]);
console.log(splits.shares.map((s) => s.toString()));
// Aggregators
console.log(sumMoney([Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")]).amount); // 150n
console.log(minMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")).amount); // 50n
console.log(maxMoney(Money.fromMinor(100n, "USD"), Money.fromMinor(50n, "USD")).amount); // 100n
```
## Usage: Advanced Example
Use ratios with fractional weights to model a tiered payout, and verify the
sum-equals-input invariant explicitly for an auditable distribution:
```ts
import {
allocateByRatios,
sumMoney,
MoneyError,
MONEY_CODES,
} from "@suaveplan/money";
import type { MoneyLike } from "@suaveplan/money";
interface Payout {
party: string;
weight: number;
}
function distribute(total: MoneyLike, payouts: readonly Payout[]): Map<string, MoneyLike> {
const result = allocateByRatios(total, payouts.map((p) => p.weight));
const sum = sumMoney(result.shares);
if (sum.amount !== total.amount) {
// This branch is impossible — invariant guaranteed — but defensive code
// documents the contract for future maintainers.
throw new MoneyError(MONEY_CODES.INVALID_ALLOCATION, "invariant broken");
}
return new Map(payouts.map((p, i) => [p.party, result.shares[i] as MoneyLike]));
}
// Three-tier payout: a, b, c receive 1.5, 1.0, 0.5 weight units
const distribution = distribute(
{ amount: 1000n, currency: "USD" },
[
{ party: "a", weight: 1.5 },
{ party: "b", weight: 1.0 },
{ party: "c", weight: 0.5 },
]
);
```
## API Reference
### Allocators
| Function | Signature | Throws |
|---|---|---|
| `allocateEvenly` | `(money: MoneyLike, parts: number) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` when `parts <= 0`. |
| `allocateByRatios` | `(money: MoneyLike, ratios: readonly number[]) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` on empty array, negative ratio, or total ratio ≤ 0. |
| `allocateByPercentages` | `(money: MoneyLike, percentages: readonly number[]) => AllocationResult` | `MoneyError({ code: MONEY.INVALID_ALLOCATION })` when percentages don't sum to 100 (± 0.001). |
### Aggregators
| Function | Signature | Throws |
|---|---|---|
| `sumMoney` | `(values: readonly MoneyLike[]) => Money` | `MoneyError({ code: MONEY.EMPTY_INPUT })` on empty array; `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `minMoney` | `(...values: MoneyLike[]) => Money` | Same as `sumMoney`. |
| `maxMoney` | `(...values: MoneyLike[]) => Money` | Same as `sumMoney`. |
## Implementation Notes
`allocateEvenly` uses `bigint` integer division: `base = amount / n`, `remainder
= amount % n`. The first `|remainder|` shares each get an extra unit (with the
sign of the original amount), so negative inputs distribute negative
remainders. The result `remainder` is always `0n` because the entire input is
distributed into `shares`.
`allocateByRatios` multiplies the ratio by `1000` before converting to `bigint`
to preserve three decimal places of weight precision (`Math.round(ratio *
1000)`). The post-pass distributes any rounding residue round-robin to keep the
sum-equals-input invariant. This handles both positive and negative inputs.
`allocateByPercentages` is a thin wrapper that validates the input sum is
within `0.001` of `100` (a tolerance chosen to permit floating-point summing of
literal percentage values) and then delegates to `allocateByRatios`.
The aggregators take varargs (`minMoney`, `maxMoney`) or an array (`sumMoney`)
to match upstream — both shapes are convenient at call sites. Empty input is a
typed error rather than `undefined` because returning `undefined` would force
every caller to nullcheck and the empty case is almost always a bug.

View File

@@ -0,0 +1,119 @@
# `currencies` — built-in registry + lookup + registration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the currency-metadata registry that every other part of
`@suaveplan/money` consults to compute minor-unit precision, format output, and
validate inputs. It ships with 30 ISO 4217 fiat entries (USD, EUR, GBP, JPY,
CHF, CAD, AUD, CNY, INR, BRL, KRW, MXN, SGD, HKD, NOK, SEK, DKK, NZD, ZAR, PLN,
THB, TRY, TWD, AED, SAR, ILS, RUB, CZK, CLP, PHP) and 5 cryptocurrencies (BTC,
ETH, SOL, USDT, USDC) whose exponents match the canonical smallest-unit
definitions (satoshi, wei, lamport, etc.).
Callers register custom or virtual currencies through `registerCurrency(info)` —
this mutates a module-level `Map` so subsequent lookups across the entire
process observe the change. The semantic is deliberate (matches upstream)
because the alternative — passing a registry instance through every API — would
break the single-import-and-use ergonomic that consumers expect. A
constructor-injectable `CurrencyRegistry` is on the roadmap for multi-tenant
process sharing but is out of scope for this migration.
## Features
- 30 ISO 4217 fiat entries + 5 cryptocurrency entries, vetted exponents and Unicode symbols.
- `getCurrency(code)` — `O(1)` lookup; returns `undefined` for unknown codes.
- `getCurrencyOrThrow(code)` — raises typed `MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency })` when neither built-in nor custom.
- `registerCurrency(info)` — register custom or override built-in; observable globally.
- `getAllCurrencyCodes()` — sorted list of every registered code (built-in + custom).
- `getVirtualCurrencies()` / `isVirtualCurrency(code)` — helpers for game-currency callers.
- `minorUnitMultiplier(exponent)` — `10n ** BigInt(exponent)` helper used by the formatter and parser.
## Usage: Basic Example
```ts
import { getCurrency, getCurrencyOrThrow, registerCurrency } from "@suaveplan/money";
// Look up a built-in
const usd = getCurrency("USD"); // { code: "USD", exponent: 2, symbol: "$", name: "US Dollar" }
// Throw for unknown
try {
getCurrencyOrThrow("XYZ");
} catch (err) {
// MoneyError with code "MONEY.UNKNOWN_CURRENCY" and err.currency === "XYZ"
}
// Register a custom loyalty-points currency
registerCurrency({
code: "PTS",
exponent: 0,
symbol: "pts",
name: "Loyalty Points",
});
```
## Usage: Advanced Example
Register a virtual / game currency with an issuer identifier and use the
virtual-currency helpers to iterate every game-side balance:
```ts
import {
registerCurrency,
getVirtualCurrencies,
isVirtualCurrency,
Money,
formatMoney,
} from "@suaveplan/money";
registerCurrency({
code: "GEM",
exponent: 0,
symbol: "💎",
name: "Game Gem",
virtual: true,
issuerId: "game-op-1",
});
const balance = Money.fromMinor(5000n, "GEM");
console.log(formatMoney(balance)); // "💎5000"
console.log(isVirtualCurrency("GEM")); // true
// Enumerate every registered virtual currency for an audit report
for (const info of getVirtualCurrencies()) {
console.log(`${info.code} issued by ${info.issuerId ?? "<unknown>"}`);
}
```
## API Reference
| Export | Signature | Description |
|---|---|---|
| `getCurrency` | `(code: CurrencyCode) => CurrencyInfo \| undefined` | Look up metadata; returns `undefined` when unknown. |
| `getCurrencyOrThrow` | `(code: CurrencyCode) => CurrencyInfo` | Look up metadata; raises `MoneyError({ code: MONEY.UNKNOWN_CURRENCY, currency })` when unknown. |
| `registerCurrency` | `(info: CurrencyInfo) => void` | Add or override an entry. Custom entries take precedence over built-ins. |
| `getAllCurrencyCodes` | `() => CurrencyCode[]` | Returns every registered code (built-in + custom), sorted ascending. |
| `isVirtualCurrency` | `(code: CurrencyCode) => boolean` | `true` when the entry exists and has `virtual === true`. |
| `getVirtualCurrencies` | `() => CurrencyInfo[]` | Every registered entry whose `virtual` flag is `true`. |
| `minorUnitMultiplier` | `(exponent: number) => bigint` | Compute `10n ** BigInt(exponent)`. |
## Implementation Notes
The registry uses two maps: a frozen built-in `Record<string, CurrencyInfo>`
literal and a mutable `Map<string, CurrencyInfo>` for customs. Lookups consult
the customs map first so callers can override built-ins (useful for test
fixtures that want a deterministic symbol or exponent).
`registerCurrency` mutates module-level state. This is deliberate but it means
test suites must restore prior state when overriding built-ins; the test file
demonstrates the try/finally pattern. A scoped `CurrencyRegistry` instance is
on the roadmap for multi-tenant servers that need isolated registries — see
`enterprise/money.md` §3.
`minorUnitMultiplier(exponent)` uses `BigInt(exponent)` so `10n ** BigInt(18)`
produces `1_000_000_000_000_000_000n` without precision loss — the same value
ETH uses for wei.

View File

@@ -0,0 +1,107 @@
# `error-codes` — `MONEY_CODES` + `MoneyError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/money` raises only typed errors. This module owns the stable
`MONEY_CODES` registry (built with `defineCodes("MONEY", [...])` from
`@suaveplan/error`) and the `MoneyError` subclass that every public failure path
constructs. Consumers programmatically dispatch on the namespaced strings
(`"MONEY.CURRENCY_MISMATCH"`, `"MONEY.UNKNOWN_CURRENCY"`, ...) and inspect the
`currency` / `otherCurrency` discriminants to recover from or report on
failures.
The class extends `SuaveplanError<MoneyErrorCode>`, so `instanceof MoneyError`
narrows `err.code` to the literal union, and `instanceof SuaveplanError` carries
the full project contract: `.code`, `.cause`, `.context`, `.timestamp`,
`.traceId`. Six codes are declared: `CURRENCY_MISMATCH`, `UNKNOWN_CURRENCY`,
`DIVISION_BY_ZERO`, `INVALID_ALLOCATION`, `EMPTY_INPUT`, `PARSE_ERROR`. Every
code is reachable from a real production call site — registry pollution is
banned by repo policy.
## Features
- Stable namespaced codes via `defineCodes("MONEY", [...] as const)` — the
registry is frozen, the namespace is encoded into each value.
- `MoneyError extends SuaveplanError<MoneyErrorCode>` — `instanceof` narrows
`.code` to the literal-string union; `cause` chains arbitrary throwables.
- `currency` / `otherCurrency` discriminants on every error instance for
programmatic diagnostics (`err.currency === "USD"`).
- Co-located test pinning every code to a real call site so the registry never
drifts ahead of the implementation.
## Usage: Basic Example
```ts
import { Money, MoneyError, MONEY_CODES } from "@suaveplan/money";
try {
Money.fromMinor(100n, "USD").add(Money.fromMinor(50n, "EUR"));
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.CURRENCY_MISMATCH) {
console.error(`Cannot mix ${err.currency} with ${err.otherCurrency}`);
} else {
throw err;
}
}
```
## Usage: Advanced Example
Use the `cause` chain to wrap an external failure with stable code routing while
preserving the original error for upstream loggers:
```ts
import { MoneyError, MONEY_CODES } from "@suaveplan/money";
function parseUserInput(raw: string, currency: string) {
try {
return Money.fromMajor(raw, currency);
} catch (err) {
if (err instanceof MoneyError && err.code === MONEY_CODES.PARSE_ERROR) {
// Wrap with our own contextual error so downstream callers see the
// user-facing message but still recover the underlying MoneyError
// via .cause.
throw new MoneyError(
MONEY_CODES.PARSE_ERROR,
`Field "amount" could not be parsed`,
{ cause: err, currency }
);
}
throw err;
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `MONEY_CODES` | `const` (frozen) | Registry of six namespaced codes built via `defineCodes`. |
| `MoneyErrorCode` | `type` | Literal-string union of every registry value. |
| `MoneyErrorOptions` | `interface` | Extends `SuaveplanErrorOptions` with optional `currency` and `otherCurrency`. |
| `MoneyError` | `class` | `extends SuaveplanError<MoneyErrorCode>` — constructor takes `(code, message, options?)`. |
### `MoneyError` properties
- `code: MoneyErrorCode` — the namespaced error code (e.g. `"MONEY.CURRENCY_MISMATCH"`).
- `currency?: CurrencyCode` — the currency the operation was attempted against.
- `otherCurrency?: CurrencyCode` — the second currency in a mismatch pair.
- `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 `"currency": undefined`. `MoneyError` is `instanceof SuaveplanError` and
`instanceof Error`; tests pin both. The registry is created with
`as const` so TypeScript infers literal types — `MONEY_CODES.CURRENCY_MISMATCH`
is `"MONEY.CURRENCY_MISMATCH"`, 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 `MONEY_CODES` test to assert the literal value.

View File

@@ -0,0 +1,144 @@
# `formatter` — locale-aware human-readable Money rendering
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The formatter renders `MoneyLike` values to human-readable strings using
`Intl.NumberFormat(locale)`-derived separators. Two entry points cover the
common cases: `formatMoney(money, options?)` produces a full-precision string
honoring the currency exponent and locale conventions, while
`formatMoneyCompact(money)` collapses large values into `K` / `M` / `B`
notation for dashboards and summary widgets.
Unlike `Intl.NumberFormat`'s built-in currency formatter, this implementation
preserves `bigint` precision on the integer portion — it never casts the
amount through `Number` before formatting, so values with more than 15
significant digits (common for ETH amounts in wei) render exactly. The locale
contribution is limited to deriving the decimal and thousands separators, both
of which can be overridden via `decimalSeparator` / `thousandsSeparator` for
callers who need locked output.
## Features
- Locale-aware decimal + thousands separators via `Intl.NumberFormat`.
- `showSymbol` (default `true`) / `showCode` (default `false`) selectable display modes.
- `decimalSeparator` / `thousandsSeparator` overrides that win over locale.
- `trailingZeros` flag to keep or trim trailing zeros in the fractional part.
- Zero-exponent currencies (JPY, KRW, CLP) format without a decimal point.
- Negative amounts prepend `-` before the symbol.
- Compact mode with K / M / B suffixes and currency-precision fallback for small values.
- Universal (only `Intl.NumberFormat` is touched — no `node:`, `bun:`, DOM, or fs).
## Usage: Basic Example
```ts
import { formatMoney, formatMoneyCompact, Money } from "@suaveplan/money";
formatMoney({ amount: 1050n, currency: "USD" });
// "$10.50"
formatMoney({ amount: 1050n, currency: "USD" }, { showCode: true });
// "10.50 USD"
formatMoney({ amount: 105050n, currency: "EUR" }, { locale: "de-DE" });
// "€1.050,50"
formatMoneyCompact(Money.fromMajor(2_500_000, "USD"));
// "$2.5M"
```
## Usage: Advanced Example
Use the override knobs to format consistently for a multi-tenant report where
each tenant's locale differs but the corporate style guide demands the same
visual format (US-style thousands separator, no trailing zeros):
```ts
import { formatMoney, sumMoney } from "@suaveplan/money";
import type { MoneyLike } from "@suaveplan/money";
interface TenantInvoice {
tenantId: string;
locale: string;
items: readonly MoneyLike[];
}
function renderTenantTotal(invoice: TenantInvoice): string {
const total = sumMoney(invoice.items);
return formatMoney(total, {
locale: invoice.locale,
// Lock thousands + decimal separators to corporate-style, no trailing zeros.
thousandsSeparator: ",",
decimalSeparator: ".",
trailingZeros: false,
});
}
const out = renderTenantTotal({
tenantId: "t-1",
locale: "de-DE",
items: [
{ amount: 123_45n, currency: "EUR" },
{ amount: 876_55n, currency: "EUR" },
],
});
console.log(out); // "€1,000" — locked separators win even with de-DE locale
```
## API Reference
### `formatMoney(money, options?)`
| Argument | Type | Description |
|---|---|---|
| `money` | `MoneyLike` | The value to format. |
| `options.locale` | `string` (default `"en-US"`) | BCP 47 locale tag for separator derivation. |
| `options.showSymbol` | `boolean` (default `true`) | Prefix the currency symbol. |
| `options.showCode` | `boolean` (default `false`) | Suffix the ISO code; suppresses the symbol when `true`. |
| `options.decimalSeparator` | `string` | Override the Intl-derived decimal separator. |
| `options.thousandsSeparator` | `string` | Override the Intl-derived thousands separator. |
| `options.trailingZeros` | `boolean` (default `true`) | Include trailing zeros in the fractional part. |
Returns `string`. Throws `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` if the
currency is not registered.
### `formatMoneyCompact(money)`
Returns a compact string:
- `amount ≥ 1e9` → `<n.n>B` (e.g. `"$1.5B"`)
- `amount ≥ 1e6` → `<n.n>M`
- `amount ≥ 1e3` → `<n.n>K`
- smaller → currency-precision fixed-point (`exponent` decimals, capped at 2)
Returns `string`. Throws `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` if the
currency is not registered.
## Implementation Notes
The formatter never casts the integer portion through `Number` — it divides the
`bigint` amount by the `bigint` multiplier and renders both parts as strings.
Only `formatMoneyCompact` uses a `Number` cast (deliberately — compact output
is approximate by design and cannot represent precision differences between
`1.5K` and `1.50000001K`).
Locale-aware separator derivation uses `Intl.NumberFormat(locale).formatToParts`
on a probe value (`1.1` for decimal, `1000` for group). Every modern locale ICU
recognises includes both parts, so the `find` returns a defined entry — the
type assertion in the helpers is safe and avoids a dead fallback branch that
would inflate coverage tracking.
Output assembly:
1. Compute `whole = abs / multiplier`, `frac = abs % multiplier`.
2. Format `whole` with grouping (`formatWholePart`).
3. Format `frac` with padding-to-exponent (`formatFracPart`), optionally
trimming trailing zeros.
4. Join with the locale (or override) separator.
5. Prefix `-` for negative, then apply `showCode` / `showSymbol` policy.
The compact path uses `(abs / 1_000_000_000).toFixed(1)` etc., which produces a
single-decimal output even for round numbers (`$7.0B`).

View File

@@ -0,0 +1,150 @@
# `money` — immutable Money value type + `applyRounding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Money` is the cornerstone of the package: an immutable value object that
stores monetary amounts as `bigint` minor units (cents, satoshi, lamports, wei)
together with an ISO 4217 (or caller-registered) `currency` code. Every
arithmetic, comparison, and conversion method returns a new instance —
mutations are impossible — so callers can pass `Money` values around freely
without defensive copying.
The class is `implements MoneyLike` so any function expecting `{ readonly
amount: bigint, readonly currency: CurrencyCode }` accepts a `Money` directly.
Static constructors handle every common entry point: `fromMajor` for
floating-point or decimal-string inputs, `fromMinor` for already-minor-unit
inputs, `zero` for the additive identity, and `fromJSON` for the
`toJSON`-symmetric round-trip. Cross-currency operations raise
`MoneyError({ code: MONEY.CURRENCY_MISMATCH })` carrying both currencies on the
discriminants, and `divide(0)` raises `MoneyError({ code: MONEY.DIVISION_BY_ZERO })`.
`applyRounding` is a separately-exported helper that exposes the five
RoundingMode strategies (`half-up`, `half-down`, `half-even`, `floor`, `ceil`)
the multiply / divide methods use; consumers occasionally need it directly for
custom arithmetic.
## Features
- `bigint`-backed amount with `readonly amount` / `readonly currency` properties.
- Static constructors: `fromMajor(number | string, currency)`, `fromMinor(bigint | number, currency)`, `zero(currency)`, `fromJSON({ amount, currency })`.
- Arithmetic: `add`, `subtract`, `multiply(factor, rounding?)`, `divide(divisor, rounding?)`, `abs`, `negate`.
- Comparison: `compareTo`, `equals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `isZero`, `isPositive`, `isNegative`.
- Conversion: `toMajor()` (lossy for very large values), `toDecimalString()` (precise), `toJSON()`, `toString()`.
- Hardened decimal-string parser — malformed input raises `MoneyError({ code: MONEY.PARSE_ERROR })` instead of silently producing `NaN`-ish bigints.
- `applyRounding(value, mode)` — standalone helper used by `multiply` / `divide` and exported for direct caller use.
## Usage: Basic Example
```ts
import { Money, formatMoney } from "@suaveplan/money";
const price = Money.fromMajor(29.99, "USD");
const tax = price.multiply(0.08); // half-up by default
const total = price.add(tax);
console.log(formatMoney(total)); // "$32.39"
console.log(total.toDecimalString()); // "32.39"
console.log(total.toString()); // "32.39 USD"
console.log(total.toJSON()); // { amount: "3239", currency: "USD" }
```
## Usage: Advanced Example
Demonstrate `bigint` precision for high-exponent cryptocurrency arithmetic plus
explicit banker's rounding for jurisdictional compliance:
```ts
import { Money, applyRounding } from "@suaveplan/money";
// 0.5 ETH split four ways — preserving wei-level precision throughout.
const reward = Money.fromMajor("0.5", "ETH"); // amount = 500_000_000_000_000_000n
const perRecipient = reward.divide(4, "half-even"); // banker's rounding
console.log(perRecipient.amount); // 125_000_000_000_000_000n
console.log(perRecipient.toDecimalString()); // "0.125000000000000000"
// Use applyRounding for a custom calculation outside the Money class.
const customScale = applyRounding(12.5, "half-even"); // 12n (round to even)
// Demonstrate that arithmetic never mutates the receiver.
const original = Money.fromMinor(100n, "USD");
const doubled = original.multiply(2);
console.log(original.amount); // 100n — unchanged
console.log(doubled.amount); // 200n
```
## API Reference
### `class Money implements MoneyLike`
| Property | Type | Description |
|---|---|---|
| `amount` | `readonly bigint` | The amount in minor units. |
| `currency` | `readonly CurrencyCode` | The currency code. |
### Static constructors
| Method | Signature | Throws |
|---|---|---|
| `Money.fromMajor` | `(value: number \| string, currency: CurrencyCode) => Money` | `MoneyError({ code: MONEY.PARSE_ERROR })` on malformed string; `MoneyError({ code: MONEY.UNKNOWN_CURRENCY })` on unknown currency. |
| `Money.fromMinor` | `(amount: bigint \| number, currency: CurrencyCode) => Money` | — |
| `Money.zero` | `(currency: CurrencyCode) => Money` | — |
| `Money.fromJSON` | `(json: { amount: string; currency: CurrencyCode }) => Money` | `SyntaxError` if `amount` is not a valid bigint string. |
### Instance methods
| Method | Signature | Throws |
|---|---|---|
| `add` | `(other: MoneyLike) => Money` | `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `subtract` | `(other: MoneyLike) => Money` | Same as `add`. |
| `multiply` | `(factor: number \| bigint, rounding?: RoundingMode) => Money` | — |
| `divide` | `(divisor: number \| bigint, rounding?: RoundingMode) => Money` | `MoneyError({ code: MONEY.DIVISION_BY_ZERO })` when `divisor` is `0` / `0n`. |
| `abs` / `negate` | `() => Money` | — |
| `compareTo` | `(other: MoneyLike) => -1 \| 0 \| 1` | `MoneyError({ code: MONEY.CURRENCY_MISMATCH })` on cross-currency. |
| `equals` | `(other: MoneyLike) => boolean` | — (returns `false` for cross-currency rather than throwing). |
| `greaterThan` / `lessThan` / `greaterThanOrEqual` / `lessThanOrEqual` | `(other: MoneyLike) => boolean` | Same as `compareTo`. |
| `isZero` / `isPositive` / `isNegative` | `() => boolean` | — |
| `toMajor` | `() => number` | — (lossy for very large amounts). |
| `toDecimalString` | `() => string` | — (precise; preserves trailing zeros for the currency exponent). |
| `toJSON` | `() => { amount: string; currency: CurrencyCode }` | — |
| `toString` | `() => string` | — (returns `<decimal> <currency>`). |
### `applyRounding(value, mode)`
| Argument | Type |
|---|---|
| `value` | `number` |
| `mode` | `RoundingMode` (`half-up` / `half-down` / `half-even` / `floor` / `ceil`) |
Returns `bigint`.
## Implementation Notes
`fromMajor(number)` uses `Math.round(value * Number(multiplier))` so values
with more than ~15 significant digits are lossy — pass decimal strings for
precision-critical inputs. `fromMajor(string)` runs through a hardened
parser that validates `^-?(?:\d+(?:\.\d*)?|\.\d+)$` and raises
`MoneyError({ code: MONEY.PARSE_ERROR })` on anything else; the fractional part
is padded or truncated to the currency exponent so `Money.fromMajor("10.555",
"USD")` produces `1055n` (two minor digits).
`divide(divisor)` casts both operands through `Number` before applying the
rounding mode, so callers needing exact rational arithmetic should multiply by
the inverse instead. `divide(0n)` is treated identically to `divide(0)` — both
raise `MoneyError({ code: MONEY.DIVISION_BY_ZERO })`.
`compareTo` raises on cross-currency rather than silently returning a numeric
comparison; `equals` returns `false` instead of throwing so callers can use it
in conditional checks without try/catch. The asymmetry is intentional —
ordering across currencies is meaningless, but equality is naturally `false`.
`applyRounding` handles negative values correctly: `Math.floor(-2.5) === -3` is
the floor direction, and the banker's-rounding `half-even` mode uses the
`Math.abs(diff - 0.5) < Number.EPSILON` test to detect exact halves before
applying the round-to-even rule.
`toDecimalString` short-circuits for zero-exponent currencies (JPY, KRW, CLP)
so the output never carries a redundant `.0`.

View File

@@ -0,0 +1,108 @@
# `types/common` — public type contracts
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/money` exposes a small, stable set of types that downstream packages
(`@suaveplan/currency`, `@suaveplan/payment`, the API plugins, the React UI
hooks) depend on for structural typing. This module is the single source of
truth for those types — every implementation file imports from here, and every
consumer should import the type from `@suaveplan/money` (or the subpath
`@suaveplan/money/types/common`) rather than re-deriving it.
The shapes are deliberately minimal: `MoneyLike` is just `{ readonly amount:
bigint; readonly currency: CurrencyCode }` so any object with those two fields
interoperates with `formatMoney`, the allocators, and the aggregators without
needing to be an actual `Money` instance. `CurrencyInfo` carries the metadata
needed for formatting and arithmetic (exponent, symbol, name) plus optional
flags for virtual / game currencies. `FormatOptions` exposes every knob the
formatter accepts; `AllocationResult` is the contract every allocator returns.
## Features
- `MoneyLike` — duck-typed interop shape (`{ readonly amount: bigint, readonly currency: CurrencyCode }`).
- `CurrencyInfo` — ISO 4217 metadata + optional `virtual` / `issuerId` for game currencies.
- `FormatOptions` — every formatter knob: locale, symbol/code mode, separator overrides, trailing-zero control.
- `AllocationResult` — every allocator's return shape; `shares` array + `remainder` (always `0n` today).
- `RoundingMode` — literal-string union of the five rounding modes used by `multiply`, `divide`, and `applyRounding`.
- `MoneyOptions` — constructor-options shape for `Money`.
- `CurrencyCode` — opaque string alias clarifying ISO 4217 intent at call sites.
## Usage: Basic Example
```ts
import type { MoneyLike, FormatOptions } from "@suaveplan/money";
import { formatMoney } from "@suaveplan/money";
function renderPrice(value: MoneyLike, locale: string): string {
const opts: FormatOptions = { locale, showCode: true };
return formatMoney(value, opts);
}
renderPrice({ amount: 1050n, currency: "USD" }, "en-US"); // "10.50 USD"
```
## Usage: Advanced Example
Use the structural `MoneyLike` shape to interoperate with foreign data without
having to construct a `Money` instance — useful when payload validation has
already produced trusted `{ amount, currency }` records:
```ts
import type { MoneyLike, AllocationResult } from "@suaveplan/money";
import { allocateByRatios, sumMoney } from "@suaveplan/money";
interface Invoice {
total: MoneyLike;
splits: readonly { partyId: string; ratio: number }[];
}
function distribute(invoice: Invoice): Map<string, MoneyLike> {
const result: AllocationResult = allocateByRatios(
invoice.total,
invoice.splits.map((s) => s.ratio)
);
// sumMoney always equals the input — invariant guaranteed by the allocator.
if (sumMoney(result.shares).amount !== invoice.total.amount) {
throw new Error("allocator broke its invariant");
}
return new Map(
invoice.splits.map((s, i) => [s.partyId, result.shares[i] as MoneyLike])
);
}
```
## API Reference
| Type | Shape |
|---|---|
| `CurrencyCode` | `string` (opaque alias) |
| `RoundingMode` | `"half-up" \| "half-down" \| "half-even" \| "floor" \| "ceil"` |
| `MoneyOptions` | `{ amount: bigint; currency: CurrencyCode }` |
| `CurrencyInfo` | `{ code; exponent; symbol; name; virtual?; issuerId? }` |
| `FormatOptions` | `{ locale?; showSymbol?; showCode?; decimalSeparator?; thousandsSeparator?; trailingZeros? }` |
| `MoneyLike` | `{ readonly amount: bigint; readonly currency: CurrencyCode }` |
| `AllocationResult` | `{ shares: readonly MoneyLike[]; remainder: bigint }` |
All types are re-exported from the package root and from the
`@suaveplan/money/types/common` subpath.
## Implementation Notes
`CurrencyCode` is intentionally `string` rather than a literal union because the
registry is open — callers may `registerCurrency({ code: "GEM", ... })` for
game / loyalty / experimental tokens. A closed union would force every consumer
to use a type-assertion when accepting user-registered codes, defeating the
ergonomic purpose of the alias.
`AllocationResult.remainder` is always `0n` in the current allocators (every
allocator distributes its remainder into `shares`). The field is retained as a
forward-compatible escape hatch for future allocators (e.g. greedy allocators
that surface their remainder for the caller to handle out-of-band).
`MoneyLike` deliberately omits methods — it is the structural-typing contract,
not the implementation surface. `Money implements MoneyLike` satisfies the
constraint at no runtime cost.

View File

@@ -0,0 +1,19 @@
# `payment`
Payment intent lifecycle, provider adapters, and fraud detection.
<!-- begin:auto-docs -->
## Packages in `payment`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestration, r… |
| [`@suaveplan/payment-provider-adyen`](./payment-provider-adyen/README.md) | Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`. Implements the `PaymentProvider` interface via the slim `@suaveplan/payment-providers` c… |
| [`@suaveplan/payment-provider-mollie`](./payment-provider-mollie/README.md) | Universal Mollie Payments API v2 adapter for `@suaveplan/payment`. Sibling provider package on the slim `@suaveplan/payment-providers` core: implements `Paymen… |
| [`@suaveplan/payment-provider-paypal`](./payment-provider-paypal/README.md) | PayPal sibling adapter for the slim `@suaveplan/payment-providers` foundation. Ships `PayPalProvider` — a `PaymentProvider` implementation against the PayPal O… |
| [`@suaveplan/payment-provider-square`](./payment-provider-square/README.md) | Universal Square Payments API adapter for `@suaveplan/payment`. Ships the `SquareProvider` class (implements the structural `PaymentProvider` contract with lit… |
| [`@suaveplan/payment-provider-stripe`](./payment-provider-stripe/README.md) | Universal Stripe adapter for `@suaveplan/payment`. Implements `PaymentProvider` against the Stripe REST API using the slim `@suaveplan/payment-providers` found… |
| [`@suaveplan/payment-providers`](./payment-providers/README.md) | Universal **foundation** for the `@suaveplan/payment-provider-*` sibling-package family. This package ships only the shared abstractions every concrete adapter… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Payment intent lifecycle, provider adapters, and fraud detection.

View File

@@ -0,0 +1,94 @@
# @suaveplan/payment-provider-adyen - Universal Adyen Checkout API v71 adapter for @suaveplan/payment. Implements the PaymentProvider interface via the slim @suaveplan/payment-providers core (httpRequest + composeAbortSignal + MerchantKeyConfig + PaymentProviderError), forwarding X-API-Key auth on every call and injecting the configured merchantAccount into capture / cancel / refund request bodies. Generates default payment references via an injectable RandomIdFn so the package stays universal and deterministic under test.
Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`.
Implements the `PaymentProvider` interface via the slim
`@suaveplan/payment-providers` core (`httpRequest`,
`composeAbortSignal`, `MerchantKeyConfig`, `PaymentProviderError`),
forwarding `X-API-Key` auth on every call and injecting the configured
`merchantAccount` into capture / cancel / refund request bodies.
Generates default payment references via an injectable `RandomIdFn`
so the package stays universal and deterministic under test.
<!-- begin:auto-docs -->
## Documentation tree
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
- [mapping](./src/adyen/mapping.md) — Pure mapping helpers that lift Adyen Checkout API v71 response shapes into the canonical `PaymentIntent`, `PaymentMetho…
- [types](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
- [adyen](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-adyen @suaveplan/payment @suaveplan/payment-providers
```
## Quick start
```ts
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { PaymentEngine } from "@suaveplan/payment";
const adyen = new AdyenProvider({
apiKey: process.env.ADYEN_API_KEY ?? "",
merchantAccount: "AcmeMerchantECOM",
});
const registry = new ProviderRegistry();
registry.register(adyen);
const engine = new PaymentEngine({ provider: registry.get("adyen") });
const intent = await engine.createIntent({
amount: { amount: 5_000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Features
- **Full `PaymentProvider` surface** — `createIntent` /
`confirmIntent` / `captureIntent` / `cancelIntent` / `refund` /
`getIntent`.
- **`X-API-Key` authentication** with `Idempotency-Key` forwarding on
`createIntent` and `refund`.
- **`merchantAccount` context** injected into every modification body
(`/captures`, `/cancels`, `/refunds`).
- **Injectable `RandomIdFn`** for default `reference` generation —
with `globalThis.crypto.randomUUID()` fallback at
call time.
- **Adyen status mapping** for all 10 documented v71 result codes
plus `failed` fallback.
- **Typed errors only** — every failure surfaces as
`PaymentProviderError` (HTTP / timeout / parse) from the slim core.
- **Universal tier** — zero `node:` / `bun:` / `fs` / `path` /
`stream` / `net` / `http` / `crypto` imports; runs on Node, Bun,
browsers, and Web Workers.
- **Tree-shakeable** — `sideEffects: false` + per-feature subpath
exports.
- **100% test coverage** (Istanbul) + 100% docs coverage.
## Modules
- [`src/adyen/adyen.md`](./src/adyen/adyen.md) — the `AdyenProvider`
class.
- [`src/adyen/mapping.md`](./src/adyen/mapping.md) — pure mappers
(`mapStatus`, `mapRefundStatus`, `mapPaymentMethod`, `mapPayment`).
- [`src/types/adyen.md`](./src/types/adyen.md) — `AdyenConfig` type
alias for the slim-core `MerchantKeyConfig`.
## Related
- [`@suaveplan/payment-providers`](../payment-providers/README.md) —
slim foundation: `httpRequest`, `composeAbortSignal`,
`ProviderRegistry`, `PaymentProviderError`, shared config types.
- [`@suaveplan/payment`](../payment/README.md) — `PaymentEngine`,
`WebhookVerifier`, `PaymentProvider` interface.
## License
MIT

View File

@@ -0,0 +1,216 @@
# `AdyenProvider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal adapter that implements the `PaymentProvider` interface
from `@suaveplan/payment` against Adyen's Checkout API v71. The class
authenticates every request via the `X-API-Key` header (rather than
HTTP Basic auth like Stripe/Mollie or OAuth like PayPal), and injects
the configured `merchantAccount` string into every request body Adyen
requires it on (`/payments`, `/payments/{id}/captures`,
`/payments/{id}/cancels`, `/payments/{id}/refunds`).
The class is the sole runtime export of
`@suaveplan/payment-provider-adyen`. It depends only on the slim core
`@suaveplan/payment-providers` (for `httpRequest`,
`PaymentProviderError`, `RandomIdFn`, and `MerchantKeyConfig`) and on
the type-only `@suaveplan/payment` interface — no Node, browser, or
React imports, no `node:crypto`, and no module-scope wall-clock or
RNG access. Default `reference` generation goes through an injectable
`RandomIdFn`, falling back to `globalThis.crypto.randomUUID()` only at
call time and only when available; tests inject a deterministic id
source instead.
## Features
- **Full `PaymentProvider` surface** — `createIntent`, `confirmIntent`,
`captureIntent`, `cancelIntent`, `refund`, `getIntent`.
- **`X-API-Key` authentication** — set on every request, including
`GET`s.
- **`Idempotency-Key` header forwarding** — when the caller supplies
`idempotencyKey` on `createIntent` or `refund`, the same value is
echoed as both the request-level `Idempotency-Key` header and (for
`createIntent`) the `reference` field in the body.
- **Injectable `randomId`** — default `reference` generation uses
caller-supplied `RandomIdFn`; falls back to
`globalThis.crypto.randomUUID()` only at call time so the package
stays universal and never imports `node:crypto`.
- **`merchantAccount` context injection** — every modification body
(`/captures`, `/cancels`, `/refunds`) carries the configured value.
- **Adyen status mapping** — `Authorised` / `Refused` / `Cancelled` /
`Error` / `Pending` / `Received` / `RedirectShopper` /
`IdentifyShopper` / `ChallengeShopper` / `PresentToShopper` plus
unknown codes mapped to the canonical `PaymentStatus`.
- **Refund status mapping** — `received` → `pending`, every other
status → `failed`.
- **Payment-method mapping** — `scheme` → `card`, everything else →
`wallet`; brand lower-cased; `lastFour` preserved as `last4`.
- **Typed errors** — every HTTP / timeout / parse failure surfaces as
a `PaymentProviderError` (one of `PAYMENT_PROVIDER_CODES`) via the
shared `httpRequest` helper; the class never throws plain `Error`.
- **Universal purity** — zero `node:`, `bun:`, `fs`, `path`,
`stream`, `net`, `http`, or `crypto` imports.
## Usage: Basic Example
```ts
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
const adyen = new AdyenProvider({
apiKey: process.env.ADYEN_API_KEY ?? "",
merchantAccount: "AcmeMerchantECOM",
});
const intent = await adyen.createIntent({
amount: { amount: 4_999n, currency: "USD" },
description: "Acme premium subscription",
captureMethod: "automatic",
});
console.log(intent.id, intent.status);
```
## Usage: Advanced Example
```ts
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
import {
ProviderRegistry,
type FetchLike,
} from "@suaveplan/payment-providers";
import { PaymentEngine } from "@suaveplan/payment";
// Test-time injection: recording fetch + deterministic randomId.
const calls: Array<{ url: string; init?: RequestInit }> = [];
const recordingFetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return new Response(
JSON.stringify({
pspReference: "PSP_REC",
resultCode: "Authorised",
amount: { value: 4999, currency: "USD" },
paymentMethod: { type: "scheme", brand: "visa", lastFour: "4242" },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
let refCounter = 0;
const adyen = new AdyenProvider({
apiKey: "AQE_test",
merchantAccount: "AcmeMerchantECOM",
baseUrl: "https://checkout-test.adyen.com/v71",
fetch: recordingFetch,
randomId: () => `ref_${++refCounter}`,
timeoutMs: 5_000,
});
const registry = new ProviderRegistry();
registry.register(adyen);
const engine = new PaymentEngine({ provider: registry.get("adyen") });
const intent = await engine.createIntent({
amount: { amount: 4_999n, currency: "USD" },
captureMethod: "manual",
});
// Verify the recording layer saw the right bytes:
const body = JSON.parse(String(calls[0]?.init?.body));
console.assert(body.merchantAccount === "AcmeMerchantECOM");
console.assert(body.reference === "ref_1");
console.assert(body.captureDelayHours === 0);
```
## API Reference
### `new AdyenProvider(config: AdyenConfig)`
Construct an Adyen adapter. `config` is the `MerchantKeyConfig` alias
documented in [`types/adyen.md`](../types/adyen.md). Required:
`apiKey`, `merchantAccount`. Optional: `baseUrl`, `timeoutMs`,
`signal`, `fetch`, `randomId`, `webhookHmacKey`. When `randomId` is
omitted the class falls back to `globalThis.crypto.randomUUID()` at
call time; if neither is available, the first auto-generated
`reference` raises a `PaymentProviderError` with code
`PAYMENT_PROVIDER.PARSE_ERROR`.
### `readonly name: "adyen"`
Stable identifier consumed by `ProviderRegistry` and any
multi-provider routing layer.
### `createIntent(input): Promise<PaymentIntent>`
POST `${baseUrl}/payments`. Request body always carries
`merchantAccount`, `amount`, and `reference` (caller's `idempotencyKey`
or `randomId()`). When `captureMethod === "manual"` the body adds
`captureDelayHours: 0`. Optional `description` → `shopperStatement`,
`returnUrl` → `returnUrl`, `paymentMethodId` →
`paymentMethod.storedPaymentMethodId`, `metadata` → `metadata`.
### `confirmIntent(intentId): Promise<PaymentIntent>`
POST `${baseUrl}/payments/${intentId}/details` with `{ details: {} }`.
### `captureIntent(intentId): Promise<PaymentIntent>`
POST `${baseUrl}/payments/${intentId}/captures` with
`{ merchantAccount, amount: {} }`. Returns a `PaymentIntent` with
`status: "captured"` and `capturedAmount` reflecting the response.
### `cancelIntent(intentId): Promise<PaymentIntent>`
POST `${baseUrl}/payments/${intentId}/cancels` with
`{ merchantAccount }`. Returns a `PaymentIntent` with
`status: "canceled"`.
### `refund(input): Promise<Refund>`
POST `${baseUrl}/payments/${input.paymentIntentId}/refunds` with
`{ merchantAccount }` plus optional `amount` when supplied. Forwards
`Idempotency-Key` when `input.idempotencyKey` is set. Refund `status`
is mapped via `mapRefundStatus`.
### `getIntent(intentId): Promise<PaymentIntent>`
GET `${baseUrl}/payments/${intentId}`.
### Errors
Every method propagates `PaymentProviderError` for HTTP non-2xx
responses, network failures, request timeouts, and JSON parse
failures — see `@suaveplan/payment-providers`
[`error-codes.md`](../../../payment-providers/src/error-codes/error-codes.md).
The class never throws plain `Error`.
## Implementation Notes
- The default `baseUrl` is the **test** endpoint
(`https://checkout-test.adyen.com/v71`). Live integrations MUST
override `baseUrl` with the merchant-specific
`https://<prefix>-checkout-live.adyenpayments.com/checkout/v71`
URL. Adyen does not provide a single shared live endpoint.
- The cancel response carries no `amount` (Adyen models cancellation
as a zero-value state change), so `cancelIntent` returns
`amount`/`capturedAmount`/`refundedAmount` all set to `{ amount: 0n,
currency: <response.currency> }`. Consumers needing the original
authorisation amount should refetch via `getIntent`.
- `BigInt(p.amount.value)` is safe because Adyen returns integer minor
units across the Checkout API; the conversion would silently round
any floating-point input that ever appeared, which is not a
documented v71 case.
- The class holds no mutable state beyond the constructor seams; every
method composes its own `headers` and request body fresh, so
concurrent calls are safe.
- `mapping.ts` is a sibling file holding the pure status / refund /
payment-method / payment mappers; splitting them out keeps `adyen.ts`
inside the 300-line hard limit (rule 3) and documents the mapping
contract on its own page.
- Webhook signature verification is **not** in scope for this package
— it lives in `@suaveplan/payment`'s `WebhookVerifier`. The
`webhookHmacKey` field on `AdyenConfig` is plumbed through so
consumers can pass it to the verifier without re-typing it.

View File

@@ -0,0 +1,123 @@
# Adyen response mapping
## Purpose
Pure mapping helpers that lift Adyen Checkout API v71 response shapes
into the canonical `PaymentIntent`, `PaymentMethod`, `PaymentStatus`,
and `RefundStatus` types exported by `@suaveplan/payment`. Splitting
these helpers out of `adyen.ts` keeps the provider class under the
300-line hard limit (rule 3) and keeps the mappers separately testable
through the public provider surface — every helper is exercised by the
`AdyenProvider` test suite via real recording `FetchLike` closures.
The mappers are deliberately total: every Adyen `resultCode` that the
v71 contract documents has an explicit row in `RESULT_STATUS`, every
unknown code falls through to `failed`, every non-`received` refund
status collapses to `failed`, and every non-`scheme` payment-method
type collapses to `wallet`. This matches the upstream behaviour
verbatim and the spec delta covers each row with a dedicated test.
## Features
- `mapStatus(resultCode)` — 10-entry result-code table + `failed`
fallback.
- `mapRefundStatus(status)` — single-row table (`received` → `pending`)
with `failed` fallback.
- `mapPaymentMethod(pspReference, method)` — emits `card` for `scheme`,
`wallet` for everything else; lower-cases the brand; preserves
`lastFour` as `last4`.
- `mapPayment(payment, captureMethod, nowIso)` — composes the above
into a full `PaymentIntent`. `capturedAmount` is always `0n` here
because Adyen's `/payments` and `/payments/{id}` endpoints never
return a captured state — capture is a separate `/captures` call
modelled by `AdyenProvider.captureIntent`, which builds its own
response.
- Typed `AdyenAmount` / `AdyenPaymentMethod` / `AdyenPayment` /
`AdyenModification` interfaces describing the response excerpts the
provider consumes.
## Usage: Basic Example
```ts
import { mapStatus, mapRefundStatus } from "@suaveplan/payment-provider-adyen/adyen/adyen";
mapStatus("Authorised"); // -> "authorized"
mapStatus("RedirectShopper"); // -> "pending"
mapStatus("WhoKnows"); // -> "failed"
mapRefundStatus("received"); // -> "pending"
mapRefundStatus("error"); // -> "failed"
```
## Usage: Advanced Example
```ts
import { mapPayment, type AdyenPayment } from "@suaveplan/payment-provider-adyen/adyen/adyen";
const raw: AdyenPayment = {
pspReference: "PSP_999",
resultCode: "Authorised",
amount: { value: 12_500, currency: "USD" },
merchantReference: "order_42",
paymentMethod: { type: "scheme", brand: "VISA", lastFour: "4242" },
};
const intent = mapPayment(raw, "manual", "2026-05-17T12:00:00.000Z");
intent.amount.amount; // -> 12500n
intent.amount.currency; // -> "USD"
intent.capturedAmount.amount; // -> 0n (capture is a separate /captures call)
intent.paymentMethod?.brand; // -> "visa"
intent.paymentMethod?.last4; // -> "4242"
intent.paymentMethod?.type; // -> "card"
```
## API Reference
### `mapStatus(resultCode: string): PaymentStatus`
Look up the Adyen result code in the static `RESULT_STATUS` table.
Returns `"failed"` for any code not in the table.
### `mapRefundStatus(status: string): RefundStatus`
Returns `"pending"` for `"received"`, `"failed"` for anything else.
### `mapPaymentMethod(pspReference: string, method?: AdyenPaymentMethod): PaymentMethod | undefined`
Returns `undefined` when `method` is `undefined`. Otherwise composes a
`PaymentMethod` with `id` `"adyen_<pspReference>"`, `type` `"card"`
when `method.type === "scheme"` and `"wallet"` otherwise, lower-cased
`brand`, and `last4` taken from `method.lastFour`.
### `mapPayment(payment: AdyenPayment, captureMethod: "automatic" | "manual", nowIso: string): PaymentIntent`
Composes a full `PaymentIntent`. `nowIso` is supplied by the caller so
the provider class controls the timestamp source; the helper does not
read the wall clock itself. `capturedAmount` is always `0n` in the same
currency — Adyen's `/payments` endpoint never reports a captured state,
so the mapped intent records the captured value as zero and leaves
capture reporting to `AdyenProvider.captureIntent`.
### Types
`AdyenAmount`, `AdyenPaymentMethod`, `AdyenPayment`, and
`AdyenModification` are minimal `readonly` excerpts of the upstream v71
shapes — only the fields the mappers consume are declared, so changes
to the wider Adyen response shape do not invalidate the types here.
## Implementation Notes
- The `RESULT_STATUS` table is frozen at module load time and exported
only through the wrapping `mapStatus` function so consumers cannot
mutate it.
- `BigInt(p.amount.value)` is safe because Adyen always returns
integer minor units — every Checkout response field is documented as
an integer; floating-point input would silently round here.
- The mappers are pure: same input → same output, no IO, no
closures over module state. The provider class injects `nowIso` and
`captureMethod` so callers can fix the timestamp in tests.
- `mapPaymentMethod` widens the lower-cased `brand` to
`PaymentMethod["brand"]` via a `as` cast. Adyen returns the brand
free-form, and `PaymentMethod["brand"]` is a discriminated union;
the cast is the boundary at which untyped wire data meets the typed
domain model. The provider tests cover the common brands (`visa`,
`applepay`) end-to-end.

View File

@@ -0,0 +1,115 @@
# `AdyenConfig`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Type alias exported by `@suaveplan/payment-provider-adyen` describing
the constructor argument accepted by `AdyenProvider`. It re-exports the
slim-core `MerchantKeyConfig` verbatim so consumers importing only this
sibling package do not need a second import from
`@suaveplan/payment-providers`, and so future Adyen-specific fields can
be added here without disturbing the foundation contract.
The type is structurally identical to `MerchantKeyConfig` — any value
typed as `MerchantKeyConfig` is assignable to `AdyenConfig` and vice
versa. This is deliberate: the slim-core shape already carries every
Adyen-specific seam (`apiKey`, `merchantAccount`, `webhookHmacKey`,
`randomId`), and forking the type would create a second source of
truth for the same contract.
## Features
- Re-exports `MerchantKeyConfig` under an Adyen-local name so callers
see the right vocabulary.
- Carries the injectable `randomId: RandomIdFn` seam used by
`AdyenProvider.createIntent` to generate the default `reference`
field when no `idempotencyKey` is supplied.
- Carries the standard `ProviderCommonOptions` seams (`fetch`,
`timeoutMs`, `signal`) so tests can swap in a recording `FetchLike`
and exercise abort propagation without monkey-patching globals.
- Carries `webhookHmacKey` for future webhook verification wiring
(currently consumed by `@suaveplan/payment`'s `WebhookVerifier`).
## Usage: Basic Example
```ts
import { AdyenProvider, type AdyenConfig } from "@suaveplan/payment-provider-adyen";
const config: AdyenConfig = {
apiKey: process.env.ADYEN_API_KEY ?? "",
merchantAccount: "AcmeMerchantECOM",
};
const adyen = new AdyenProvider(config);
```
## Usage: Advanced Example
```ts
import {
AdyenProvider,
type AdyenConfig,
} from "@suaveplan/payment-provider-adyen";
import type { FetchLike } from "@suaveplan/payment-providers";
// Deterministic reference generation + recording fetch for tests.
const calls: Array<{ url: string; init?: RequestInit }> = [];
const recordingFetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return new Response(
JSON.stringify({
pspReference: "PSP_TEST",
resultCode: "Authorised",
amount: { value: 5000, currency: "USD" },
}),
{ status: 200 }
);
};
let counter = 0;
const config: AdyenConfig = {
apiKey: "AQE_test_key",
merchantAccount: "AcmeMerchantECOM",
baseUrl: "https://checkout-test.adyen.com/v71",
fetch: recordingFetch,
randomId: () => `gen_ref_${++counter}`,
timeoutMs: 5_000,
};
const adyen = new AdyenProvider(config);
```
## API Reference
### `type AdyenConfig`
Alias for `MerchantKeyConfig` from `@suaveplan/payment-providers`.
Fields (all read-only):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `apiKey` | `string` | yes | — | Adyen Checkout API key, sent as `X-API-Key`. |
| `merchantAccount` | `string` | yes | — | Adyen merchant account code, injected into modification bodies. |
| `baseUrl` | `string` | no | `https://checkout-test.adyen.com/v71` | Endpoint override (use `https://<prefix>-checkout-live.adyenpayments.com/checkout/v71` for live). |
| `webhookHmacKey` | `string` | no | — | Reserved for webhook signature verification. |
| `randomId` | `() => string` | no | `globalThis.crypto?.randomUUID` when available | Default-`reference` generator. |
| `fetch` | `FetchLike` | no | `globalThis.fetch` | Injectable HTTP client. |
| `timeoutMs` | `number` | no | `30_000` | Per-call timeout in milliseconds. |
| `signal` | `AbortSignal` | no | — | External cancellation token. |
## Implementation Notes
- `AdyenConfig` is intentionally a `type` alias rather than an
`interface extends MerchantKeyConfig` — the latter would diverge the
two types under `tsc --strict` if `MerchantKeyConfig` ever gains an
optional field. Aliasing preserves structural equality forever.
- The `randomId` seam is the universal escape hatch for default
`reference` generation. Without it, the provider would have to call
`crypto.randomUUID()` at request time and bring the entire
`node:crypto` polyfill surface into bundlers targeting browsers and
Web Workers. With it, callers in those environments can pass a
deterministic id source (UUIDv7, NanoID, sequence counter) and the
package stays pure.

View File

@@ -0,0 +1,72 @@
# @suaveplan/payment-provider-mollie - Universal Mollie Payments API v2 adapter for @suaveplan/payment. Sibling provider package on the slim @suaveplan/payment-providers core: implements PaymentProvider with Mollie's string-amount marshalling ({ currency, value: "X.YY" }) round-trip via exported formatMollieAmount / parseMollieAmount helpers.
Universal Mollie Payments API v2 adapter for `@suaveplan/payment`.
Sibling provider package on the slim `@suaveplan/payment-providers`
core: implements `PaymentProvider` with Mollie's string-amount
marshalling (`{ currency, value: "X.YY" }`) round-trip via the
exported `formatMollieAmount` / `parseMollieAmount` helpers.
<!-- begin:auto-docs -->
## Documentation tree
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-mollie @suaveplan/payment-providers @suaveplan/payment
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
const registry = new ProviderRegistry();
registry.register(
new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" })
);
const mollie = registry.get("mollie");
const intent = await mollie.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Order #42",
returnUrl: "https://shop.example/return",
});
console.log(intent.id, intent.status);
```
## Features
- `MollieProvider` — full `PaymentProvider` lifecycle
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
`refund`, `getIntent`) on top of the slim `httpRequest` helper.
- Pure, exported amount helpers — `formatMollieAmount(amount: bigint)`
and `parseMollieAmount(value: string)` — that round-trip losslessly
via `bigint` and cover the 1-cent, exact-major, large-value, and
over-precision edges.
- Total exported status maps — `mapMollieStatus` and
`mapMollieRefundStatus` — each individually unit-testable without
invoking the network path.
- `Authorization: Bearer <apiKey>` header auto-applied on every call.
- Universal — no Node-specific or browser-specific globals; runs
on Node 18+, Bun, browsers, and Web Workers.
- All failures surface as `PaymentProviderError` from
`@suaveplan/payment-providers` carrying a `PAYMENT_PROVIDER_*` code.
## Modules
- [`src/mollie/mollie.md`](./src/mollie/mollie.md) —
`MollieProvider` class, helpers, and status maps.
## Sibling packages
Part of the `@suaveplan/payment-provider-*` family. See
[`@suaveplan/payment-providers`](../payment-providers/README.md) for the
shared foundation (`ProviderRegistry`, `httpRequest`,
`composeAbortSignal`, `PaymentProviderError`, the `PAYMENT_PROVIDER_*`
code registry, and the shared config shapes).

View File

@@ -0,0 +1,185 @@
# MollieProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MollieProvider` is the sibling adapter that implements the
`PaymentProvider` contract from `@suaveplan/payment` against the Mollie
Payments API v2. It is the only `@suaveplan/payment-provider-*` sibling
that has to marshal money amounts to and from a string representation:
every Mollie request and response carries amounts as
`{ currency: "EUR", value: "10.00" }`, while the rest of the suaveplan
payment family models money as `bigint` minor-units. This package
owns that round-trip end-to-end and exposes the underlying helpers
(`formatMollieAmount`, `parseMollieAmount`) so callers can test or reuse
them in their own integrations.
## Features
- `MollieProvider` class implementing `PaymentProvider` with name
`"mollie"`.
- Constructor takes `ApiKeyConfig` from `@suaveplan/payment-providers`
(`apiKey`, optional `baseUrl`, `timeoutMs`, `fetch`, `signal`,
`webhookSecret`).
- Auto-sets `Authorization: Bearer <apiKey>` on every call.
- Uses the slim-core `httpRequest` helper for JSON serialisation,
per-call timeout, external `AbortSignal` composition, and typed
error surfacing.
- Defaults `description` to `"Payment"` and `redirectUrl` to
`"https://localhost/return"` when omitted, matching upstream
behaviour.
- `confirmIntent` and `captureIntent` are read-throughs to `getIntent`
because Mollie auto-captures.
- Pure, exported amount helpers (`formatMollieAmount`,
`parseMollieAmount`) and status maps (`mapMollieStatus`,
`mapMollieRefundStatus`) — all individually unit-testable.
- All failures surface as `PaymentProviderError` carrying a
`PAYMENT_PROVIDER_*` code; raw `Error` is never thrown.
## Usage: Basic Example
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
const registry = new ProviderRegistry();
registry.register(new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" }));
const mollie = registry.get("mollie");
const intent = await mollie.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Order #42",
returnUrl: "https://shop.example/return",
});
console.log(intent.id, intent.status);
```
## Usage: Advanced Example
```ts
import { MollieProvider, formatMollieAmount, parseMollieAmount } from "@suaveplan/payment-provider-mollie";
import type { FetchLike } from "@suaveplan/payment-providers";
// Inject a recording FetchLike for tests — no global monkey-patching.
const recorded: { url: string; init?: RequestInit }[] = [];
const recordingFetch: FetchLike = async (url, init) => {
recorded.push({ url, init });
return new Response(JSON.stringify({
id: "tr_xyz",
status: "paid",
amount: { value: "12.34", currency: "EUR" },
createdAt: "2026-01-01T00:00:00+00:00",
}), { status: 200, headers: { "content-type": "application/json" } });
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
timeoutMs: 5_000,
fetch: recordingFetch,
});
// Round-trip amount marshalling without hitting the network:
const wire = formatMollieAmount(1234n); // "12.34"
const back = parseMollieAmount(wire); // 1234n
// Drive the full lifecycle for assertions:
const intent = await provider.createIntent({
amount: { amount: 1234n, currency: "EUR" },
});
const captured = await provider.captureIntent(intent.id); // read-through
const refund = await provider.refund({
paymentIntentId: intent.id,
amount: { amount: 1234n, currency: "EUR" },
});
```
## API Reference
### `MollieProvider`
- `new MollieProvider(config: ApiKeyConfig)` — constructs the adapter.
`config.apiKey` is required; `baseUrl`, `timeoutMs`, `fetch`,
`signal`, and `webhookSecret` are optional. `baseUrl` defaults to
`MOLLIE_DEFAULT_BASE_URL`; `timeoutMs` defaults to `30_000`.
- `name: "mollie"` — literal-typed instance field.
- `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>` —
POST `${baseUrl}/payments`. Defaults `description` and `redirectUrl`
when omitted.
- `confirmIntent(intentId: string): Promise<PaymentIntent>` —
read-through to `getIntent`.
- `captureIntent(intentId: string): Promise<PaymentIntent>` —
read-through to `getIntent` (Mollie auto-captures).
- `cancelIntent(intentId: string): Promise<PaymentIntent>` — DELETE
`${baseUrl}/payments/${intentId}`.
- `refund(input: RefundInput): Promise<Refund>` — POST
`${baseUrl}/payments/${intentId}/refunds`. Omits the body's `amount`
field when `input.amount` is undefined (Mollie issues a full refund).
- `getIntent(intentId: string): Promise<PaymentIntent>` — GET
`${baseUrl}/payments/${intentId}`.
- Throws: `PaymentProviderError` with code
`PAYMENT_PROVIDER.HTTP_ERROR` / `PAYMENT_PROVIDER.TIMEOUT` /
`PAYMENT_PROVIDER.PARSE_ERROR`.
### `MOLLIE_DEFAULT_BASE_URL: "https://api.mollie.com/v2"`
Production Mollie REST v2 base URL constant.
### `MOLLIE_PROVIDER_NAME: "mollie"`
Stable provider identifier.
### `formatMollieAmount(amount: bigint): string`
Format a minor-units `bigint` into a two-decimal string. Always
produces exactly two minor digits, pads left for sub-major values
(`1n` → `"0.01"`), and supports negative values.
### `parseMollieAmount(value: string): bigint`
Parse a Mollie wire-format decimal string into a minor-units `bigint`.
Accepts no-decimal, single-decimal, two-decimal, or over-precision
inputs; over-precision is truncated, never rounded.
### `mapMollieStatus(status: string): PaymentStatus`
Total map from Mollie's payment-status string to the canonical
`PaymentStatus` union. Unknown values fall back to `"failed"`.
### `mapMollieRefundStatus(status: string): RefundStatus`
Total map from Mollie's refund-status string to the canonical
`RefundStatus` union. Unknown values fall back to `"failed"`.
## Implementation Notes
- The package is universal — no Node-specific or browser-specific
globals. The only runtime concept beyond the language standard is
the WHATWG `fetch` surface, available in every supported runtime.
- Tests inject a recording `FetchLike` closure into the constructor
instead of mutating `globalThis.fetch`. This satisfies rule 8 (no
mocks) and matches the binance / ecb sibling pattern.
- `confirmIntent` and `captureIntent` are deliberate read-throughs to
`getIntent`: Mollie auto-captures, so there is no separate confirm or
capture step at the protocol level. Returning the current state
preserves the `PaymentProvider` contract without falsifying the
underlying behaviour.
- `formatMollieAmount` and `parseMollieAmount` are inverses for every
`bigint` produced from a Mollie response. Round-trip is exact for
zero, single cent, exact major unit, and large values up to
`2^53`-and-beyond range (`bigint` is arbitrary-precision; the helpers
never go through `number`).
- Over-precision in `parseMollieAmount` (`"10.123"` → `1012n`) is
truncation, not rounding. Mollie emits exactly minor-unit precision;
over-precision is upstream garbage and the safest action is to
ignore the excess rather than to bias the result.
- The card-brand normalisation accepts any of `visa` / `mastercard` /
`amex` / `discover` / `diners` / `jcb` / `unionpay`; every other
label collapses to `"unknown"` rather than leaking arbitrary strings
into the `CardBrand` union.
- Forwarded `AbortSignal` composition (caller `signal` + per-call
timeout) is handled by the slim-core `httpRequest`; this package
does not re-implement it.

View File

@@ -0,0 +1,107 @@
# @suaveplan/payment-provider-paypal - PayPal payment provider for @suaveplan/payment-providers. Implements the PaymentProvider interface against the PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2 client-credentials token cache, injectable now/base64Encode/FetchLike seams, sandbox-endpoint shortcut, MoneyLike bigint marshalling, and structured PaymentProviderError surfaces. Universal — runs unchanged on Node, Bun, browsers, and Web Workers.
PayPal sibling adapter for the slim
[`@suaveplan/payment-providers`](../payment-providers/README.md) foundation.
Ships `PayPalProvider` — a `PaymentProvider` implementation against the
PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2
client-credentials token cache, injectable `now` / `base64Encode` /
`FetchLike` seams, a `sandbox` shortcut, `MoneyLike` bigint marshalling,
and structured `PaymentProviderError` surfaces. Universal — runs
unchanged on Node, Bun, browsers, and Web Workers.
<!-- begin:auto-docs -->
## Documentation tree
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
- [mapping](./src/paypal/mapping.md) — Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders v2 + Payments Captures Refund wire shapes i…
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-paypal @suaveplan/payment-providers @suaveplan/payment
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const registry = new ProviderRegistry();
registry.register(
new PayPalProvider({
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
sandbox: true,
})
);
const paypal = registry.get("paypal");
const intent = await paypal.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Order #1234",
captureMethod: "automatic",
idempotencyKey: "order-1234",
});
console.log(intent.id, intent.status);
```
## Features
- `class PayPalProvider implements PaymentProvider` with literal-typed
`name: "paypal"`
- OAuth2 client-credentials token cache with 60 s clock-skew buffer,
refreshed transparently on expiry
- Injectable `now: NowFn`, `base64Encode: Base64EncodeFn`, and
`fetch: FetchLike` for universal determinism and testability
- `sandbox` flag → `https://api-m.sandbox.paypal.com`; explicit `baseUrl`
always wins
- `MoneyLike` bigint minor-unit amounts marshalled to PayPal's major-unit
decimal-string format
- `PayPal-Request-Id` idempotency-header support on `createIntent` and
`refund`
- Exhaustive PayPal-status → `PaymentStatus` / `RefundStatus`
mapping; unknown statuses map to `failed`
- Structured `PaymentProviderError` (from `@suaveplan/payment-providers`)
on every failure path — `OAUTH_TOKEN_FAILURE`, `HTTP_ERROR`, `TIMEOUT`,
`PARSE_ERROR` — always with `provider: "paypal"`
- Universal: zero Node / browser / React imports; `sideEffects: false`
- 100% Istanbul coverage; sibling `.md` per implementation file
## Modules
- [`src/paypal/paypal.md`](./src/paypal/paypal.md) — `PayPalProvider`
class, `PayPalConfig` interface, lifecycle methods, OAuth token cache
- [`src/paypal/mapping.md`](./src/paypal/mapping.md) — pure helpers:
`mapStatus`, `mapRefundStatus`, `parseAmount`, `formatAmount`,
`mapOrder`
## Foundation
This package depends on
[`@suaveplan/payment-providers`](../payment-providers/README.md) for:
- `httpRequest`, `composeAbortSignal`, `FetchLike`
- `OAuthConfig`, `NowFn`, `Base64EncodeFn`
- `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `oauthTokenFailure`
and on [`@suaveplan/payment`](../payment/README.md) for the structural
`PaymentProvider`, `PaymentIntent`, `Refund`, `PaymentStatus`,
`RefundStatus`, `CreatePaymentIntentInput`, and `RefundInput` types.
## Related sibling packages
- `@suaveplan/payment-provider-stripe`
- `@suaveplan/payment-provider-square`
- `@suaveplan/payment-provider-mollie`
- `@suaveplan/payment-provider-adyen`
All four follow the same pattern: depend on the slim core, expose a
single `*Provider` class, register cleanly into `ProviderRegistry`.
## License
MIT

View File

@@ -0,0 +1,146 @@
# PayPal mapping helpers
## Purpose
Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders
v2 + Payments Captures Refund wire shapes into the suaveplan-canonical
`PaymentIntent` and `RefundStatus` types. Splitting them out of the
provider class keeps the lifecycle methods focused on transport and lets
the mapping rules be reviewed and tested in isolation. Every function is
synchronous, side-effect-free, and total over the documented PayPal
inputs (unknown statuses fall through to `failed`, missing decimals fall
back to `00`).
## Features
- `mapStatus(ppStatus)` — exhaustive PayPal-order-status → suaveplan
`PaymentStatus` mapping; unknown statuses (including the empty string)
map to `failed`
- `mapRefundStatus(status)` — PayPal-refund-status → suaveplan
`RefundStatus` (`COMPLETED` → `succeeded`, `PENDING` → `pending`,
everything else → `failed`)
- `parseAmount(value)` — parses a PayPal major-unit decimal string into
a `bigint` count of minor units (`"50.00"` → `5000n`, `"500"` → `50000n`)
- `formatAmount(minorUnits)` — formats a non-negative `bigint` minor-unit
amount into a PayPal major-unit decimal string with two fractional
digits (`5000n` → `"50.00"`)
- `mapOrder(order)` — full PayPal Orders v2 → `PaymentIntent` projection,
summing every nested `payments.captures[].amount.value` into
`capturedAmount`
## Usage: Basic
```ts
import {
formatAmount,
mapStatus,
parseAmount,
} from "@suaveplan/payment-provider-paypal/paypal/paypal";
// (Re-exported via the package root.)
const status = mapStatus("COMPLETED"); // "captured"
const minor = parseAmount("12.34"); // 1234n
const wire = formatAmount(5000n); // "50.00"
```
## Usage: Advanced
`mapOrder` is the round-trip function — it consumes a raw PayPal Orders v2
JSON shape and returns the `PaymentIntent` the rest of the
codebase consumes:
```ts
import type { PayPalOrder } from "@suaveplan/payment-provider-paypal";
import { mapOrder } from "@suaveplan/payment-provider-paypal/paypal/paypal";
const raw: PayPalOrder = {
id: "ORDER_42",
status: "COMPLETED",
intent: "AUTHORIZE",
purchase_units: [
{
amount: { currency_code: "EUR", value: "99.99" },
payments: {
captures: [
{
id: "CAP1",
amount: { value: "50.00", currency_code: "EUR" },
status: "COMPLETED",
},
{
id: "CAP2",
amount: { value: "49.99", currency_code: "EUR" },
status: "COMPLETED",
},
],
},
},
],
create_time: "2026-05-17T00:00:00Z",
update_time: "2026-05-17T00:00:01Z",
};
const intent = mapOrder(raw);
intent.status; // "captured"
intent.amount; // { amount: 9999n, currency: "EUR" }
intent.capturedAmount; // { amount: 9999n, currency: "EUR" }
intent.captureMethod; // "manual" (because intent === "AUTHORIZE")
```
## API Reference
### `mapStatus(ppStatus: string): PaymentStatus`
| PayPal status | Returns |
|---|---|
| `CREATED` / `SAVED` | `"created"` |
| `APPROVED` | `"authorized"` |
| `VOIDED` | `"canceled"` |
| `COMPLETED` | `"captured"` |
| `PAYER_ACTION_REQUIRED` | `"pending"` |
| anything else | `"failed"` |
### `mapRefundStatus(status: string): RefundStatus`
| PayPal refund status | Returns |
|---|---|
| `COMPLETED` | `"succeeded"` |
| `PENDING` | `"pending"` |
| anything else | `"failed"` |
### `parseAmount(value: string): bigint`
Returns the minor-unit count. Splits on the first `.`, pads the
fractional half to exactly two digits (zero-decimal currencies like JPY
have no `.` separator and pad to `"00"`), then composes
`BigInt(major) * 100n + BigInt(minorPadded)`.
### `formatAmount(minorUnits: bigint): string`
Returns `"<major>.<minor>"` where `minor` is zero-padded to two digits.
`MoneyLike.amount` is non-negative by contract, so no sign
handling is performed.
### `mapOrder(order: PayPalOrder): PaymentIntent`
Returns the full `PaymentIntent` derived from a PayPal Orders v2
response. The first purchase unit drives `amount` and currency; all
captures across that unit sum into `capturedAmount`. `refundedAmount`
is always reported as `0n` here — the provider's `refund` method
returns standalone `Refund` records rather than nested refund totals.
## Implementation Notes
- All functions are synchronous and have no I/O, no logging, and no
observable side effects. They are safe to call from any tier.
- `mapOrder` deliberately omits PayPal's nested `payments.refunds[]`
entries from the `PaymentIntent.refunds` list — that surface is
reserved for future expansion when `@suaveplan/payment` adds a
refund-list field to `PaymentIntent`.
- The default currency `"USD"` is only reached when PayPal returns an
order with no purchase units. That happens for void responses and for
malformed test fixtures; production payment-intent responses always
carry a purchase unit.
- `formatAmount` and `parseAmount` are inverses for any non-negative
minor-unit `bigint` ≤ `2^53` (PayPal max). Property-style round-trip
tests live in `paypal.test.ts`.

View File

@@ -0,0 +1,203 @@
# PayPalProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PayPalProvider` is the PayPal-flavoured implementation of the
`PaymentProvider` contract exported by `@suaveplan/payment`. It maps every
suaveplan payment-lifecycle method onto the PayPal Orders v2 and Payments
Captures Refund APIs, takes care of the OAuth2 client-credentials handshake
once per token lifetime, and surfaces every failure as a typed
`PaymentProviderError` raised by the slim core. The package exists so that
applications can register a PayPal adapter into a `ProviderRegistry`
without writing the token-cache, idempotency-header, status-mapping, or
decimal-string-marshalling boilerplate the upstream API requires.
The provider is deliberately universal: it has no `node:*` /
`bun:*` / `react` / DOM imports, and every runtime dependency (the
HTTP client, the OAuth Basic header's base-64 encoder, and the clock the
token cache uses) is injected through the constructor config. That means
the same class runs unchanged on Node, Bun, modern browsers, and Web
Workers — and that tests can replace the HTTP transport, the clock, and
the base-64 encoder with closures, so the package needs no global
mocking and no platform-specific polyfills.
## Features
- Implements the full `PaymentProvider` surface
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
`refund`, `getIntent`) with a literal-typed `name: "paypal"` discriminant
- OAuth2 client-credentials token cache with 60 s clock-skew buffer;
reused across every method call until expiry, refreshed on demand
- Injectable `now: NowFn` for the token-cache TTL (tests can pin expiry
without freezing global `Date`)
- Injectable `base64Encode: Base64EncodeFn` for the `Authorization: Basic`
header (defaults to `globalThis.btoa`; runtimes without `btoa` supply
their own)
- Injectable `fetch: FetchLike` for every HTTP call (tests pass a recorder
closure; production passes a custom client when needed)
- `sandbox` flag toggles the default `https://api-m.sandbox.paypal.com`
endpoint; an explicit `baseUrl` always wins
- `MoneyLike` `bigint` minor-unit amounts marshalled to PayPal's
major-unit decimal-string format (`5000n` ↔ `"50.00"`)
- Idempotency keys surface as PayPal's `PayPal-Request-Id` header on
`createIntent` and `refund`
- Every failure surfaces as `PaymentProviderError` with `provider: "paypal"`,
the URL on the `url` discriminant, and the underlying cause attached
## Usage: Basic
```ts
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const paypal = new PayPalProvider({
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
});
const intent = await paypal.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Order #1234",
captureMethod: "automatic",
idempotencyKey: "order-1234",
});
console.log(intent.id, intent.status); // "ORDER_xxx" "created"
```
## Usage: Advanced
Combine the sandbox flag, an injected `FetchLike`, an injected `NowFn`,
and a `ProviderRegistry` from the slim core:
```ts
import {
type FetchLike,
ProviderRegistry,
} from "@suaveplan/payment-providers";
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const calls: string[] = [];
const recorder: FetchLike = async (url, init) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
// Forward to the real PayPal sandbox via a custom client, add
// logging / retry / circuit-breaker around it, etc.
return fetch(url, init);
};
let clock = Date.parse("2026-05-17T00:00:00Z");
const paypal = new PayPalProvider({
clientId: process.env.PAYPAL_SANDBOX_CLIENT_ID ?? "",
clientSecret: process.env.PAYPAL_SANDBOX_CLIENT_SECRET ?? "",
sandbox: true,
fetch: recorder,
now: () => clock,
timeoutMs: 10_000,
});
const registry = new ProviderRegistry();
registry.register(paypal);
const intent = await registry
.get("paypal")
.createIntent({
amount: { amount: 999n, currency: "EUR" },
captureMethod: "manual",
});
// Advance the clock past the token expiry — the next call refreshes.
clock += 4_000_000;
const confirmed = await paypal.confirmIntent(intent.id);
console.log(calls);
// → ["POST https://api-m.sandbox.paypal.com/v1/oauth2/token",
// "POST https://api-m.sandbox.paypal.com/v2/checkout/orders",
// "POST https://api-m.sandbox.paypal.com/v1/oauth2/token",
// "POST https://api-m.sandbox.paypal.com/v2/checkout/orders/.../authorize"]
console.log(confirmed.status); // "authorized"
```
## API Reference
### `class PayPalProvider implements PaymentProvider`
Constructor: `new PayPalProvider(config: PayPalConfig)`.
| Field of `PayPalConfig` | Type | Default | Notes |
|---|---|---|---|
| `clientId` | `string` | — | PayPal REST app client ID |
| `clientSecret` | `string` | — | PayPal REST app client secret |
| `baseUrl` | `string?` | derived | Wins over `sandbox` when set |
| `sandbox` | `boolean?` | `false` | Selects `https://api-m.sandbox.paypal.com` when `true` |
| `timeoutMs` | `number?` | `30_000` | Per-HTTP-call timeout |
| `fetch` | `FetchLike?` | `globalThis.fetch` | Injectable HTTP client |
| `signal` | `AbortSignal?` | — | External signal composed with the per-call timeout |
| `now` | `NowFn?` | `Date.now` | Clock for the token cache + `cancelIntent` timestamps |
| `base64Encode` | `Base64EncodeFn?` | `globalThis.btoa` | OAuth Basic-header encoder |
| `webhookSecret` | `string?` | — | Carried through for symmetry; not yet wired to a verifier |
Public methods:
- `name: "paypal"` — literal-typed discriminant
- `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`
- `confirmIntent(intentId: string): Promise<PaymentIntent>`
- `captureIntent(intentId: string): Promise<PaymentIntent>`
- `cancelIntent(intentId: string): Promise<PaymentIntent>` — returns a
synthesised intent because PayPal's void endpoint returns no body
- `refund(input: RefundInput): Promise<Refund>`
- `getIntent(intentId: string): Promise<PaymentIntent>`
Errors: every method may throw `PaymentProviderError` with one of
`PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE`, `…HTTP_ERROR`, `…TIMEOUT`, or
`…PARSE_ERROR`. The `provider` discriminant is always `"paypal"`.
### `interface PayPalConfig extends OAuthConfig`
See the table above. Re-exported from `@suaveplan/payment-provider-paypal`
and from the `./paypal/paypal` subpath.
### Status mapping
| PayPal status | `PaymentStatus` |
|---|---|
| `CREATED` / `SAVED` | `created` |
| `APPROVED` | `authorized` |
| `VOIDED` | `canceled` |
| `COMPLETED` | `captured` |
| `PAYER_ACTION_REQUIRED` | `pending` |
| anything else | `failed` |
| PayPal refund status | `RefundStatus` |
|---|---|
| `COMPLETED` | `succeeded` |
| `PENDING` | `pending` |
| anything else | `failed` |
## Implementation Notes
- The OAuth token is cached in-memory on the provider instance. Two
instances do not share a cache; build a singleton at the application
composition root if you want a process-wide cache.
- The token TTL is `tokenAcquiredAt + (expires_in * 1000) - 60_000` to
guard against PayPal-side clock skew. Tests pin the expiry by injecting
`now` and asserting the recorded call count after the boundary.
- `cancelIntent` returns a synthesised `PaymentIntent` because PayPal's
void endpoint returns no body. The timestamps come from
`new Date(now()).toISOString()` so tests can pin them by injecting a
deterministic `now`.
- Amount marshalling assumes the `MoneyLike` `amount` is denominated in
the smallest indivisible unit of `currency` (e.g. cents for USD). Zero-
decimal currencies (JPY) are not yet auto-detected — pass the major-unit
value as the bigint when using them and trim the `.00` suffix in your
consumer if needed; future work tracked in the parity checklist.
- The `Refund` returned from `refund` carries the caller's
`paymentIntentId` verbatim, not the PayPal `capture_id`, because the
`Refund.paymentIntentId` field semantically points to the
capture / intent the caller addressed.
- Adding new failure paths: surface them through the slim core's
factories (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`)
so the `provider` discriminant stays consistent. Never raise a bare
`Error` from this package.

View File

@@ -0,0 +1,104 @@
# @suaveplan/payment-provider-square - Universal Square Payments API adapter for @suaveplan/payment. Ships SquareProvider (implements PaymentProvider with literal name "square"), SquareConfig (extends ApiKeyConfig with locationId, sandbox, apiVersion, randomId), and the Square-specific payment/refund mappers. Built on the @suaveplan/payment-providers foundation (httpRequest, composeAbortSignal, PAYMENT_PROVIDER_CODES, PaymentProviderError).
Universal Square Payments API adapter for `@suaveplan/payment`.
Ships the `SquareProvider` class (implements the structural
`PaymentProvider` contract with literal `name: "square"`), the
`SquareConfig` interface (extends `ApiKeyConfig` from
`@suaveplan/payment-providers` with the Square-specific `locationId`,
`sandbox`, `apiVersion`, and `randomId` fields), and the
Square-specific payment / refund mappers (`mapPayment`, `mapRefund`,
`mapPaymentStatus`, `mapRefundStatus`).
Built on the `@suaveplan/payment-providers` foundation — every
outbound HTTP call goes through `httpRequest`, which handles JSON
serialisation, the per-call timeout signal, external `AbortSignal`
composition, and typed `PaymentProviderError` raising.
<!-- begin:auto-docs -->
## Documentation tree
- [payment](./src/mapping/payment.md) — Translates between Square's `Payment` envelope (as returned by `POST /v2/payments`, `POST /v2/payments/{id}/complete`,…
- [refund](./src/mapping/refund.md) — Translates Square's `Refund` envelope (as returned by `POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. T…
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
- [types](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
- [config](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-square @suaveplan/payment-providers @suaveplan/payment
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { SquareProvider } from "@suaveplan/payment-provider-square";
import { PaymentEngine } from "@suaveplan/payment";
const registry = new ProviderRegistry();
registry.register(
new SquareProvider({
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
locationId: process.env.SQUARE_LOCATION_ID ?? "",
})
);
const engine = new PaymentEngine({ provider: registry.get("square") });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
});
```
## Features
- **`SquareProvider`** — full Square Payments API lifecycle
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
`refund`, `getIntent`) with literal `name: "square"`.
- **`SquareConfig`** — extends `ApiKeyConfig`; adds required
`locationId` plus optional `sandbox`, `apiVersion`, `randomId`.
- **Sandbox toggle** — `sandbox: true` flips the default base URL to
`https://connect.squareupsandbox.com/v2`. Explicit `baseUrl`
always wins.
- **Pinned `Square-Version`** — defaults to `2024-01-18`; override via
`apiVersion` to opt into a newer API surface.
- **Injectable `randomId`** — fills missing `idempotency_key` fields
on `CreatePayment` + `RefundPayment` calls. Tests pass a
deterministic counter; production defaults to `crypto.randomUUID()`.
- **Universal** — no `node:`, `bun:`, `fs`, `path`, `crypto`
imports; runs on Node, Bun, browsers, and Web Workers.
- **`PaymentProviderError`-only** — every failure surfaces as a
typed `PaymentProviderError` via the foundation's `httpRequest`.
- **100% test coverage** (Istanbul) + sibling `.md` per
implementation file + 5 subpath exports.
## Modules
- [`src/square/square.md`](./src/square/square.md) — `SquareProvider`
class and lifecycle methods.
- [`src/types/config.md`](./src/types/config.md) — `SquareConfig`,
`SQUARE_DEFAULT_BASE_URL`, `SQUARE_SANDBOX_BASE_URL`,
`SQUARE_DEFAULT_API_VERSION`.
- [`src/mapping/payment.md`](./src/mapping/payment.md) — `mapPayment`,
`mapPaymentStatus`, `SquarePaymentDto`, `SquareMoney`, `SquareCard`.
- [`src/mapping/refund.md`](./src/mapping/refund.md) — `mapRefund`,
`mapRefundStatus`, `SquareRefundDto`.
## Related
- `@suaveplan/payment-providers` — `ProviderRegistry`,
`definePaymentProvider`, `httpRequest`, `composeAbortSignal`,
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`,
`ApiKeyConfig`/`OAuthConfig`/`MerchantKeyConfig`,
`FetchLike`/`RandomIdFn`/`NowFn`/`Base64EncodeFn`.
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
`PaymentProvider` interface, the canonical `PaymentIntent` /
`Refund` / `PaymentStatus` / `RefundStatus` shapes.
## License
MIT

View File

@@ -0,0 +1,126 @@
# Square payment mapping
## Purpose
Translates between Square's `Payment` envelope (as returned by
`POST /v2/payments`, `POST /v2/payments/{id}/complete`,
`POST /v2/payments/{id}/cancel`, and `GET /v2/payments/{id}`) and
`@suaveplan/payment`'s `PaymentIntent` shape. Square's status values
are upper-snake-case (`COMPLETED`, `APPROVED`, ...) while uses
the lower-case `PaymentStatus` union; the mapping pins this contract
in one place so the provider class stays focused on transport.
The mapper also captures the two implicit branches Square encodes via
optional fields rather than dedicated states: `approved_money`
present ↔ manual capture, `card_details` present ↔ card-shaped
`PaymentMethod`.
## Features
- `mapPaymentStatus(squareStatus)` — exhaustive five-status map with a
defensive `"failed"` fallback for forward-rolled API versions.
- `mapPayment(p)` — full envelope-to-`PaymentIntent` shape including
`capturedAmount` derivation (`p.total_money` on `COMPLETED`, `0n`
otherwise), `captureMethod` derivation (`approved_money` ↔
`"manual"`), and optional `PaymentMethod` derivation from
`card_details`.
- `SquareCard` / `SquareMoney` / `SquarePaymentDto` — typed views of
the Square API envelope that downstream code can rely on instead of
threading `unknown` through call sites.
- Card-brand normalisation — Square returns brands upper-case; the
mapper lower-cases and validates against the `CardBrand`
union, falling back to `"unknown"` for non-canonical brands
(Square gift cards, JCB sub-brands, etc.).
## Usage: Basic Example
```ts
import {
mapPayment,
mapPaymentStatus,
} from "@suaveplan/payment-provider-square";
mapPaymentStatus("COMPLETED"); // "captured"
const intent = mapPayment({
id: "sq_pay_123",
status: "COMPLETED",
amount_money: { amount: 5000, currency: "USD" },
total_money: { amount: 5000, currency: "USD" },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
});
console.log(intent.status); // "captured"
console.log(intent.capturedAmount.amount); // 5000n
```
## Usage: Advanced Example
Map an authorized-but-not-captured payment with card metadata:
```ts
import { mapPayment } from "@suaveplan/payment-provider-square";
const intent = mapPayment({
id: "sq_pay_456",
status: "APPROVED",
amount_money: { amount: 9999, currency: "EUR" },
total_money: { amount: 9999, currency: "EUR" },
approved_money: { amount: 9999, currency: "EUR" },
customer_id: "cust_42",
note: "Pre-authorisation for hold",
card_details: { card: { card_brand: "MASTERCARD", last_4: "4242" } },
created_at: "2026-02-01T10:00:00Z",
updated_at: "2026-02-01T10:00:00Z",
});
console.log(intent.captureMethod); // "manual"
console.log(intent.status); // "authorized"
console.log(intent.capturedAmount.amount); // 0n
console.log(intent.paymentMethod);
// { id: "sq_card_sq_pay_456", type: "card", brand: "mastercard", last4: "4242" }
```
## API Reference
### `mapPaymentStatus(squareStatus: string): PaymentStatus`
Maps Square status → suaveplan status. Known mappings:
| Square | suaveplan |
|---|---|
| `APPROVED` | `"authorized"` |
| `COMPLETED` | `"captured"` |
| `CANCELED` | `"canceled"` |
| `FAILED` | `"failed"` |
| `PENDING` | `"pending"` |
| _other_ | `"failed"` |
### `mapPayment(p: SquarePaymentDto): PaymentIntent`
Shape the envelope. `capturedAmount.amount` is
`BigInt(p.total_money.amount)` when `p.status === "COMPLETED"`,
otherwise `0n`. `captureMethod` is `"manual"` when `p.approved_money`
is set. `paymentMethod` is set when `p.card_details` is present.
`refundedAmount.amount` is `0n` (refund totals are tracked by
`PaymentEngine`, not derived here). `refunds` is an empty array.
### Types
- `SquareMoney` — `{ amount: number; currency: string }`.
- `SquareCard` — `{ card_brand: string; last_4: string }`.
- `SquarePaymentDto` — the subset of Square's `Payment` envelope this
package consumes.
## Implementation Notes
- The status map is frozen at module init so accidental mutation
throws in strict mode.
- The card-brand normaliser is intentionally conservative: any brand
Square introduces that does not match the `CardBrand` union
falls back to `"unknown"` so downstream code never receives an
invalid discriminant.
- `paymentMethod.id` is synthesised as `sq_card_<payment-id>` because
Square does not expose a stable card-fingerprint on the inline
`card_details.card` object. Consumers needing a true stable card id
should hydrate it from Square's Cards API and overlay it.

View File

@@ -0,0 +1,120 @@
# Square refund mapping
## Purpose
Translates Square's `Refund` envelope (as returned by
`POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. The
mapping is small but load-bearing: Square encodes refund lifecycle
states as upper-snake-case strings while the `RefundStatus`
union is lower-case, and Square's `reason` is a free-form string
where the `RefundReason` union is a closed discriminator.
The reason mapping is intentionally narrow — Square never reports
machine-readable reason codes, so any non-empty reason collapses to
`"requested_by_customer"` and an absent / empty reason collapses to
`"other"`. Consumers that need richer reason granularity must layer
their own mapping on top.
## Features
- `mapRefundStatus(squareStatus)` — exhaustive three-state map
(`COMPLETED`, `PENDING`, everything-else) with `"failed"` covering
Square's `REJECTED` and `FAILED` plus any forward-rolled values.
- `mapRefund(r)` — full envelope-to-`Refund` shape including bigint
amount coercion and reason defaulting.
- `SquareRefundDto` — typed view of the Square refund envelope.
## Usage: Basic Example
```ts
import {
mapRefund,
mapRefundStatus,
} from "@suaveplan/payment-provider-square";
mapRefundStatus("COMPLETED"); // "succeeded"
const refund = mapRefund({
id: "sq_ref_123",
payment_id: "sq_pay_123",
amount_money: { amount: 2000, currency: "USD" },
status: "COMPLETED",
created_at: "2026-01-02T00:00:00Z",
reason: "Customer changed mind",
});
console.log(refund.status); // "succeeded"
console.log(refund.reason); // "requested_by_customer"
console.log(refund.amount.amount); // 2000n
```
## Usage: Advanced Example
Show all three branches in turn — including the reason fallback and
the `REJECTED → "failed"` collapse:
```ts
import { mapRefund } from "@suaveplan/payment-provider-square";
const base = {
id: "sq_ref_a",
payment_id: "sq_pay_a",
amount_money: { amount: 500, currency: "GBP" },
created_at: "2026-03-01T00:00:00Z",
};
const pending = mapRefund({ ...base, status: "PENDING" });
console.log(pending.status); // "pending"
console.log(pending.reason); // "other" (no reason supplied)
const rejected = mapRefund({ ...base, status: "REJECTED", reason: "" });
console.log(rejected.status); // "failed"
console.log(rejected.reason); // "other" (empty string)
const succeeded = mapRefund({
...base,
status: "COMPLETED",
reason: "duplicate charge",
});
console.log(succeeded.status); // "succeeded"
console.log(succeeded.reason); // "requested_by_customer"
```
## API Reference
### `mapRefundStatus(squareRefundStatus: string): RefundStatus`
| Square | suaveplan |
|---|---|
| `COMPLETED` | `"succeeded"` |
| `PENDING` | `"pending"` |
| _other_ (`REJECTED`, `FAILED`, ...) | `"failed"` |
### `mapRefund(r: SquareRefundDto): Refund`
Produces a `Refund` whose `amount` mirrors `r.amount_money` (with
the amount widened to `bigint`), whose `status` comes from
`mapRefundStatus`, whose `reason` is `"requested_by_customer"` when
`r.reason` is a non-empty string and `"other"` otherwise, and whose
`createdAt` is `r.created_at` verbatim. `paymentIntentId` is set to
`r.payment_id`.
### Types
- `SquareRefundDto` — the subset of Square's `Refund` envelope this
package consumes.
## Implementation Notes
- The reason fallback to `"other"` is deliberate: Square's free-form
`reason` carries operational notes ("customer called", "fraud
flagged by ops"), not the discriminated reasons suaveplan tracks.
Treating any non-empty value as `"requested_by_customer"` matches
the upstream package's behaviour verbatim.
- `mapRefundStatus` is exported separately so callers consuming raw
Square webhooks (where the refund envelope arrives outside an HTTP
response) can reuse the same mapping logic without round-tripping
through `mapRefund`.
- `r.amount_money.amount` is coerced via `BigInt()`. Square uses
smallest-unit integers, so the coercion is exact for every
documented currency.

View File

@@ -0,0 +1,175 @@
# SquareProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SquareProvider` is the Square Payments API adapter for
`@suaveplan/payment`. It implements the structural `PaymentProvider`
contract with `name: "square"` (narrowed at the type level) and
routes every outbound HTTP call through the
`@suaveplan/payment-providers` foundation's `httpRequest`, which
handles JSON serialisation, the per-call timeout signal, external
`AbortSignal` composition, and typed `PaymentProviderError` raising.
The provider preserves the upstream `square.ts` surface verbatim — the
six lifecycle methods (`createIntent`, `confirmIntent`, `captureIntent`,
`cancelIntent`, `refund`, `getIntent`), the canonical request bodies,
and the status mapping — while wiring the previously-hard-coded
`globalThis.crypto.randomUUID()` call onto the foundation's injectable
`RandomIdFn` seam. Tests pass a deterministic counter instead of
monkey-patching `globalThis.fetch`.
## Features
- Implements `PaymentProvider` with literal `name: "square"`.
- Sends `Authorization: Bearer <apiKey>` and `Square-Version` on every
request (`Square-Version` defaults to `2024-01-18`; override via
`SquareConfig.apiVersion`).
- Sandbox toggle (`sandbox: true`) flips the default base URL to
`https://connect.squareupsandbox.com/v2`; an explicit `baseUrl`
always wins.
- Idempotency keys filled from the injected `randomId` seam when the
caller omits one — deterministic in tests, `crypto.randomUUID()` by
default in production.
- Multi-location merchants supported via the required
`SquareConfig.locationId` field, sent as `location_id` on every
`CreatePayment` body.
- All errors raised as `PaymentProviderError` (foundation contract);
the package itself never throws `Error` instances directly.
- Universal — no `node:`, `bun:`, `fs`, `path`, `crypto` imports.
## Usage: Basic Example
```ts
import { SquareProvider } from "@suaveplan/payment-provider-square";
const square = new SquareProvider({
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
locationId: process.env.SQUARE_LOCATION_ID ?? "",
});
const intent = await square.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Order #123",
});
const captured = await square.captureIntent(intent.id);
console.log(captured.status); // "captured"
```
## Usage: Advanced Example
Inject deterministic seams (FetchLike + RandomIdFn) for testing,
target the sandbox, pin the API version, and compose with an external
`AbortSignal`:
```ts
import type { FetchLike } from "@suaveplan/payment-providers";
import { SquareProvider } from "@suaveplan/payment-provider-square";
const controller = new AbortController();
const fakeFetch: FetchLike = async (url, init) => {
return new Response(
JSON.stringify({
payment: {
id: "sq_pay_1",
status: "COMPLETED",
amount_money: { amount: 5000, currency: "USD" },
total_money: { amount: 5000, currency: "USD" },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
}),
{ status: 200 }
);
};
const square = new SquareProvider({
apiKey: "sandbox-token",
locationId: "L1",
sandbox: true,
apiVersion: "2024-09-19",
fetch: fakeFetch,
signal: controller.signal,
randomId: () => "deterministic-idempotency-key",
timeoutMs: 5_000,
});
const intent = await square.createIntent({
amount: { amount: 5000n, currency: "USD" },
});
```
## API Reference
### `class SquareProvider implements PaymentProvider`
#### `constructor(config: SquareConfig)`
Accepts a `SquareConfig` (see `../types/config.md`). Required:
`apiKey`, `locationId`. Optional: `baseUrl`, `sandbox`, `apiVersion`,
`randomId`, `fetch`, `signal`, `timeoutMs`, `webhookSecret`.
#### `readonly name: "square"`
Literal — narrows at the type level for discriminated unions.
#### `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`
POSTs `<baseUrl>/payments` with `amount_money`, `idempotency_key`
(falls back to `randomId()`), `autocomplete` (`true` unless
`captureMethod === "manual"`), `location_id`, and the optional
`customer_id`, `note`, `source_id` fields. Returns the mapped
`PaymentIntent`.
#### `confirmIntent(intentId: string): Promise<PaymentIntent>`
No-op for Square (payments are auto-confirmed). Delegates to
`getIntent(intentId)`.
#### `captureIntent(intentId: string): Promise<PaymentIntent>`
POSTs `<baseUrl>/payments/<intentId>/complete` with no body.
#### `cancelIntent(intentId: string): Promise<PaymentIntent>`
POSTs `<baseUrl>/payments/<intentId>/cancel` with no body.
#### `refund(input: RefundInput): Promise<Refund>`
POSTs `<baseUrl>/refunds` with `payment_id`, `idempotency_key`
(falls back to `randomId()`), optional `amount_money`, and optional
`reason`. Returns the mapped `Refund`.
#### `getIntent(intentId: string): Promise<PaymentIntent>`
GETs `<baseUrl>/payments/<intentId>`.
### Throws
Every method throws `PaymentProviderError` on HTTP failure, timeout,
or JSON parse failure (raised by the foundation's `httpRequest`).
`err.provider` is always `"square"`; `err.httpStatus` is set when the
failure originated from a non-ok response.
## Implementation Notes
- The provider stores its `FetchLike` / `AbortSignal` / `timeoutMs` /
`randomId` once at construction and passes them through to every
`httpRequest` call so behaviour is deterministic across method
invocations.
- `location_id` is sent on `CreatePayment` only — Square does not
require it on `CompletePayment` / `CancelPayment` / `Refund` calls.
- The default `randomId` is a closure that calls `crypto.randomUUID()`
at call time. Runtimes without a global `crypto` MUST inject their
own `RandomIdFn`.
- The class does not duplicate webhook verification logic; Square
webhook signatures are verified by `@suaveplan/payment`'s
`WebhookVerifier`, which is provider-agnostic.
- All formatting flows through the foundation: caller-supplied
`Content-Type` / `Accept` are preserved by `httpRequest`'s shallow
header merge, but the Square-specific `Authorization` and
`Square-Version` headers always win on collision.

View File

@@ -0,0 +1,102 @@
# SquareConfig and default constants
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the configuration shape `SquareProvider` accepts and the three
default constants the provider falls back to when the caller omits the
corresponding optional field. The interface extends the shared
`ApiKeyConfig` from `@suaveplan/payment-providers` so every common
injectable seam — `fetch`, `timeoutMs`, `signal`, `baseUrl`,
`webhookSecret` — is inherited rather than redeclared.
## Features
- `SquareConfig extends ApiKeyConfig` with `locationId: string`
(required), `sandbox?: boolean`, `apiVersion?: string`, and
`randomId?: RandomIdFn`.
- Three exported constants pin the production base URL, the sandbox
base URL, and the default `Square-Version` header value so they
show up exactly once across the package and are auditable.
- Type-only — the file ships no runtime objects beyond the constants;
the interface is erased at build time.
## Usage: Basic Example
```ts
import type { SquareConfig } from "@suaveplan/payment-provider-square";
const config: SquareConfig = {
apiKey: "sq0atp-...",
locationId: "L1234567890",
};
```
## Usage: Advanced Example
Pin every optional field — useful for the sandbox + deterministic
testing combination:
```ts
import type { SquareConfig } from "@suaveplan/payment-provider-square";
import {
SQUARE_DEFAULT_API_VERSION,
SQUARE_SANDBOX_BASE_URL,
} from "@suaveplan/payment-provider-square";
const config: SquareConfig = {
apiKey: "sq0atp-sandbox-...",
locationId: "L_SANDBOX",
sandbox: true,
apiVersion: SQUARE_DEFAULT_API_VERSION,
randomId: () => "deterministic-id",
timeoutMs: 5_000,
baseUrl: SQUARE_SANDBOX_BASE_URL, // optional — sandbox flag picks this up
};
```
## API Reference
### `interface SquareConfig extends ApiKeyConfig`
| Field | Type | Required | Notes |
|---|---|---|---|
| `apiKey` | `string` | yes | Inherited from `ApiKeyConfig`. |
| `locationId` | `string` | yes | Square `location_id` sent on every `CreatePayment` body. |
| `sandbox` | `boolean` | no | When `true`, flips the default base URL to `SQUARE_SANDBOX_BASE_URL`. |
| `apiVersion` | `string` | no | Value of the `Square-Version` header. Defaults to `SQUARE_DEFAULT_API_VERSION`. |
| `randomId` | `RandomIdFn` | no | Generator for missing idempotency keys. Defaults to `crypto.randomUUID()`. |
| `baseUrl` | `string` | no | Inherited from `ApiKeyConfig`. Wins over `sandbox`. |
| `webhookSecret` | `string` | no | Inherited from `ApiKeyConfig`. Not used by the provider itself. |
| `fetch` | `FetchLike` | no | Inherited from `ProviderCommonOptions`. |
| `timeoutMs` | `number` | no | Inherited from `ProviderCommonOptions`. |
| `signal` | `AbortSignal` | no | Inherited from `ProviderCommonOptions`. |
### `const SQUARE_DEFAULT_BASE_URL`
`"https://connect.squareup.com/v2"` — Square production base URL.
### `const SQUARE_SANDBOX_BASE_URL`
`"https://connect.squareupsandbox.com/v2"` — Square sandbox base URL.
### `const SQUARE_DEFAULT_API_VERSION`
`"2024-01-18"` — default `Square-Version` header value.
## Implementation Notes
- `locationId` is intentionally required (the upstream
`payment-providers` Square adapter omitted it; consumers had to
wrap the provider to inject it). Multi-location merchants are the
common case, and Square's `CreatePayment` endpoint rejects requests
that target the wrong location implicitly.
- The `sandbox` flag is a convenience — callers who need exotic base
URLs (Square Connect for the EU, an internal proxy, etc.) should
set `baseUrl` directly.
- `randomId` is shaped as a synchronous `() => string` rather than a
promise-returning function. Square's idempotency keys are opaque;
any unique-enough string works.

View File

@@ -0,0 +1,111 @@
# @suaveplan/payment-provider-stripe - Universal Stripe adapter for @suaveplan/payment. Implements PaymentProvider against the Stripe REST API via the slim @suaveplan/payment-providers foundation: Bearer auth on every call, Idempotency-Key forwarding on every mutating call, exhaustive Stripe status/refund-status/refund-reason mapping, payment-method normalisation, every error raised as PaymentProviderError with provider: 'stripe' discriminant. No Node / browser / React deps; every HTTP call flows through the injectable FetchLike from the slim core.
Universal Stripe adapter for `@suaveplan/payment`. Implements
`PaymentProvider` against the Stripe REST API using the slim
`@suaveplan/payment-providers` foundation: Bearer auth on every call,
`Idempotency-Key` forwarding on every mutating call, exhaustive
Stripe → domain status / refund-status / refund-reason mapping,
payment-method normalisation, every error raised as
`PaymentProviderError` with `provider: "stripe"` discriminant.
This package is one of the five sibling adapters that re-landed
after `2026-05-17-slim-payment-providers` split the bundled
`@suaveplan/payment-providers` into a foundation-with-sibling-plugins
shape.
<!-- begin:auto-docs -->
## Documentation tree
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-stripe \
@suaveplan/payment-providers \
@suaveplan/payment
```
## Quick start
```ts
import { PaymentEngine } from "@suaveplan/payment";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
});
const engine = new PaymentEngine({ provider });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
idempotencyKey: crypto.randomUUID(),
});
```
## Compose with the foundation registry
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const registry = new ProviderRegistry();
registry.register(
new StripeProvider({ apiKey: process.env.STRIPE_SECRET_KEY ?? "" })
);
const stripe = registry.get("stripe"); // typed as PaymentProvider
```
## Features
- **`StripeProvider`** — implements `PaymentProvider` from
`@suaveplan/payment` (literal `name: "stripe"`, six lifecycle
methods).
- **Bearer authentication** on every request (read + write).
- **`Idempotency-Key` forwarding** — when the caller supplies
`idempotencyKey` on `CreatePaymentIntentInput` or `RefundInput`,
the provider forwards it verbatim to Stripe so retries collapse
into a single side effect.
- **Exhaustive status mappings** — seven Stripe payment-intent
statuses, three Stripe refund statuses, four Stripe refund reasons,
every unmapped value falls back to `failed` / `other`.
- **Payment-method normalisation** — `card` carries `brand` + `last4`;
any other Stripe `type` maps to `wallet`; absent payment method
maps to `undefined`.
- **Typed errors** — every failure is a `PaymentProviderError` from
the slim core with `provider: "stripe"` discriminant.
- **Universal tier** — zero Node, browser, React, or
platform-specific imports. Runs unchanged on Node, Bun, browsers,
and Web Workers.
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
exports.
- **100% test coverage** (Istanbul) and **100% docs coverage**.
## Modules
- [`src/stripe/stripe.md`](./src/stripe/stripe.md) — `StripeProvider`
class, `StripeConfig`, `StripeIntent` / `StripeRefund` wire-format
interfaces.
## Related
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
`PaymentProvider` interface, `PaymentIntent` / `Refund` / status
enums.
- `@suaveplan/payment-providers` — slim foundation with
`ProviderRegistry`, `httpRequest`, `composeAbortSignal`,
`PaymentProviderError`, shared config shapes.
- `@suaveplan/payment-provider-paypal`,
`@suaveplan/payment-provider-square`,
`@suaveplan/payment-provider-mollie`,
`@suaveplan/payment-provider-adyen` — sibling provider packages.
## License
MIT

View File

@@ -0,0 +1,201 @@
# StripeProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It
implements the `PaymentProvider` contract (six methods covering the
payment-intent + refund lifecycle) against the Stripe REST API, using
the slim `@suaveplan/payment-providers` foundation for every HTTP
call. The class adds nothing beyond what `PaymentProvider` requires:
no Stripe-specific surface bleeds into the consumer; no in-flight
state is held between calls. Provider construction takes the same
`ApiKeyConfig` shape every API-key-based sibling uses (`apiKey`,
optional `baseUrl`, optional `webhookSecret`, plus the
`ProviderCommonOptions` injectable seams — `fetch`, `timeoutMs`,
`signal`).
Every mutating Stripe call (`createIntent`, `refund`) forwards the
caller-supplied `idempotencyKey` as the standard
`Idempotency-Key` HTTP header so retries against Stripe collapse
into a single side effect. Bearer auth (`Authorization: Bearer
<apiKey>`) is sent on every call, including read-only `getIntent`,
so the provider works against either a Stripe live secret or test
secret without any further wiring.
Stripe lifecycle states are mapped exhaustively into the
domain enums from `@suaveplan/payment`: seven explicit Stripe
status strings collapse into the five user-visible `PaymentStatus`
values, an unknown status maps to `failed`, refund statuses collapse
to `succeeded` / `pending` / `failed`, refund reasons collapse to the
four `RefundReason` values with `other` as the fallback.
## Features
- Implements `PaymentProvider` from `@suaveplan/payment` — class
with literal `name: "stripe"`, all six methods, every return value
conforms to `PaymentIntent` / `Refund`.
- Routes every HTTP call through the slim-core `httpRequest` helper
— inherits per-call timeout, `AbortSignal` composition, typed
errors, empty-body handling, JSON parse-error handling.
- Bearer authentication on every request (read + write).
- `Idempotency-Key` header forwarded verbatim when the caller
supplies `idempotencyKey` on `CreatePaymentIntentInput` or
`RefundInput`. Omitted on read calls and on writes where the
caller did not supply a key.
- Exhaustive Stripe → domain status / refund-status / refund-reason
mappers; unknown values fall back to `failed` / `other`.
- Payment-method normalisation: Stripe `payment_method.type === "card"`
maps to `"card"` with `brand` and `last4`; any other type maps to
`"wallet"`; absent payment method maps to `undefined`.
- Every raised `PaymentProviderError` carries `provider: "stripe"` so
consumers running multiple providers can discriminate without
parsing messages.
- Universal — no `node:*`, no `bun:*`, no platform-specific
imports anywhere under `src/`. Runs unchanged on Node, Bun,
browsers, and Web Workers.
## Usage: Basic
```ts
import { PaymentEngine } from "@suaveplan/payment";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
});
const engine = new PaymentEngine({ provider });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Usage: Advanced
Compose multiple providers via the slim-core `ProviderRegistry`,
inject a custom `FetchLike` for tracing/retries, scope every call to
a caller-supplied `AbortSignal`, and forward an `Idempotency-Key`
through the engine to collapse retried `createIntent` calls into a
single Stripe side effect:
```ts
import {
ProviderRegistry,
type FetchLike,
} from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const tracingFetch: FetchLike = async (url, init) => {
const start = performance.now();
try {
return await globalThis.fetch(url, init);
} finally {
// emit a span with (url, performance.now() - start)
}
};
const ac = new AbortController();
const provider = new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
baseUrl: "https://api.stripe.com/v1",
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
fetch: tracingFetch,
timeoutMs: 10_000,
signal: ac.signal,
});
const registry = new ProviderRegistry();
registry.register(provider);
const intent = await registry.get("stripe").createIntent({
amount: { amount: 12_99n, currency: "USD" },
captureMethod: "manual",
idempotencyKey: crypto.randomUUID(),
});
```
## API Reference
### `class StripeProvider implements PaymentProvider`
```ts
new StripeProvider(config: StripeConfig)
```
- **`name`** — `"stripe"` (literal type, frozen on the class).
- **`createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`**
— `POST /payment_intents`. Maps `input.amount.amount` to Stripe's
minor-unit integer (`Number`), lowercases the currency, forwards
`customer` / `description` / `metadata` / `payment_method` when
supplied. Forwards `Idempotency-Key` from `input.idempotencyKey`.
- **`confirmIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/confirm`. No body, no `Idempotency-Key`.
- **`captureIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/capture`. No body, no `Idempotency-Key`.
- **`cancelIntent(intentId): Promise<PaymentIntent>`** — `POST
/payment_intents/:id/cancel`. No body, no `Idempotency-Key`.
- **`refund(input: RefundInput): Promise<Refund>`** — `POST
/refunds`. Forwards `amount` / `reason` / `metadata` when supplied.
Forwards `Idempotency-Key` from `input.idempotencyKey`.
- **`getIntent(intentId): Promise<PaymentIntent>`** — `GET
/payment_intents/:id`. No body, no `Idempotency-Key`.
### `type StripeConfig = ApiKeyConfig`
Construction shape — alias for the shared `ApiKeyConfig` from
`@suaveplan/payment-providers`. Re-exported by name so consumers can
pin construction shape locally:
- `apiKey: string` — Stripe secret key (live or test).
- `baseUrl?: string` — defaults to `https://api.stripe.com/v1`.
- `webhookSecret?: string` — surfaced for downstream
`WebhookVerifier` wiring; not used by the provider itself.
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
- `timeoutMs?: number` — per-call timeout (default 30 000 ms).
- `signal?: AbortSignal` — caller cancellation, composed with the
internal timeout signal.
### Error semantics
Every failure raised under `StripeProvider` is a `PaymentProviderError`
from the slim core. Possible codes:
- `PAYMENT_PROVIDER.HTTP_ERROR` — Stripe returned non-2xx; `httpStatus`
carries the status code, `bodyExcerpt` carries up to 256 chars of
the response.
- `PAYMENT_PROVIDER.TIMEOUT` — the per-call timeout fired before
Stripe responded.
- `PAYMENT_PROVIDER.PARSE_ERROR` — Stripe returned a malformed JSON
response.
Every raised error carries `provider: "stripe"` so consumers
discriminate without parsing messages.
## Implementation Notes
- The provider holds no in-flight Promise cache and no token cache —
Stripe uses static Bearer auth, so there's nothing to memoise.
Idempotency is delegated to Stripe via the `Idempotency-Key`
header; the provider just forwards the caller-supplied key.
- The status / refund-status / refund-reason mappings are exhaustive
and tested per-row. The fallback `failed` / `other` branches are
exercised by passing an unmapped Stripe enum value.
- `mapPaymentMethod` exists as a small dedicated helper so the
card vs. wallet vs. absent branches stay testable in isolation;
consumers can rely on `paymentMethod.type === "card"` always
carrying `brand` and `last4` when Stripe surfaced them.
- The `commonOptions` private accessor exists so every method
forwards `fetch`, `signal`, `timeoutMs`, and the `provider` field
in lockstep. Calling sites stay symmetric — adding a new
`PaymentProvider` method only needs to spread `this.commonOptions()`
into the `httpRequest` call.
- `package.json` declares `"sideEffects": false` and the only
runtime imports are `@suaveplan/payment` (types-only) and
`@suaveplan/payment-providers` (one named symbol: `httpRequest`).
The dep-graph test pins universal purity.

View File

@@ -0,0 +1,213 @@
# @suaveplan/payment-providers - Slim universal foundation for the @suaveplan/payment-provider-* sibling packages. Ships ProviderRegistry, definePaymentProvider factory, shared httpRequest/composeAbortSignal helpers, FetchLike, the ApiKeyConfig / OAuthConfig / MerchantKeyConfig / HttpOptions / ProviderResponse types, and the PAYMENT_PROVIDER_CODES + PaymentProviderError error model. Concrete Stripe / PayPal / Square / Mollie / Adyen adapters ship as separate @suaveplan/payment-provider-<name> packages that depend on this foundation.
Universal **foundation** for the `@suaveplan/payment-provider-*`
sibling-package family. This package ships only the shared abstractions
every concrete adapter needs — the `ProviderRegistry`, the
`definePaymentProvider` factory, the `httpRequest` +
`composeAbortSignal` HTTP helpers, the `FetchLike` type, the shared
config shapes (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
`HttpOptions`, `ProviderResponse`), and the `PAYMENT_PROVIDER_CODES`
registry + `PaymentProviderError` class. Concrete adapters
(Stripe, PayPal, Square, Mollie, Adyen) live in **sibling packages** so
consumers pay only for the providers they install.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
- [define](./src/registry/define.md) — Factory helper for sibling `@suaveplan/payment-provider-*` packages that want to expose a `PaymentProvider` (from `@sua…
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
- [types](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
- [provider](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-providers @suaveplan/payment
# plus one or more of:
bun add @suaveplan/payment-provider-stripe
bun add @suaveplan/payment-provider-paypal
bun add @suaveplan/payment-provider-square
bun add @suaveplan/payment-provider-mollie
bun add @suaveplan/payment-provider-adyen
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
import { PaymentEngine } from "@suaveplan/payment";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
}));
const engine = new PaymentEngine({ provider: registry.get("stripe") });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Features
- **`ProviderRegistry`** — process-local, synchronous,
insertion-ordered registry of `PaymentProvider` instances keyed by
`name`; strict `get` with typed miss, duplicate-safe `register`,
idempotent `unregister`, frozen `list()` snapshot
- **`definePaymentProvider<TName>(spec)`** — factory that returns a
frozen, literal-typed `PaymentProvider` for sibling packages that
prefer a closure to a class
- **`httpRequest<T>(options)`** — universal `FetchLike` wrapper with
per-call timeout, JSON serialisation, custom-header passthrough,
typed errors, empty-body handling, and external-`AbortSignal`
composition
- **`composeAbortSignal(internal, external?)`** — reusable
abort-signal composer (extracted from `httpRequest` so sibling
packages can compose their own out-of-band HTTP calls with
caller-supplied cancellation)
- **`PAYMENT_PROVIDER_CODES`** + **`PaymentProviderError`** — 6-code
registry (`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`,
`OAUTH_TOKEN_FAILURE`, `PROVIDER_NOT_REGISTERED`,
`PROVIDER_ALREADY_REGISTERED`) with optional `provider` /
`httpStatus` / `url` / `bodyExcerpt` discriminants
- **Shared config shapes** — `ApiKeyConfig`, `OAuthConfig`,
`MerchantKeyConfig`, `HttpOptions`, `ProviderResponse`,
`ProviderCommonOptions`, plus `NowFn` / `RandomIdFn` /
`Base64EncodeFn` injectable seams
- **Universal tier** — zero Node, browser, React, or `node:crypto`
imports; runs unchanged on Node, Bun, browsers, and Web Workers
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
exports
- **100% test coverage** (Istanbul) + 100% docs coverage (sibling
`.md` per implementation file)
## Sibling provider packages
The five concrete provider adapters that used to ship in this package
now live in dedicated sibling packages, each depending on this
foundation:
- `@suaveplan/payment-provider-stripe` — `StripeProvider`
- `@suaveplan/payment-provider-paypal` — `PayPalProvider`
- `@suaveplan/payment-provider-square` — `SquareProvider` + the
Square-specific `SquareConfig` type
- `@suaveplan/payment-provider-mollie` — `MollieProvider`
- `@suaveplan/payment-provider-adyen` — `AdyenProvider`
Install one or more alongside this foundation. Every sibling exports
the same class name + `PaymentProvider`-interface contract as before
the slim-down, so migration from a bundled import is a one-line
package swap (see `## Migration` below).
## Migration
If you were importing a concrete provider from
`@suaveplan/payment-providers`, switch to the matching sibling
package. The class names are preserved verbatim:
```ts
// Before
import { StripeProvider } from "@suaveplan/payment-providers";
// After
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
```
All shared types (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
`HttpOptions`, `ProviderResponse`, `NowFn`, `RandomIdFn`,
`Base64EncodeFn`), `httpRequest`, `composeAbortSignal`, the
`PaymentProviderError` class, and the `PAYMENT_PROVIDER_CODES`
registry continue to be exported from
`@suaveplan/payment-providers` unchanged. The Square-specific
`SquareConfig` type now ships with
`@suaveplan/payment-provider-square`.
## Modules
- [`src/types/provider.md`](./src/types/provider.md) — `ApiKeyConfig`,
`OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
`ProviderResponse`, plus `NowFn` / `RandomIdFn` / `Base64EncodeFn`
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`, factories
(`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`,
`providerNotRegistered`, `providerAlreadyRegistered`)
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
`composeAbortSignal` + `FetchLike`
- [`src/registry/registry.md`](./src/registry/registry.md) —
`ProviderRegistry`
- [`src/registry/define.md`](./src/registry/define.md) —
`definePaymentProvider` factory
## Building a custom provider
Sibling provider packages — and any consumer adding a non-standard
provider — implement the `PaymentProvider` interface from
`@suaveplan/payment` and use `httpRequest` from this foundation for
every HTTP call:
```ts
import {
type ApiKeyConfig,
httpRequest,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
} from "@suaveplan/payment";
export class CustomProvider implements PaymentProvider {
public readonly name = "custom";
private readonly config: ApiKeyConfig;
public constructor(config: ApiKeyConfig) {
this.config = config;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const { data } = await httpRequest<{ id: string }>({
method: "POST",
url: `${this.config.baseUrl ?? "https://api.custom.com"}/intents`,
headers: { "X-API-Key": this.config.apiKey },
body: { amount: Number(input.amount.amount) },
fetch: this.config.fetch,
signal: this.config.signal,
timeoutMs: this.config.timeoutMs,
provider: this.name,
});
return mapToPaymentIntent(data);
}
// ...implement remaining PaymentProvider methods
}
function mapToPaymentIntent(_raw: unknown): PaymentIntent {
throw new PaymentProviderError(
"PAYMENT_PROVIDER.PARSE_ERROR" as never,
"implement mapping"
);
}
```
## Related
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
`PaymentProvider` interface
- `@suaveplan/money` — currency-aware arithmetic
- `@suaveplan/error` — base error class + `defineCodes` registry helper
## License
MIT

View File

@@ -0,0 +1,99 @@
# Payment-provider error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error registry and typed error class for
`@suaveplan/payment-providers`. Every production failure path — non-ok
HTTP response, request timeout, malformed JSON body, OAuth
token-acquisition failure, unknown-name lookup against the registry,
duplicate registration against the registry — raises a
`PaymentProviderError` carrying one of the namespaced codes declared
via `defineCodes`. Consumers programmatically dispatch on `error.code`
(e.g. `"PAYMENT_PROVIDER.HTTP_ERROR"`) or on the optional discriminants
`provider`, `httpStatus`, `url`, and `bodyExcerpt` rather than parsing
free-form `error.message` strings.
## Features
- 6-code registry via `defineCodes("PAYMENT_PROVIDER", […])`
- `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
- Discriminants: `provider`, `httpStatus`, `url`, `bodyExcerpt`
(capped at 256 chars + ellipsis)
- Cause-preserving factories for parse and OAuth failures
- Registry-aware factories `providerNotRegistered` /
`providerAlreadyRegistered` raised by `ProviderRegistry`
- Custom message override on `httpError` for callers that have a more
user-friendly diagnostic than the default `HTTP {status} from {provider}`
## Usage: Basic
```ts
import {
httpError,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
const err = httpError("stripe", 404, "https://api.stripe.com/v1/x", "missing");
console.log(err.code); // "PAYMENT_PROVIDER.HTTP_ERROR"
console.log(err.httpStatus); // 404
console.log(err.provider); // "stripe"
if (err instanceof PaymentProviderError) {
// narrow on code below
}
if (err.code === PAYMENT_PROVIDER_CODES.HTTP_ERROR) {
// typed branch
}
```
## Usage: Advanced
```ts
import { parseError, oauthTokenFailure } from "@suaveplan/payment-providers";
try {
const json = JSON.parse("{not json");
void json;
} catch (cause) {
// Preserve the original parse failure as the .cause so OTel and
// log appenders can walk the chain.
throw parseError("stripe", "https://api.stripe.com/v1/x", "{not json", cause);
}
try {
await fetchPayPalToken();
} catch (cause) {
throw oauthTokenFailure("paypal", "https://api.paypal.com/v1/oauth2/token", cause);
}
```
## API Reference
| Symbol | Kind | Signature |
|---|---|---|
| `PAYMENT_PROVIDER_CODES` | `const` | `{ HTTP_ERROR, TIMEOUT, PARSE_ERROR, OAUTH_TOKEN_FAILURE, PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED }` (each value is `"PAYMENT_PROVIDER.<NAME>"`) |
| `PaymentProviderErrorCode` | `type` | union of registry values |
| `PaymentProviderError` | `class` | extends `SuaveplanError<PaymentProviderErrorCode>` |
| `PaymentProviderErrorOptions` | `interface` | `{ provider?, httpStatus?, url?, bodyExcerpt? } & SuaveplanErrorOptions` |
| `httpError(provider, status, url, body, message?)` | `function` | returns `PaymentProviderError(HTTP_ERROR)` |
| `timeoutError(provider, url, timeoutMs, cause?)` | `function` | returns `PaymentProviderError(TIMEOUT)` |
| `parseError(provider, url, body, cause)` | `function` | returns `PaymentProviderError(PARSE_ERROR)`, preserves cause |
| `oauthTokenFailure(provider, url, cause)` | `function` | returns `PaymentProviderError(OAUTH_TOKEN_FAILURE)`, preserves cause |
| `providerNotRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_NOT_REGISTERED)` carrying `name` on `provider` |
| `providerAlreadyRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_ALREADY_REGISTERED)` carrying `name` on `provider` |
## Implementation Notes
`bodyExcerpt` is truncated at 256 characters with a trailing `…` so the
error is safe to log without flooding sinks. The codes are registered
at module-load time via `defineCodes`, which freezes the registry
object — runtime mutations are silently ignored under strict mode and
fail loudly with `TypeError` under non-strict. The class itself
deliberately does not expose any extra serialisation beyond what
`SuaveplanError` provides; consumers wanting wire format should call
the base-class `.toJSON()` which already encodes `code`, `message`,
`cause` chain, and the discriminant fields.

View File

@@ -0,0 +1,105 @@
# `httpRequest` helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal HTTP helper used by every provider to issue typed, cancellable
JSON requests. The helper is a thin wrapper over an injectable
`FetchLike` (defaulting to `globalThis.fetch`) that JSON-serialises
non-string bodies, applies a per-call timeout via `AbortSignal`,
composes a caller-supplied `signal` with the internal timeout signal,
and converts every failure path into a typed `PaymentProviderError` so
callers can branch on `error.code` instead of parsing strings.
## Features
- Injectable `FetchLike` (`fetch?: FetchLike`) — defaults to
`globalThis.fetch`
- JSON serialisation of object bodies (string bodies pass through
unchanged, enabling `application/x-www-form-urlencoded` flows)
- Per-call `timeoutMs` (default `30_000`) via internal `AbortController`
- External `signal?: AbortSignal` composed with the internal timeout
- Typed errors: `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`
- Empty-body handling: 204 / empty responses resolve to `data: {}`
- `provider?: string` discriminant surfaced on every raised error
- `composeAbortSignal(internal, external)` public helper — reusable by
sibling provider packages that issue out-of-band HTTP calls (OAuth
token fetches, webhook signature fetches) and want the same
caller-signal composition without re-implementing the wiring
## Usage: Basic
```ts
import { httpRequest } from "@suaveplan/payment-providers";
const { status, data } = await httpRequest<{ id: string }>({
method: "POST",
url: "https://api.example.com/charges",
headers: { Authorization: "Bearer secret" },
body: { amount: 1000 },
timeoutMs: 10_000,
provider: "example",
});
```
## Usage: Advanced
```ts
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
// Inject a recording FetchLike for tests — no globalThis monkey-patching.
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
const external = new AbortController();
const promise = httpRequest({
method: "GET",
url: "https://api.example.com/intent/1",
fetch,
signal: external.signal,
timeoutMs: 5_000,
});
// Cancel externally — either signal firing aborts the request.
external.abort();
await promise.catch((err) => console.log(err.code));
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `FetchLike` | `type` | `(input: string, init?: RequestInit) => Promise<Response>` |
| `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>` | `function` | issues the HTTP call, returns parsed JSON |
| `composeAbortSignal(internal, external?): AbortSignal` | `function` | returns `internal` when `external` is undefined; otherwise returns a fresh signal that aborts when either input aborts, with already-aborted external propagating synchronously |
### `HttpOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `method` | `"GET" \| "POST" \| "DELETE" \| "PATCH"` | — | HTTP verb |
| `url` | `string` | — | request URL |
| `headers` | `Record<string, string>` | `{}` | extra headers (override the JSON defaults if set) |
| `body` | `unknown` | — | JSON-serialised unless already a `string` |
| `timeoutMs` | `number` | `30_000` | per-call timeout |
| `fetch` | `FetchLike` | `globalThis.fetch` | injectable HTTP client |
| `signal` | `AbortSignal` | — | external cancellation |
| `provider` | `string` | `"unknown"` | discriminant placed on raised errors |
## Implementation Notes
The internal `AbortController` always wins on timeout — its `abort()` is
called from a `setTimeout` whose handle is cleared in `finally`, so no
listener leaks past the call. The composed `AbortSignal` is created
inline via a third `AbortController`; both source signals
(`timeoutController.signal` and the caller's `signal`) propagate to it
via `addEventListener("abort", …, { once: true })`, plus an upfront
check for the already-aborted case. Non-`Error` throws from the
injected `FetchLike` (e.g. raw strings) become `HTTP_ERROR` with a
stringified body excerpt — the helper never lets an untyped failure
escape.

View File

@@ -0,0 +1,119 @@
# `definePaymentProvider`
## Purpose
Factory helper for sibling `@suaveplan/payment-provider-*` packages
that want to expose a `PaymentProvider` (from `@suaveplan/payment`)
without authoring a full class. The factory returns a frozen object
that satisfies the structural `PaymentProvider` contract and carries
the caller-supplied method references verbatim — closures are
preserved, no `this` rebinding happens. Sibling packages MAY use this
factory or return a class instance from their `new XProvider(...)`
constructor; both register identically into
{@link ProviderRegistry}. The factory exists so providers without
per-instance state (deterministic test providers, in-memory mocks,
thin wrappers around an HTTP closure) can skip the class boilerplate.
## Features
- Literal-typed `name` — the result narrows to
`PaymentProvider & { readonly name: TName }` so consumers can
discriminate at the type level (`provider.name === "stripe"` narrows
the rest of the object)
- Frozen output — accidental mutation downstream throws in strict mode
- Method-identity preservation — the function passed in is the function
dispatched at call time (useful for spies / instrumentation that
identify methods by reference)
- Zero runtime cost beyond a single `Object.freeze`
- Universal — no Node, browser, or React imports
## Usage: Basic
```ts
import { definePaymentProvider, ProviderRegistry } from "@suaveplan/payment-providers";
const stub = definePaymentProvider({
name: "stub",
createIntent: async (input) => ({
id: "intent_1",
provider: "stub",
amount: input.amount,
currency: input.amount.currency,
status: "created",
captureMethod: input.captureMethod ?? "automatic",
refunds: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
confirmIntent: async () => { throw new Error("not used"); },
captureIntent: async () => { throw new Error("not used"); },
cancelIntent: async () => { throw new Error("not used"); },
refund: async () => { throw new Error("not used"); },
getIntent: async () => { throw new Error("not used"); },
});
const registry = new ProviderRegistry();
registry.register(stub);
```
## Usage: Advanced
```ts
import {
definePaymentProvider,
ProviderRegistry,
} from "@suaveplan/payment-providers";
// A deterministic test provider that records every call by reference
// — note that `recordedCreate` IS the function exposed on
// `provider.createIntent`, so spies / assertions can identify it by
// equality.
const calls: string[] = [];
const recordedCreate = async (input: never): Promise<never> => {
calls.push("create");
void input;
throw new Error("test-only stub");
};
const provider = definePaymentProvider({
name: "test",
createIntent: recordedCreate,
confirmIntent: recordedCreate,
captureIntent: recordedCreate,
cancelIntent: recordedCreate,
refund: recordedCreate,
getIntent: recordedCreate,
});
// Type narrowing on the literal name.
if (provider.name === "test") {
// `provider` here is narrowed to `{ name: "test", ... }`
}
// The factory result is frozen.
expect(Object.isFrozen(provider)).toBe(true);
expect(provider.createIntent).toBe(recordedCreate);
const registry = new ProviderRegistry();
registry.register(provider);
expect(registry.get("test")).toBe(provider);
```
## API Reference
| Symbol | Kind | Signature |
|---|---|---|
| `PaymentProviderSpec<TName>` | `interface` | `{ name: TName; createIntent; confirmIntent; captureIntent; cancelIntent; refund; getIntent }` — every method type matches the `PaymentProvider` interface from `@suaveplan/payment` |
| `definePaymentProvider<TName>(spec)` | `function` | returns `PaymentProvider & { readonly name: TName }`, frozen |
## Implementation Notes
`definePaymentProvider` is intentionally minimal — it does not
register the result anywhere, does not wrap methods in instrumentation,
and does not perform validation beyond TypeScript's structural checks.
Validation belongs to the layer that decides what to do with a
provider (the `ProviderRegistry`, the `PaymentEngine`, etc.); the
factory's only job is to package a spec into a frozen, literal-typed
`PaymentProvider`. The `Object.freeze` call freezes the top-level
object only; the methods themselves remain mutable function objects so
sibling packages can attach their own metadata if needed.

View File

@@ -0,0 +1,117 @@
# `ProviderRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Process-local, synchronous registry that indexes `PaymentProvider`
instances (from `@suaveplan/payment`) by their `name` field. The
registry is the foundation contract that sibling
`@suaveplan/payment-provider-*` packages plug into — consumers
explicitly construct + register the provider instances they want to
route to and look them up by string name. The registry has no
persistence layer, no auto-discovery, and no async lifecycle so it can
ship and run unchanged on every JavaScript runtime.
## Features
- O(1) `register`, `get`, `has`, `unregister` via an internal
`Map<string, PaymentProvider>`
- Insertion-ordered enumeration via `list()` and a frozen array
snapshot to prevent external mutation
- Strict `get` semantics — throws
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)` on
miss so callers never silently route through `undefined`
- Duplicate-safe `register` — throws
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
when the same `name` is supplied twice
- Idempotent `unregister` — returns `true` on hit and `false` on miss,
never throws
- `size` getter mirrors `Map`/`Set` semantics (numeric property, not a
method)
- Provider-agnostic — the registry knows nothing about Stripe / PayPal /
Square specifics; it only sees the `PaymentProvider` interface from
`@suaveplan/payment`
## Usage: Basic
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({ apiKey: process.env.STRIPE_KEY ?? "" }));
const stripe = registry.get("stripe");
const intent = await stripe.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Usage: Advanced
```ts
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
ProviderRegistry,
} from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({ apiKey: STRIPE_KEY }));
registry.register(new PayPalProvider({ clientId: PP_ID, clientSecret: PP_SECRET }));
// Route by name with strict semantics.
function route(name: string) {
try {
return registry.get(name);
} catch (err) {
if (
err instanceof PaymentProviderError &&
err.code === PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
) {
// Fall back to a soft default — but only after observing the
// strict miss, so logs distinguish "unconfigured" from
// "configured but failed".
return registry.get("stripe");
}
throw err;
}
}
// Enumerate for diagnostics — the result is frozen so this code can
// hand the array straight to a logger without worrying about callers
// mutating registry state.
for (const provider of registry.list()) {
console.log(`provider=${provider.name}`);
}
```
## API Reference
| Member | Kind | Signature | Description |
|---|---|---|---|
| `register(provider)` | method | `(provider: PaymentProvider) => void` | Index by `provider.name`. Throws `PROVIDER_ALREADY_REGISTERED` on duplicate. |
| `unregister(name)` | method | `(name: string) => boolean` | Remove by name; returns `true` if present. |
| `get(name)` | method | `(name: string) => PaymentProvider` | Strict lookup. Throws `PROVIDER_NOT_REGISTERED` on miss. |
| `has(name)` | method | `(name: string) => boolean` | Soft membership check. |
| `list()` | method | `() => readonly PaymentProvider[]` | Frozen insertion-ordered snapshot. |
| `size` | getter | `number` | Number of registered providers. |
## Implementation Notes
The registry is backed by an `ES Map` keyed by `provider.name` to keep
operations O(1) and to inherit `Map`'s insertion-order iteration
guarantee. `list()` returns a frozen `Array.from` snapshot — copying
on read keeps the public surface immutable without giving up the
`Map` storage. The two registry-related error codes
(`PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`) live with
the other `PAYMENT_PROVIDER_CODES` so consumers do a single
`switch (err.code)` over the whole namespace. The class is deliberately
**not** a singleton — applications that need multiple registries (e.g.
test isolation, multi-tenant routing) construct multiple instances.

View File

@@ -0,0 +1,102 @@
# Provider configuration types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared configuration interfaces and injectable seams consumed by every
sibling `@suaveplan/payment-provider-*` adapter package. These types
live in the slim foundation so each sibling can declare its concrete
config (`StripeConfig`, `PayPalConfig`, `SquareConfig`,
`AdyenConfig`, …) by extending one of the three base shapes here. They
are universal — no runtime code, no browser or Node imports, no
dependencies beyond a type-only `FetchLike` re-import from the sibling
`http/http.ts` module.
## Features
- `ApiKeyConfig` — Bearer-auth providers (Stripe, Mollie, Square via
the sibling package)
- `OAuthConfig` — client-credentials providers (PayPal via the sibling
package) with injectable `now` and `base64Encode`
- `MerchantKeyConfig` — header-keyed providers carrying a merchant
account (Adyen via the sibling package) with injectable `randomId`
- `ProviderResponse<T>` — generic HTTP response wrapper returned by
`httpRequest`
- `HttpOptions` — input shape of `httpRequest`
- `ProviderCommonOptions` — shared injectable seams (`fetch`,
`timeoutMs`, `signal`)
- `NowFn` / `RandomIdFn` / `Base64EncodeFn` — function-type aliases for
the injectable seams
## Usage: Basic
```ts
import type { ApiKeyConfig } from "@suaveplan/payment-providers/types/provider";
const config: ApiKeyConfig = {
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
baseUrl: "https://api.stripe.com/v1",
timeoutMs: 10_000,
};
```
## Usage: Advanced
```ts
import type {
MerchantKeyConfig,
OAuthConfig,
} from "@suaveplan/payment-providers/types/provider";
// OAuth provider (PayPal sibling): inject a deterministic clock so the
// token-cache TTL is reproducible in tests, and a Base-64 encoder that
// works on every runtime (Node, Bun, browsers, Web Workers).
const oauth: OAuthConfig = {
clientId: "id",
clientSecret: "secret",
now: () => 0,
base64Encode: (s) => Buffer.from(s, "utf8").toString("base64"),
timeoutMs: 5_000,
};
// Merchant-keyed provider (Adyen sibling): inject a deterministic id
// generator so auto-generated `reference` values are predictable in
// tests.
const merchant: MerchantKeyConfig = {
apiKey: "k",
merchantAccount: "TestMerchant",
randomId: () => "deterministic-id",
};
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `ApiKeyConfig` | `interface` | `{ apiKey, baseUrl?, webhookSecret?, fetch?, timeoutMs?, signal? }` |
| `OAuthConfig` | `interface` | adds `clientId`, `clientSecret`, `now?`, `base64Encode?` |
| `MerchantKeyConfig` | `interface` | `{ apiKey, merchantAccount, baseUrl?, webhookHmacKey?, randomId?, …common }` |
| `ProviderResponse<T>` | `interface` | `{ status: number; data: T }` |
| `HttpOptions` | `interface` | `{ method, url, headers?, body?, provider?, …common }` |
| `ProviderCommonOptions` | `interface` | `{ fetch?, timeoutMs?, signal? }` |
| `NowFn` | `type` | `() => number` |
| `RandomIdFn` | `type` | `() => string` |
| `Base64EncodeFn` | `type` | `(input: string) => string` |
## Implementation Notes
`fetch`, `now`, `randomId`, and `base64Encode` default at the sibling
package level to `globalThis.fetch`, `Date.now`,
`globalThis.crypto.randomUUID`, and `globalThis.btoa` respectively.
Each sibling provider resolves its seams at construction time rather
than at call time so tests can inject a single deterministic fixture
per instance. `signal` composes with the per-call internal
`AbortSignal` driven by `timeoutMs` via the foundation's
`composeAbortSignal` helper, so callers cancelling a request from
outside cancel the in-flight `fetch` and free the `setTimeout`.
Provider-specific shapes (`StripeConfig`, `SquareConfig`, `PayPalConfig`,
etc.) live in their respective sibling packages — the foundation only
ships shapes that apply to every provider class.

View File

@@ -0,0 +1,189 @@
# @suaveplan/payment - Universal payment intent lifecycle, state-machine enforcement, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestration, reconciliation, and card tokenization. Provider-agnostic — concrete gateway adapters (Stripe, PayPal, Test) ship in this package; bring-your-own PaymentProvider for everything else.
Universal payment intent lifecycle, webhook signature verification,
receipt generation, fraud scoring, refund tracking, tax calculation, retry
orchestration, reconciliation, and PCI-conscious card tokenisation.
Provider-agnostic — drop in `TestGateway`, `StripeGateway`, `PayPalGateway`,
or any custom `PaymentProvider`. Zero browser- or Node-only imports; runs
on Bun, Node ≥ 18, modern browsers, and Web Workers.
<!-- begin:auto-docs -->
## Documentation tree
- [engine](./src/engine/engine.md) — Orchestrates the full payment intent lifecycle on top of an injected `PaymentProvider`. Owns the state machine (`VALID_…
- [engine](./src/engine/engine.md) — Orchestrates the full payment intent lifecycle on top of an injected `PaymentProvider`. Owns the state machine (`VALID_…
- [error-codes](./src/error-codes/error-codes.md) — Stable, namespaced error registry and discriminated `PaymentError` class for the payment package. Every failure path in…
- [error-codes](./src/error-codes/error-codes.md) — Stable, namespaced error registry and discriminated `PaymentError` class for the payment package. Every failure path in…
- [fraud](./src/fraud/fraud.md) — Risk-scoring helper that flags suspicious payment attempts before they reach the provider. Four built-in rules — veloci…
- [fraud](./src/fraud/fraud.md) — Risk-scoring helper that flags suspicious payment attempts before they reach the provider. Four built-in rules — veloci…
- [paypal](./src/gateway/paypal.md) — PayPal REST adapter for the abstract `PaymentGateway` contract. Talks to the Orders API v2 via injected `fetch`, caches…
- [stripe](./src/gateway/stripe.md) — Stripe REST adapter for the abstract `PaymentGateway` contract. Talks to the Payment Intents API directly via injected…
- [test](./src/gateway/test.md) — In-memory `PaymentGateway` implementation used by tests, fixtures, and local development. Records every operation in ch…
- [types](./src/gateway/types.md) — Defines the abstract `PaymentGateway` contract every concrete adapter (the in-memory `TestGateway`, the Stripe REST ada…
- [hooks](./src/hooks/hooks.md) — Tiny generic hooks bus owned by the payment package. It powers the `payment:processing` pipeline that fires inside `Pay…
- [hooks](./src/hooks/hooks.md) — Tiny generic hooks bus owned by the payment package. It powers the `payment:processing` pipeline that fires inside `Pay…
- [receipt](./src/receipt/receipt.md) — Generates structured `Receipt` objects from `PaymentIntent` plus caller- supplied line items, then provides a minimal p…
- [receipt](./src/receipt/receipt.md) — Generates structured `Receipt` objects from `PaymentIntent` plus caller- supplied line items, then provides a minimal p…
- [reconciliation](./src/reconciliation/reconciliation.md) — Reconciles a batch of internal payment records against gateway transaction records, matches them by id, surfaces amount…
- [reconciliation](./src/reconciliation/reconciliation.md) — Reconciles a batch of internal payment records against gateway transaction records, matches them by id, surfaces amount…
- [refund](./src/refund/refund.md) — Tracks per-intent refund history with balance validation, partial-refund support, and reason categorisation. The `Refun…
- [refund](./src/refund/refund.md) — Tracks per-intent refund history with balance validation, partial-refund support, and reason categorisation. The `Refun…
- [retry](./src/retry/retry.md) — Generic retry helper for payment operations. Wraps any async function with exponential backoff + jitter, honours a non-…
- [retry](./src/retry/retry.md) — Generic retry helper for payment operations. Wraps any async function with exponential backoff + jitter, honours a non-…
- [tax](./src/tax/tax.md) — In-memory tax-rate registry and line-item calculator. Supports tax-inclusive (price-includes-tax) and tax-exclusive (pr…
- [tax](./src/tax/tax.md) — In-memory tax-rate registry and line-item calculator. Supports tax-inclusive (price-includes-tax) and tax-exclusive (pr…
- [tokenization](./src/tokenization/tokenization.md) — PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects malformed expiry data and expired cards as…
- [tokenization](./src/tokenization/tokenization.md) — PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects malformed expiry data and expired cards as…
- [types](./src/types/payment.md) — Shared type contracts for the payment lifecycle: status union, payment-method descriptors, refund and dispute records,…
- [payment](./src/types/payment.md) — Shared type contracts for the payment lifecycle: status union, payment-method descriptors, refund and dispute records,…
- [webhook](./src/webhook/webhook.md) — Universal webhook signature verifier. Computes an HMAC over a `timestamp.body` envelope using `@suaveplan/crypto-h…
- [webhook](./src/webhook/webhook.md) — Universal webhook signature verifier. Computes an HMAC over a `timestamp.body` envelope using `@suaveplan/crypto-h…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment
```
## Quick start
```ts
import {
PaymentEngine,
ReceiptGenerator,
TestGateway,
WebhookVerifier,
} from "@suaveplan/payment";
const gateway = new TestGateway();
const engine = new PaymentEngine({
provider: {
name: gateway.name,
createIntent: async (input) => ({
id: "pi_001",
status: "created",
amount: input.amount,
capturedAmount: { amount: 0n, currency: input.amount.currency },
refundedAmount: { amount: 0n, currency: input.amount.currency },
captureMethod: input.captureMethod ?? "automatic",
refunds: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
confirmIntent: async () => {
throw new Error("not used in quick start");
},
captureIntent: async () => {
throw new Error("not used in quick start");
},
cancelIntent: async () => {
throw new Error("not used in quick start");
},
refund: async () => {
throw new Error("not used in quick start");
},
getIntent: async () => {
throw new Error("not used in quick start");
},
},
});
engine.on("intent.created", (intent) =>
console.log("created", intent.id, intent.amount)
);
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
const verifier = new WebhookVerifier({ secret: "whsec_test" });
const ts = Date.now();
const body = JSON.stringify({
id: "evt_001",
type: "payment.captured",
timestamp: ts,
data: { intentId: intent.id },
});
const sig = verifier.sign(body, ts);
const { payload } = verifier.verify(body, sig, ts);
const receiptGen = new ReceiptGenerator({ merchantName: "Acme" });
const receipt = receiptGen.generate({
intent,
lineItems: [
{
description: "Widget",
quantity: 1,
unitPrice: intent.amount,
total: intent.amount,
},
],
subtotal: intent.amount,
total: intent.amount,
});
console.log(receiptGen.toPlainText(receipt), payload.type);
```
## Modules
- [`engine/engine`](./src/engine/engine.md) — `PaymentEngine`, state-machine
enforcement, lifecycle events.
- [`webhook/webhook`](./src/webhook/webhook.md) — `WebhookVerifier`
(HMAC + replay window).
- [`receipt/receipt`](./src/receipt/receipt.md) — `ReceiptGenerator` and
plain-text renderer.
- [`gateway/types`](./src/gateway/types.md) — `PaymentGateway` abstract
contract.
- [`gateway/test`](./src/gateway/test.md) — in-memory `TestGateway`.
- [`gateway/stripe`](./src/gateway/stripe.md) — Stripe REST adapter.
- [`gateway/paypal`](./src/gateway/paypal.md) — PayPal REST adapter.
- [`fraud/fraud`](./src/fraud/fraud.md) — `FraudDetector` scoring.
- [`refund/refund`](./src/refund/refund.md) — `RefundManager` ledger.
- [`retry/retry`](./src/retry/retry.md) — `PaymentRetrier` with backoff +
jitter.
- [`tax/tax`](./src/tax/tax.md) — `TaxCalculator` jurisdiction registry.
- [`tokenization/tokenization`](./src/tokenization/tokenization.md) —
`CardTokenizer` (Luhn + HMAC).
- [`reconciliation/reconciliation`](./src/reconciliation/reconciliation.md)
— `PaymentReconciliation` matcher / report.
- [`hooks/hooks`](./src/hooks/hooks.md) — `Hooks` + `paymentHooks`
singleton + `payment:processing` pipeline.
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) —
`PAYMENT_CODES`, `PaymentError`, factory functions.
- [`types/payment`](./src/types/payment.md) — `PaymentIntent`,
`PaymentProvider`, `PaymentEventMap`, `VALID_TRANSITIONS`.
## Features
- **Payment intent lifecycle** — 9-state machine (`created` → `pending` →
`authorized` → `captured` → `partially_refunded` / `refunded` /
`disputed` / `canceled` / `failed`) with `VALID_TRANSITIONS` enforcement.
- **Typed lifecycle events** — `intent.created`, `intent.confirmed`,
`intent.captured`, `intent.canceled`, `intent.failed`, `refund.created`,
`refund.succeeded`, `refund.failed`, `dispute.opened`, `dispute.closed`.
- **Webhook verification** — HMAC over `timestamp.body`, configurable
algorithm (SHA-256/384/512), constant-time compare, replay window.
- **Receipt generation** — structured records + plain-text fallback.
- **Concrete gateway adapters** — `StripeGateway` (Payment Intents API),
`PayPalGateway` (Orders API v2 + OAuth token cache), `TestGateway`
in-memory.
- **Fraud scoring** — velocity, amount, customer, new-method heuristics.
- **Refund manager** — partial refunds, balance ledger, reason filtering.
- **Retry orchestration** — exponential backoff + jitter, non-retryable
blocklist.
- **Tax calculator** — region/country lookup, inclusive + exclusive
pricing, exempt items.
- **Card tokenisation** — Luhn check, brand detection, HMAC-SHA-256 token
via Web Crypto.
- **Reconciliation** — match / discrepancy / unmatched report with
normalised status compare.
- **Typed errors** — `PaymentError extends SuaveplanError<PaymentErrorCode>`
carrying optional `intentId` / `currentStatus` / `targetStatus` / `ageMs`
discriminants.
## License
MIT

View File

@@ -0,0 +1,145 @@
# engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Orchestrates the full payment intent lifecycle on top of an injected
`PaymentProvider`. Owns the state machine (`VALID_TRANSITIONS`), the typed
`EventBus<PaymentEventMap>` that fires lifecycle events, the refund-balance
guard that rejects refunds exceeding the captured total, and the
`payment:processing` hook pipeline that lets callers transform intent input
before it reaches the provider. Provider-agnostic — Stripe, PayPal, the
in-memory test gateway, or any custom implementation drops in unchanged.
## Features
- `createIntent` / `confirmIntent` / `captureIntent` / `cancelIntent` /
`refund` / `getIntent` — every lifecycle entry point.
- `markFailed` / `openDispute` / `closeDispute` — explicit emitters for
out-of-band states the provider may not surface directly.
- State-machine enforcement via `canTransition` + private `assertTransition`;
illegal transitions raise `PaymentError(PAYMENT.INVALID_TRANSITION)` with
`currentStatus` / `targetStatus` discriminants set.
- Refund-balance guard — `refund({ amount })` whose amount exceeds the
remaining (captured − refunded) raises
`PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)`.
- Provider errors are wrapped — `PaymentError(PAYMENT.PROVIDER_ERROR)` with
the original error preserved as `cause`; non-`Error` throws are stringified
into the message.
- Typed `on(event, listener)` → `Unsubscribe` for every entry in
`PaymentEventMap`.
## Usage: Basic
```ts
import { PaymentEngine } from "@suaveplan/payment/engine/engine";
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
declare const provider: PaymentProvider;
const engine = new PaymentEngine({ provider });
engine.on("intent.captured", (intent) => {
console.log("captured", intent.id, intent.capturedAmount);
});
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
await engine.confirmIntent(intent.id);
await engine.captureIntent(intent.id);
```
## Usage: Advanced
```ts
import { PaymentEngine } from "@suaveplan/payment/engine/engine";
import {
PAYMENT_CODES,
PaymentError,
} from "@suaveplan/payment/error-codes/error-codes";
import { onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
declare const provider: PaymentProvider;
// Stack a hook that normalises currency before the provider call.
onPaymentProcessing((data) => ({
...data,
input: {
...data.input,
amount: {
amount: data.input.amount.amount,
currency: data.input.amount.currency.toUpperCase(),
},
},
}));
const engine = new PaymentEngine({ provider });
try {
await engine.refund({
paymentIntentId: "pi_001",
amount: { amount: 9999n, currency: "USD" },
});
} catch (err) {
if (
err instanceof PaymentError &&
err.code === PAYMENT_CODES.REFUND_EXCEEDS_CAPTURED
) {
console.warn("refund denied", err.intentId);
}
}
```
## API Reference
- `PaymentEngineConfig` — `{ provider: PaymentProvider }`.
- `new PaymentEngine(config)` — construct an engine.
- `engine.providerName` → string getter exposing `provider.name`.
- `engine.on(event, listener)` → `Unsubscribe`. Listener payload is typed
per `PaymentEventMap`.
- `engine.createIntent(input)` → `Promise<PaymentIntent>`. Fires the
`payment:processing` hook chain, then emits `intent.created`.
- `engine.confirmIntent(id)` → `Promise<PaymentIntent>`. Reads current
status; rejects illegal transitions; emits `intent.confirmed`.
- `engine.captureIntent(id, amount?)` → `Promise<PaymentIntent>`. Emits
`intent.captured`. `amount` allows partial capture when the provider
supports it.
- `engine.cancelIntent(id)` → `Promise<PaymentIntent>`. Emits
`intent.canceled`.
- `engine.refund(input)` → `Promise<Refund>`. Validates current status is
refundable, validates amount ≤ remaining, then emits one of
`refund.succeeded` / `refund.failed` / `refund.created` depending on the
provider's reported `status`.
- `engine.getIntent(id)` → `Promise<PaymentIntent>`. Wraps provider
failures as `PaymentError(PAYMENT.NOT_FOUND)`.
- `engine.markFailed(intent)` — emit `intent.failed`.
- `engine.openDispute(dispute)` — emit `dispute.opened`.
- `engine.closeDispute(dispute)` — emit `dispute.closed`.
- `engine.canTransition(from, to)` → boolean. Pure lookup on
`VALID_TRANSITIONS`.
## Implementation Notes
- The engine owns a private `EventBus<PaymentEventMap>` — listeners
subscribed via `engine.on(...)` receive payloads via the bus's async
dispatch path, which awaits each listener in registration order.
- `wrapProviderError` strips empty messages so non-`Error` throws like `42`
surface as `"42"`, `null` as `"null"`, and Error instances pass through
with `message` preserved. The original error is always attached as `cause`
when present.
- `getIntent` swallows any provider error and re-raises as
`PAYMENT.NOT_FOUND` so consumers can dispatch on a single code rather than
matching on substring messages.
- The `refundable` array (`["captured", "partially_refunded"]`) is declared
inline so adding a new refundable status is one-line. `VALID_TRANSITIONS`
encodes the broader machine; the array enforces the refund-specific
guard.
- `markFailed`, `openDispute`, and `closeDispute` are explicit emitters so
the event map can stay closed (no `[key: string]: unknown[]` escape
hatch). Consumers that need to surface those states call these methods
directly.

View File

@@ -0,0 +1,115 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable, namespaced error registry and discriminated `PaymentError` class for
the payment package. Every failure path in the engine, webhook verifier,
gateways, refund manager, tokenizer, and reconciler raises a `PaymentError`
carrying one of these codes so callers can dispatch programmatically without
parsing message strings. Built on `defineCodes("PAYMENT", […])` from
`@suaveplan/error` to keep the namespacing convention consistent with every
other package.
## Features
- `PAYMENT_CODES` — frozen registry with 13 namespaced codes.
- `PaymentErrorCode` — string union derived from the registry.
- `PaymentError extends SuaveplanError<PaymentErrorCode>` — discriminated
error class with optional `intentId` / `currentStatus` / `targetStatus` /
`ageMs` discriminants.
- 12 thin factory functions (`paymentNotFound`, `invalidTransition`,
`alreadyCaptured`, `alreadyRefunded`, `refundExceedsCaptured`,
`providerError`, `webhookInvalidSignature`, `webhookExpired`,
`webhookParseError`, `amountMismatch`, `invalidCard`, `gatewayError`,
`refundIntentNotRegistered`) — one per registry entry.
## Usage: Basic
```ts
import {
PAYMENT_CODES,
PaymentError,
paymentNotFound,
} from "@suaveplan/payment/error-codes/error-codes";
try {
throw paymentNotFound("pi_001");
} catch (err) {
if (err instanceof PaymentError && err.code === PAYMENT_CODES.NOT_FOUND) {
console.warn("intent missing", err.intentId);
}
}
```
## Usage: Advanced
```ts
import {
invalidTransition,
PAYMENT_CODES,
PaymentError,
webhookExpired,
} from "@suaveplan/payment/error-codes/error-codes";
function handle(err: unknown): void {
if (!(err instanceof PaymentError)) throw err;
switch (err.code) {
case PAYMENT_CODES.INVALID_TRANSITION:
console.warn("illegal transition", err.currentStatus, "→", err.targetStatus);
break;
case PAYMENT_CODES.WEBHOOK_EXPIRED:
console.warn("webhook age (ms)", err.ageMs);
break;
default:
throw err;
}
}
handle(invalidTransition("pi_002", "captured", "pending"));
handle(webhookExpired(420_000));
```
## API Reference
- `PAYMENT_CODES` — `Readonly<{ NOT_FOUND, INVALID_TRANSITION, ALREADY_CAPTURED,
ALREADY_REFUNDED, REFUND_EXCEEDS_CAPTURED, PROVIDER_ERROR,
WEBHOOK_INVALID_SIGNATURE, WEBHOOK_EXPIRED, WEBHOOK_PARSE_ERROR,
AMOUNT_MISMATCH, INVALID_CARD, GATEWAY_ERROR, REFUND_INTENT_NOT_REGISTERED }>`,
every value namespaced as `"PAYMENT.<KEY>"`.
- `PaymentErrorCode` — `(typeof PAYMENT_CODES)[keyof typeof PAYMENT_CODES]`.
- `PaymentErrorOptions` — `{ intentId?, currentStatus?, targetStatus?, ageMs?,
cause?, context?, clock?, traceId? }`.
- `PaymentError(code, message, options?)` — extends `SuaveplanError`; exposes
`code`, `message`, `cause`, `timestamp`, and the four discriminants.
- `paymentNotFound(intentId)` → `PAYMENT.NOT_FOUND`.
- `invalidTransition(id, current, target)` → `PAYMENT.INVALID_TRANSITION`.
- `alreadyCaptured(id)` → `PAYMENT.ALREADY_CAPTURED`.
- `alreadyRefunded(id)` → `PAYMENT.ALREADY_REFUNDED`.
- `refundExceedsCaptured(id)` → `PAYMENT.REFUND_EXCEEDS_CAPTURED`.
- `providerError(message, cause?)` → `PAYMENT.PROVIDER_ERROR`.
- `webhookInvalidSignature()` → `PAYMENT.WEBHOOK_INVALID_SIGNATURE`.
- `webhookExpired(ageMs)` → `PAYMENT.WEBHOOK_EXPIRED`, sets `err.ageMs`.
- `webhookParseError(cause?)` → `PAYMENT.WEBHOOK_PARSE_ERROR`.
- `amountMismatch(id)` → `PAYMENT.AMOUNT_MISMATCH`.
- `invalidCard(reason)` → `PAYMENT.INVALID_CARD`.
- `gatewayError(message, cause?)` → `PAYMENT.GATEWAY_ERROR`.
- `refundIntentNotRegistered(id)` → `PAYMENT.REFUND_INTENT_NOT_REGISTERED`.
## Implementation Notes
- `PAYMENT_CODES` is frozen by `defineCodes`; mutation throws under strict
mode and is impossible under structured-clone serialisation.
- `PaymentError` carries optional discriminants that callers can read without
consulting `err.context`. Each is conditionally assigned so reflection
helpers see `undefined` rather than enumerable `undefined` entries.
- `cause` is forwarded through to `SuaveplanError`, which forwards to the
underlying `Error.cause` mechanism — the full cause chain is preserved.
- No factory throws by itself; every factory returns a `PaymentError` so call
sites stay grep-able (`throw paymentNotFound(...)`).
- The registry intentionally namespaces every code with `PAYMENT.` even for
webhook-scoped failures; the suffix (`WEBHOOK_*`) carries the sub-category.
Consumers that route on category prefix can split on the second dot.

View File

@@ -0,0 +1,103 @@
# fraud
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Risk-scoring helper that flags suspicious payment attempts before they reach
the provider. Four built-in rules — velocity per customer, high-amount
anomaly, missing-customer-id heuristic, and first-seen payment method — sum
into a 0-100 score that the detector turns into an `allow` / `review` /
`block` recommendation. Every threshold is configurable and the time source
is injectable, so the detector is deterministic in tests.
## Features
- `FraudDetector.check(input)` — runs every rule and returns a typed result.
- Configurable thresholds for review (default 30) and block (default 70).
- Velocity check: bounded sliding window per customer (default 5 attempts
per hour).
- High-amount anomaly: `bigint` threshold (default 100,000 minor units).
- Missing-customer-id heuristic (default 15 points).
- New-payment-method heuristic (default 10 points, learns over time).
- Injected `now()` clock for reproducible velocity windows.
- `reset()` clears the velocity log and known-method memo.
## Usage: Basic
```ts
import { FraudDetector } from "@suaveplan/payment/fraud/fraud";
const detector = new FraudDetector();
const result = detector.check({
amount: { amount: 500n, currency: "USD" },
paymentMethod: { id: "pm_1", type: "card", last4: "4242" },
customerId: "cust_001",
});
console.log(result.recommendation, result.score);
```
## Usage: Advanced
```ts
import { FraudDetector } from "@suaveplan/payment/fraud/fraud";
let now = 0;
const detector = new FraudDetector({
reviewThreshold: 20,
blockThreshold: 60,
velocityMaxAttempts: 3,
velocityWindowMs: 60_000,
highAmountThreshold: 50_000n,
now: () => now,
});
for (let i = 0; i < 3; i++) {
now += 1_000;
detector.check({
amount: { amount: 1000n, currency: "USD" },
paymentMethod: { id: "pm_repeat", type: "card", last4: "4242" },
customerId: "cust_x",
});
}
now += 1_000;
const fourth = detector.check({
amount: { amount: 60_000n, currency: "USD" },
paymentMethod: { id: "pm_repeat", type: "card", last4: "4242" },
customerId: "cust_x",
});
console.log(fourth.recommendation); // "block" — velocity + high-amount
detector.reset();
```
## API Reference
- `FraudRecommendation` — `"allow" | "review" | "block"`.
- `FraudFlag` — `{ rule, description, score }`.
- `FraudCheckResult` — `{ score, flags, recommendation, checkedAt }`.
- `FraudCheckInput` — `{ amount, paymentMethod, customerId?, ip?, email?,
metadata? }`.
- `FraudDetectorConfig` — every rule weight + threshold + the injected
`now`.
- `new FraudDetector(config?)` → instance.
- `detector.check(input)` → `FraudCheckResult`.
- `detector.reset()` — clear velocity history and known-method memo.
## Implementation Notes
- The velocity log grows unbounded by design — the upstream contract trades
memory for simplicity. Long-running consumers call `reset()` periodically
(a real production deployment would swap in a sliding-window structure).
- Recommendation thresholds use `>=`: a score equal to `blockThreshold`
blocks; equal to `reviewThreshold` reviews. Tests in this package pin
every boundary.
- Score is capped at 100 — many rules firing simultaneously cannot push the
recommendation past `block`.
- The detector intentionally bills "missing customer" as a flag rather than
raising an error — caller's choice whether to refuse anonymous payments
outright. Calling code can inspect `result.flags` to act on individual
rules.
- `ip` / `email` / `metadata` are accepted on the input but not consumed by
any of the built-in rules; they are preserved on the input shape so
downstream rule extensions can use them without a contract break.

View File

@@ -0,0 +1,115 @@
# gateway/paypal
## Purpose
PayPal REST adapter for the abstract `PaymentGateway` contract. Talks to the
Orders API v2 via injected `fetch`, caches OAuth access tokens until 60s
before expiry to amortise the auth round-trip, and maps PayPal-specific
status values onto the canonical `GatewayChargeStatus` union. Universal
(no PayPal SDK) so the package keeps its runtime contract.
## Features
- `createCharge` posts to `/v2/checkout/orders` with `intent=CAPTURE` /
`AUTHORIZE` depending on `options.capture`.
- `captureCharge` posts to `/v2/checkout/orders/{id}/capture`, optionally
supplying a partial amount.
- `refundCharge` reads the order, locates the first capture id, then posts
to `/v2/payments/captures/{id}/refund` with optional partial amount.
- `getCharge` retrieves the order and sums capture / refund totals into a
normalised `GatewayCharge`.
- `validatePaymentMethod` performs basic structural validation.
- Token cache uses the injected `now` source so test transcripts stay
deterministic.
- Injectable `fetch` + `now` + `btoa` for the universal promise: the
default uses the runtime globals.
## Usage: Basic
```ts
import { PayPalGateway } from "@suaveplan/payment/gateway/paypal";
const gateway = new PayPalGateway({
clientId: process.env.PP_CLIENT_ID!,
clientSecret: process.env.PP_CLIENT_SECRET!,
sandbox: true,
});
const result = await gateway.createCharge(
{ amount: 1000n, currency: "USD" },
{ id: "pm_wallet", type: "wallet" }
);
console.log(result.chargeId, result.status);
```
## Usage: Advanced
```ts
import { PayPalGateway } from "@suaveplan/payment/gateway/paypal";
let currentTime = 1_700_000_000_000;
const transcript: Array<{ url: string; init?: RequestInit }> = [];
const fakeFetch = async (url: string, init?: RequestInit): Promise<Response> => {
transcript.push({ url, init });
if (url.endsWith("/v1/oauth2/token")) {
return new Response(
JSON.stringify({ access_token: "tok_x", token_type: "Bearer", expires_in: 3600 }),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
return new Response(
JSON.stringify({
id: "ord_1",
status: "COMPLETED",
purchase_units: [{ amount: { currency_code: "USD", value: "10.00" } }],
create_time: "2026-05-17T00:00:00Z",
update_time: "2026-05-17T00:00:01Z",
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const gateway = new PayPalGateway({
clientId: "test",
clientSecret: "secret",
sandbox: true,
fetch: fakeFetch,
now: () => currentTime,
btoa: (raw) => Buffer.from(raw, "utf8").toString("base64"),
});
const result = await gateway.createCharge(
{ amount: 1000n, currency: "USD" },
{ id: "pm_wallet", type: "wallet" }
);
console.log(transcript.length, result.status);
```
## API Reference
- `PayPalGatewayConfig` — `{ clientId, clientSecret, sandbox?, baseUrl?,
timeout?, now?, fetch?, btoa? }`.
- `new PayPalGateway(config)` → `PaymentGateway` implementation.
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
`refundCharge`, `getCharge`, `validatePaymentMethod`.
## Implementation Notes
- The token cache stores `accessToken` + `tokenExpiresAt`; the next request
re-uses the token until 60s before its declared expiry, at which point a
refresh round-trip is automatic.
- The frozen `STATUS_MAP` translates PayPal's vocabulary
(`CREATED`/`SAVED`/`APPROVED`/`VOIDED`/`COMPLETED`/
`PAYER_ACTION_REQUIRED`) into the canonical `GatewayChargeStatus`. Unknown
statuses map to `"failed"`.
- Amounts are formatted as `"<dollars>.<cents>"` via
`formatAmount(money) → (Number(amount) / 100).toFixed(2)`; PayPal requires
the decimal-string format on the wire.
- `refundCharge` looks up the order's first capture id to issue the refund
against. When no capture exists (e.g. authorise-only flow), a `failed`
result is returned with `errorCode = "NO_CAPTURE"`.
- Auth and request failures both raise `PaymentError(PAYMENT.GATEWAY_ERROR)`
with the response status and body in the message.
- Empty response bodies are tolerated — `request` returns an empty object
cast as `R` when the body is empty (e.g. 204 No Content).
- `btoa` is injected because it has different semantics across runtimes
(browser-native; Node 16+ ships it; Bun ships it). Tests that need to
enforce a specific encoding pass `Buffer.from(...).toString("base64")`.

View File

@@ -0,0 +1,113 @@
# gateway/stripe
## Purpose
Stripe REST adapter for the abstract `PaymentGateway` contract. Talks to the
Payment Intents API directly via injected `fetch` so no Stripe SDK ships in
the bundle — keeps `@suaveplan/payment` universal and lets every test
drive deterministic transcripts without real HTTP. Maps Stripe-specific
status values onto the canonical `GatewayChargeStatus` union.
## Features
- `createCharge` posts to `/v1/payment_intents` with `confirm=true`, honours
`capture` (manual vs automatic), forwards `description`, `customerId`, and
arbitrary `metadata[…]` entries, and threads `Idempotency-Key` through the
HTTP header when supplied.
- `captureCharge` posts to `/v1/payment_intents/{id}/capture` with an
optional `amount_to_capture` for partial captures.
- `refundCharge` posts to `/v1/refunds` keyed by the payment-intent id with
optional `amount` and `reason`.
- `getCharge` retrieves the intent, sums all refund amounts across the
underlying charges, and returns a normalised `GatewayCharge`.
- `validatePaymentMethod` performs static card validation (last4, expiry
month / year, future expiry) without network calls.
- Wall-clock-based `AbortSignal.timeout` enforces the request budget.
- Failures raise `PaymentError(PAYMENT.GATEWAY_ERROR)` with the response
body and status preserved in the message.
## Usage: Basic
```ts
import { StripeGateway } from "@suaveplan/payment/gateway/stripe";
const gateway = new StripeGateway({ secretKey: process.env.STRIPE_KEY! });
const result = await gateway.createCharge(
{ amount: 1000n, currency: "USD" },
{ id: "pm_test", type: "card", last4: "4242" }
);
console.log(result.chargeId, result.status);
```
## Usage: Advanced
```ts
import {
type FetchLike,
StripeGateway,
} from "@suaveplan/payment/gateway/stripe";
const transcript: Array<{ url: string; init?: RequestInit }> = [];
const fakeFetch: FetchLike = async (url, init) => {
transcript.push({ url, init });
if (url.endsWith("/v1/payment_intents")) {
return new Response(
JSON.stringify({
id: "pi_001",
status: "succeeded",
amount: 1000,
amount_received: 1000,
currency: "usd",
created: 1_700_000_000,
charges: { data: [{ amount_refunded: 0 }] },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
return new Response("not found", { status: 404 });
};
const gateway = new StripeGateway({
secretKey: "sk_test",
apiVersion: "2024-12-18.acacia",
timeout: 5_000,
fetch: fakeFetch,
});
const charge = await gateway.createCharge(
{ amount: 1000n, currency: "USD" },
{ id: "pm_test", type: "card", last4: "4242" },
{ idempotencyKey: "ord_42", metadata: { source: "stage" } }
);
console.log(transcript.length, charge.status);
```
## API Reference
- `FetchLike` — `(input: string, init?: RequestInit) => Promise<Response>`.
- `StripeGatewayConfig` — `{ secretKey, baseUrl?, apiVersion?, timeout?,
fetch? }`.
- `new StripeGateway(config)` → `PaymentGateway` implementation.
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
`refundCharge`, `getCharge`, `validatePaymentMethod`.
## Implementation Notes
- The injected `fetch` defaults to the global `fetch` — universal in Node 18+,
Bun, browsers, and Web Workers.
- Stripe status mapping uses a frozen lookup table; unknown values map to
`"failed"` (`mapStripeStatus`).
- Non-2xx responses raise `gatewayError(...)` with the response status and
body; the caller's invariant — that adapter errors surface as
`PaymentError` — is preserved.
- `GET` requests fold the URLSearchParams into the URL when populated;
`POST` requests serialise the params into the body and set
`Content-Type: application/x-www-form-urlencoded`.
- The default `apiVersion` is `"2024-12-18.acacia"`; pinning the value in the
`Stripe-Version` header avoids surprise breakage when Stripe ships a new
default API version.
- `validatePaymentMethod` is purely client-side — it does not hit Stripe.
Real method tokens come from Stripe.js / Elements; the validation here
catches malformed inputs before they hit the network.
- `refundCharge` defaults the currency to `"USD"` when only `refund.amount`
is available on the response; callers that need exact currency pass an
explicit `amount` argument.

View File

@@ -0,0 +1,91 @@
# gateway/test
## Purpose
In-memory `PaymentGateway` implementation used by tests, fixtures, and local
development. Records every operation in chronological order so assertions can
verify the engine called the gateway with the expected arguments, supports
configurable failure modes for negative-path tests, and tracks stored charges
in a `Map` so refunds and captures behave realistically against earlier
creates.
## Features
- Full `PaymentGateway` implementation — `createCharge`, `captureCharge`,
`refundCharge`, `getCharge`, `validatePaymentMethod`.
- Operation log (`gateway.operations`) — readonly array of recorded calls.
- Configurable failure mode (`shouldFail` + `failureCode` + `failureMessage`).
- Configurable artificial delay (`delayMs`) to exercise async handling.
- Injectable `now()` clock so timestamps stay reproducible in tests.
- `resetChargeCounter()` resets the module-level sequence so charge ids are
stable across runs.
## Usage: Basic
```ts
import { TestGateway } from "@suaveplan/payment/gateway/test";
const gateway = new TestGateway();
const result = await gateway.createCharge(
{ amount: 5000n, currency: "USD" },
{ id: "pm_test", type: "card", last4: "4242" }
);
console.log(result.status, gateway.chargeCount);
```
## Usage: Advanced
```ts
import { resetChargeCounter, TestGateway } from "@suaveplan/payment/gateway/test";
resetChargeCounter();
const gateway = new TestGateway({
shouldFail: false,
delayMs: 0,
now: () => 1_700_000_000_000,
});
const created = await gateway.createCharge(
{ amount: 2000n, currency: "EUR" },
{ id: "pm_card", type: "card", last4: "0004", expiryMonth: 12, expiryYear: 2030 },
{ capture: false, metadata: { source: "stage" } }
);
const captured = await gateway.captureCharge(created.chargeId);
const refunded = await gateway.refundCharge(created.chargeId, {
amount: 500n,
currency: "EUR",
});
console.log(captured.status, refunded.status, gateway.operations.length);
gateway.clearOperations();
```
## API Reference
- `GatewayOperation` — `{ type, chargeId?, amount?, timestamp }`.
- `TestGatewayConfig` — `{ shouldFail?, failureMessage?, failureCode?,
delayMs?, now? }`.
- `new TestGateway(config?)` → instance.
- `gateway.operations` → readonly log.
- `gateway.chargeCount` → number of stored charges.
- `gateway.clearOperations()` → reset the log.
- `resetChargeCounter()` → reset the module-level counter.
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
`refundCharge`, `getCharge`, `validatePaymentMethod`.
## Implementation Notes
- The gateway stores charges in a private `Map<string, StoredCharge>` keyed
by the generated charge id. Refund and capture mutate the stored entry
rather than replacing it so subsequent `getCharge` calls reflect the
latest balances.
- `shouldFail` causes every operation to return a `success: false`
`GatewayResult`. The charge id is empty for the create case and echoed
back for capture / refund so callers can correlate.
- Validation rejects card methods missing `last4` or expiry, and treats
past expiries as invalid — reproducing the behaviour callers expect from
a real adapter.
- `getCharge` throws a plain `Error` (not a `PaymentError`) on missing
charges so the gateway behaves like a raw provider; consumers compose
`paymentNotFound` themselves when promoting that failure.
- `now` is invoked once per recorded operation so the operation log and
charge timestamps stay aligned even under a frozen clock.

View File

@@ -0,0 +1,123 @@
# gateway/types
## Purpose
Defines the abstract `PaymentGateway` contract every concrete adapter (the
in-memory `TestGateway`, the Stripe REST adapter, the PayPal REST adapter, or
caller-provided implementations) must satisfy. Centralising the contract here
keeps the engine, retry, and reconciliation modules agnostic to which gateway
backs them and ensures every adapter speaks the same status / result /
validation vocabulary.
## Features
- `PaymentGateway` interface — five async methods covering create, capture,
refund, get, and validate.
- `ChargeOptions` — caller-supplied `description`, `customerId`,
`capture`, `metadata`, and `idempotencyKey` passthrough.
- `GatewayResult` — `{ success, chargeId, status, amount, errorCode?,
errorMessage?, metadata?, createdAt }`.
- `GatewayCharge` — full charge snapshot retrieved from the gateway.
- `GatewayChargeStatus` union — seven canonical states the gateway can
report, mapped per-adapter from provider-specific vocabulary.
- `ValidationResult` — `{ valid, errors }`.
## Usage: Basic
```ts
import type { PaymentGateway } from "@suaveplan/payment/gateway/types";
declare const gateway: PaymentGateway;
const result = await gateway.createCharge(
{ amount: 1000n, currency: "USD" },
{ id: "pm_test", type: "card", last4: "4242" }
);
console.log(result.status, result.chargeId);
```
## Usage: Advanced
```ts
import type {
GatewayCharge,
GatewayResult,
PaymentGateway,
ValidationResult,
} from "@suaveplan/payment/gateway/types";
import type { MoneyLike } from "@suaveplan/money";
import type { PaymentMethod } from "@suaveplan/payment/types/payment";
class NoopGateway implements PaymentGateway {
readonly name = "noop";
async createCharge(
amount: MoneyLike,
_method: PaymentMethod
): Promise<GatewayResult> {
return {
success: true,
chargeId: "ch_noop",
status: "captured",
amount,
createdAt: new Date().toISOString(),
};
}
async captureCharge(chargeId: string): Promise<GatewayResult> {
return {
success: true,
chargeId,
status: "captured",
amount: { amount: 0n, currency: "USD" },
createdAt: new Date().toISOString(),
};
}
async refundCharge(chargeId: string): Promise<GatewayResult> {
return {
success: true,
chargeId,
status: "refunded",
amount: { amount: 0n, currency: "USD" },
createdAt: new Date().toISOString(),
};
}
async getCharge(chargeId: string): Promise<GatewayCharge> {
return {
chargeId,
status: "captured",
amount: { amount: 0n, currency: "USD" },
capturedAmount: { amount: 0n, currency: "USD" },
refundedAmount: { amount: 0n, currency: "USD" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async validatePaymentMethod(): Promise<ValidationResult> {
return { valid: true, errors: [] };
}
}
```
## API Reference
- `PaymentGateway` — `{ name, createCharge, captureCharge, refundCharge,
getCharge, validatePaymentMethod }`.
- `ChargeOptions` — `{ description?, customerId?, capture?, metadata?,
idempotencyKey? }`.
- `GatewayChargeStatus` — `pending | authorized | captured | refunded |
partially_refunded | failed | canceled`.
- `GatewayResult` — operation result.
- `GatewayCharge` — full charge snapshot.
- `ValidationResult` — `{ valid, errors }`.
## Implementation Notes
- `createCharge` accepts an optional `paymentMethod` even when the gateway
doesn't need one (e.g. PayPal Orders) so the engine and adapter signatures
align.
- `captureCharge(amount)` allows partial capture — adapters that don't
support it must ignore the parameter and capture the full authorised
amount (Stripe behaviour); adapters that do support it honour the value.
- `refundCharge(amount, reason)` likewise allows partial refunds; missing
arguments mean "full refund".
- `errorCode` and `errorMessage` only appear on `success: false` results.
- The interface is intentionally narrow — gateway-specific webhook handling
lives in `webhook/`, not here, so adapters stay easy to swap.

View File

@@ -0,0 +1,108 @@
# hooks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tiny generic hooks bus owned by the payment package. It powers the
`payment:processing` pipeline that fires inside `PaymentEngine.createIntent`
before the input reaches the provider, giving callers a place to enrich,
normalise, or validate intent data without monkey-patching the engine. A
self-contained primitive (no `@suaveplan/events/hooks` dependency exists on
suaveplan) so the payment package stays universal and additively
extensible.
## Features
- Generic `Hooks<TMap extends Record<string, unknown>>` class — pluggable map
of keys → data envelopes.
- Synchronous registration, async sequential application.
- Priority-ordered execution (lower priority runs earlier; default `0`).
- Unsubscribe handle returned from every `hook(...)` call.
- `clear(key)` to drop every handler for a key; `count(key)` for diagnostics.
- Singleton `paymentHooks` + `onPaymentProcessing` shorthand for the canonical
`payment:processing` pipeline.
## Usage: Basic
```ts
import { onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
const unsubscribe = onPaymentProcessing((data) => ({
...data,
input: {
...data.input,
metadata: { ...data.input.metadata, source: "webapp" },
},
}));
// later, when shutting down a request:
unsubscribe();
```
## Usage: Advanced
```ts
import { Hooks, onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
// Use the generic class for a custom map outside the singleton.
interface MyMap extends Record<string, unknown> {
"refund:rerouting": { customerId: string; reroute: boolean };
}
const refundHooks = new Hooks<MyMap>();
refundHooks.hook("refund:rerouting", async (data) => {
const reroute = await isHighValue(data.customerId);
return { ...data, reroute };
});
// Composition: stack two ordered handlers on the singleton.
onPaymentProcessing(
(data) => ({
...data,
input: {
...data.input,
description: data.input.description?.trim(),
},
}),
{ priority: -10 } // runs before any default-priority handler
);
onPaymentProcessing((data) => ({
...data,
metadata: { ...data.metadata, normalised: true },
}));
declare function isHighValue(id: string): Promise<boolean>;
```
## API Reference
- `PaymentProcessingHookData` — `{ input: CreatePaymentIntentInput, metadata:
Record<string, unknown> }`.
- `PaymentHookMap` — `{ "payment:processing": PaymentProcessingHookData }`.
- `HookHandler<TData>` — `(data: TData) => TData | Promise<TData>`.
- `HookHandlerOptions` — `{ priority?: number }`.
- `Hooks<TMap>.hook(key, handler, options?)` → unsubscribe function.
- `Hooks<TMap>.applyHooksAsync(key, data)` → resolves to the data envelope
after every handler has run in priority order. With zero registered
handlers, returns `data` unchanged.
- `Hooks<TMap>.clear(key)` → drops every handler for `key`.
- `Hooks<TMap>.count(key)` → number of handlers.
- `paymentHooks` — pre-instantiated `Hooks<PaymentHookMap>` singleton.
- `onPaymentProcessing(handler, options?)` → shorthand wrapper.
## Implementation Notes
- Handlers run sequentially, not in parallel: the contract is "each handler
sees the output of the previous one". This matches a transformation
pipeline far better than fire-and-forget pub/sub.
- The `priority` field is stable-sorted; handlers with equal priority retain
registration order.
- Returning a Promise from a handler is fully supported; the bus awaits it
before invoking the next handler.
- Removing a handler during execution: the snapshot is taken via the array
iterator at call-time; mid-flight `unsubscribe()` calls take effect on the
next `applyHooksAsync` invocation.
- The `Hooks` class is intentionally minimal — no built-in error envelope, no
per-handler timeout. Callers needing those features wrap their handler.

View File

@@ -0,0 +1,126 @@
# receipt
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generates structured `Receipt` objects from `PaymentIntent` plus caller-
supplied line items, then provides a minimal plain-text renderer for log
trails, email fallbacks, and CLI surfaces. Receipts are pure data — HTML /
PDF / locale-aware money formatting are left to consumers so the package
stays universal.
## Features
- `ReceiptGenerator` class with injected `merchantName`, address, contact,
logo, footer, and tax label.
- Injectable `now` clock and `nextId` generator for deterministic tests.
- `generate(input)` — builds a `Receipt` carrying merchant identity,
customer details, line items, subtotal / tax / total, captured / refunded
totals, the attached payment method, the refund history, and the receipt
creation timestamp.
- `toPlainText(receipt)` — human-readable rendering with the merchant
header, line items, totals, payment method last-four, and footer.
- `resetReceiptCounter()` — reset the module-level sequence counter; used
by tests to keep receipt ids stable across runs.
## Usage: Basic
```ts
import {
ReceiptGenerator,
type ReceiptLineItem,
} from "@suaveplan/payment/receipt/receipt";
import type { PaymentIntent } from "@suaveplan/payment/types/payment";
declare const captured: PaymentIntent;
const generator = new ReceiptGenerator({ merchantName: "Acme Store" });
const lineItems: ReceiptLineItem[] = [
{
description: "Widget",
quantity: 2,
unitPrice: { amount: 1500n, currency: "USD" },
total: { amount: 3000n, currency: "USD" },
},
];
const receipt = generator.generate({
intent: captured,
lineItems,
subtotal: { amount: 3000n, currency: "USD" },
total: { amount: 3000n, currency: "USD" },
});
console.log(generator.toPlainText(receipt));
```
## Usage: Advanced
```ts
import {
ReceiptGenerator,
resetReceiptCounter,
} from "@suaveplan/payment/receipt/receipt";
import type { PaymentIntent } from "@suaveplan/payment/types/payment";
declare const intent: PaymentIntent;
resetReceiptCounter();
let seq = 0;
const generator = new ReceiptGenerator({
merchantName: "Acme Store",
merchantAddress: "123 Main St",
merchantEmail: "billing@acme.com",
footerText: "Thank you for your purchase!",
taxLabel: "VAT",
now: () => 1_700_000_000_000,
nextId: () => `rcpt_${(++seq).toString().padStart(6, "0")}`,
});
const receipt = generator.generate({
intent,
lineItems: [
{
description: "Widget",
quantity: 1,
unitPrice: { amount: 1000n, currency: "EUR" },
total: { amount: 1000n, currency: "EUR" },
},
],
subtotal: { amount: 1000n, currency: "EUR" },
tax: { amount: 200n, currency: "EUR" },
total: { amount: 1200n, currency: "EUR" },
customerName: "Jane Doe",
});
const text = generator.toPlainText(receipt);
```
## API Reference
- `ReceiptLineItem` — `{ description, quantity, unitPrice, total }`.
- `ReceiptConfig` — merchant identity + presentation overrides +
`now`/`nextId` injectables.
- `Receipt` — structured receipt record returned by `generate`.
- `GenerateReceiptInput` — `{ intent, lineItems, subtotal, tax?, total,
customerName?, customerEmail? }`.
- `new ReceiptGenerator(config)` → instance.
- `generator.generate(input)` → `Receipt`.
- `generator.toPlainText(receipt)` → multi-line string.
- `resetReceiptCounter()` — module-level reset of the default sequence.
## Implementation Notes
- The default id generator (`defaultGenerateReceiptId`) emits
`rcpt_<timestamp36>_<seq36>` — collision-resistant within a single process,
not cryptographically random. Callers needing UUIDs pass `nextId`.
- The plain-text renderer concatenates with `"\n"` (not `os.EOL`) to stay
universal — consumers can normalise per-platform as needed.
- `amountRefunded.amount > 0n` gates the "Refunded:" line so unrefunded
receipts read cleanly without a trailing "Refunded: 0" line.
- `taxLabel` falls back to `"Tax"` only when `receipt.tax` is present and
`taxLabel` is undefined — both at the receipt level and the config level
resolve here.
- `paymentMethod` rendering prefers `brand` over `type` when both are
present and `last4` is known; otherwise it falls back to the bare type.
- The injected `now` source is invoked once per `generate` call; receipts
generated in a tight loop with a frozen clock all share the same
`createdAt` ISO string, which is the desired behaviour for deterministic
golden-file tests.

View File

@@ -0,0 +1,145 @@
# reconciliation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reconciles a batch of internal payment records against gateway transaction
records, matches them by id, surfaces amount / currency / status
discrepancies, and emits a structured report that downstream tooling
(dashboards, alerts, finance exports) can act on. Pure function over the two
input arrays — no state is retained between calls — so reconciliation runs
safely from any worker.
## Features
- `PaymentReconciliation.reconcile(internal, gateway)` →
`ReconciliationReport`.
- Matches internal records to gateway records via `gatewayChargeId` ↔
`chargeId`.
- Three entry statuses: `"matched"`, `"unmatched"` (either side), and
`"discrepancy"` (matched but fields disagree).
- Discrepancy detection covers amount, currency, and normalised status
(lower-case + dashes/underscores stripped — so `"PARTIALLY_REFUNDED"` and
`"partially-refunded"` are treated as equal).
- Counts every category in the report header so callers can branch on the
summary without re-walking the entries.
- Injectable `now()` clock so report timestamps are deterministic.
## Usage: Basic
```ts
import { PaymentReconciliation } from "@suaveplan/payment/reconciliation/reconciliation";
const reconciliation = new PaymentReconciliation();
const report = reconciliation.reconcile(
[
{
id: "pi_001",
gatewayChargeId: "ch_001",
amount: { amount: 1000n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
},
],
[
{
chargeId: "ch_001",
amount: { amount: 1000n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
},
]
);
console.log(report.matchedCount, report.discrepancyCount);
```
## Usage: Advanced
```ts
import { PaymentReconciliation } from "@suaveplan/payment/reconciliation/reconciliation";
const reconciliation = new PaymentReconciliation({
now: () => 1_700_000_000_000,
});
const report = reconciliation.reconcile(
[
{
id: "pi_001",
amount: { amount: 100n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
}, // no gateway id → unmatched
{
id: "pi_002",
gatewayChargeId: "ch_002",
amount: { amount: 200n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
}, // discrepancy: gateway says 199
{
id: "pi_003",
gatewayChargeId: "ch_999",
amount: { amount: 300n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
}, // unmatched: gateway has no ch_999
],
[
{
chargeId: "ch_002",
amount: { amount: 199n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
},
{
chargeId: "ch_orphan",
amount: { amount: 400n, currency: "USD" },
status: "captured",
createdAt: "2026-05-17T00:00:00Z",
}, // orphan: no internal record
]
);
console.log(
report.matchedCount,
report.unmatchedInternalCount,
report.unmatchedGatewayCount,
report.discrepancyCount
);
```
## API Reference
- `ReconciliationStatus` — `"matched" | "unmatched" | "discrepancy"`.
- `InternalRecord` — `{ id, gatewayChargeId?, amount, status, createdAt }`.
- `GatewayRecord` — `{ chargeId, amount, status, createdAt }`.
- `ReconciliationEntry` — `{ status, internalRecord?, gatewayRecord?,
discrepancies }`.
- `ReconciliationReport` — `{ matchedCount, unmatchedInternalCount,
unmatchedGatewayCount, discrepancyCount, entries, reconciledAt }`.
- `PaymentReconciliationConfig` — `{ now? }`.
- `new PaymentReconciliation(config?)` → instance.
- `reconcile(internal, gateway)` → `ReconciliationReport`.
## Implementation Notes
- The orphan-gateway pass uses a `Set<string>` of matched-on-input charge
ids to skip already-paired records — O(n+m) instead of O(n×m).
- Status normalisation is intentionally aggressive — finance teams rarely
care whether a gateway uses `"partially_refunded"` versus
`"partially-refunded"`; the reconciliation should not flag that as a
discrepancy.
- The matcher pairs by charge id only — if two internal records share a
`gatewayChargeId` (which should be a data integrity violation),
both will be flagged as discrepancies (or matched, if the gateway
record agrees) and the latter call to `matchedGatewayIds.add(id)` is a
no-op.
- An internal record without `gatewayChargeId` produces an "unmatched"
entry with a synthetic discrepancy string so reports stay self-
describing.
- Currency is part of the discrepancy check — a USD ↔ EUR mismatch with
the same amount value is flagged separately from the amount mismatch
so finance can route the alert correctly.
- `reconciledAt` is taken once per call from the injected clock.

View File

@@ -0,0 +1,120 @@
# refund
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks per-intent refund history with balance validation, partial-refund
support, and reason categorisation. The `RefundManager` is engine-agnostic
— pair it with the `PaymentEngine` (which already enforces refund balance
against provider-reported totals) when you need an in-process ledger for
analytics, dispute prep, or summary reporting.
## Features
- `registerCapture` seeds the in-memory record for an intent.
- `createRefund` raises `PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)` when
the amount would exceed the remaining balance, and
`PaymentError(PAYMENT.REFUND_INTENT_NOT_REGISTERED)` when the intent was
never seeded.
- `getSummary` returns a `RefundSummary` with `capturedAmount`,
`totalRefunded`, `remainingBalance`, `refundCount`, the refund list, and a
`fullyRefunded` flag.
- `getRefundsByReason` filters by `RefundReason`.
- `canRefund` predicate — true when the proposed amount fits remaining.
- Injectable `now` clock and `nextId` generator.
- `resetRefundCounter()` resets the module-level sequence for deterministic
tests.
## Usage: Basic
```ts
import { RefundManager } from "@suaveplan/payment/refund/refund";
const manager = new RefundManager();
manager.registerCapture({
intentId: "pi_001",
capturedAmount: { amount: 5000n, currency: "USD" },
});
const refund = manager.createRefund({
intentId: "pi_001",
amount: { amount: 2000n, currency: "USD" },
reason: "requested_by_customer",
});
console.log(refund.id, manager.getSummary("pi_001"));
```
## Usage: Advanced
```ts
import {
RefundManager,
resetRefundCounter,
} from "@suaveplan/payment/refund/refund";
import {
PAYMENT_CODES,
PaymentError,
} from "@suaveplan/payment/error-codes/error-codes";
resetRefundCounter();
let seq = 0;
const manager = new RefundManager({
now: () => 1_700_000_000_000,
nextId: () => `ref_${(++seq).toString().padStart(6, "0")}`,
});
manager.registerCapture({
intentId: "pi_002",
capturedAmount: { amount: 10_000n, currency: "EUR" },
});
manager.createRefund({
intentId: "pi_002",
amount: { amount: 4_000n, currency: "EUR" },
reason: "duplicate",
});
try {
manager.createRefund({
intentId: "pi_002",
amount: { amount: 7_000n, currency: "EUR" },
});
} catch (err) {
if (
err instanceof PaymentError &&
err.code === PAYMENT_CODES.REFUND_EXCEEDS_CAPTURED
) {
console.warn("would exceed remaining balance");
}
}
console.log(manager.canRefund("pi_002", { amount: 6_000n, currency: "EUR" }));
console.log(manager.getRefundsByReason("pi_002", "duplicate").length);
```
## API Reference
- `RefundSummary` — `{ intentId, capturedAmount, totalRefunded,
remainingBalance, refundCount, refunds, fullyRefunded }`.
- `CreateRefundInput` — `{ intentId, amount, reason?, metadata? }`.
- `RegisterCaptureInput` — `{ intentId, capturedAmount }`.
- `RefundManagerConfig` — `{ now?, nextId? }`.
- `new RefundManager(config?)` → instance.
- `registerCapture(input)` → `void`.
- `createRefund(input)` → `Refund` (or throws typed `PaymentError`).
- `getSummary(intentId)` → `RefundSummary | undefined`.
- `getRefundsByReason(intentId, reason)` → `readonly Refund[]`.
- `canRefund(intentId, amount)` → `boolean`.
- `resetRefundCounter()` — module-level sequence reset.
## Implementation Notes
- The internal `Map` is keyed by intent id — multiple calls to
`registerCapture` for the same intent replace the previous record.
- `getSummary` returns a defensive copy of the refund list so callers
cannot mutate the manager's internal state.
- `getRefundsByReason` returns an empty array (not `undefined`) for unknown
intents so callers can use the result with `.length` / `.filter` without
null checks.
- `canRefund` uses `<=` so a refund that exactly drains the remaining
balance is accepted; the resulting record becomes `fullyRefunded`.
- The injected `now` is invoked once per `createRefund` call so all refund
timestamps stay consistent under a frozen clock.

View File

@@ -0,0 +1,99 @@
# retry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generic retry helper for payment operations. Wraps any async function with
exponential backoff + jitter, honours a non-retryable error-code blocklist
matched against thrown-error message text or extracted-from-result codes,
and reports an envelope (`RetryResult<T>`) with `success`, `attempts`, the
collected `errors`, and total wall time. The RNG and sleep functions are
injectable so retry tests are deterministic.
## Features
- Exponential backoff (`baseDelayMs * 2^(attempt - 1)`) capped at
`maxDelayMs` with `[-jitter, +jitter]` * capped jitter range.
- Non-retryable code blocklist (default 5 Stripe-style codes —
`card_declined`, `expired_card`, `invalid_number`, `fraudulent`,
`authentication_required`) — both for thrown error messages and
extracted-from-result codes via the caller-supplied `extractCode` hook.
- `isSuccess` predicate decoupled from the underlying operation so callers
can plug in any "good enough" test (e.g.
`result => result.success === true`).
- Injectable RNG (`random`) so backoff jitter is reproducible.
- Injectable sleep (`sleep`) so tests advance the clock without real timers.
- `calculateDelay(attempt)` exposed for callers that want to log expected
delays or wire their own scheduler.
## Usage: Basic
```ts
import { PaymentRetrier } from "@suaveplan/payment/retry/retry";
const retrier = new PaymentRetrier({ maxAttempts: 3 });
const result = await retrier.execute(
() => fetch("/charge", { method: "POST" }).then((r) => r.json()),
(res: { success: boolean }) => res.success === true
);
console.log(result.success, result.attempts);
```
## Usage: Advanced
```ts
import { PaymentRetrier } from "@suaveplan/payment/retry/retry";
const retrier = new PaymentRetrier({
maxAttempts: 4,
baseDelayMs: 100,
maxDelayMs: 2_000,
jitter: 0.5,
nonRetryableCodes: ["card_declined", "do_not_honor"],
random: () => 0.5, // deterministic jitter
sleep: async () => {}, // no-op delays under test
});
let attempts = 0;
const result = await retrier.execute(
async () => {
attempts += 1;
if (attempts < 3) {
return { success: false, errorCode: "transient" } as const;
}
return { success: true, errorCode: undefined } as const;
},
(r) => r.success,
(r) => r.errorCode
);
console.log(result.success, result.attempts, retrier.calculateDelay(1));
```
## API Reference
- `PaymentRetryConfig` — `{ maxAttempts?, baseDelayMs?, maxDelayMs?,
jitter?, nonRetryableCodes?, random?, sleep? }`.
- `RetryResult<T>` — `{ result?, success, attempts, errors, durationMs }`.
- `new PaymentRetrier(config?)` → instance.
- `retrier.execute(operation, isSuccess, extractCode?)` → `Promise<RetryResult<T>>`.
- `retrier.calculateDelay(attempt)` → number.
## Implementation Notes
- The non-retryable blocklist is matched by `message.toLowerCase().includes(code)`
for thrown `Error`s, and by exact equality for codes returned by
`extractCode`. Both paths short-circuit further retries.
- `errors` accumulates every failure across attempts — successful results
return `errors: []` only if no prior attempt failed.
- The delay calculation uses `random() * 2 - 1` to centre the jitter at the
capped delay; with the default `0.25` jitter, actual delays span ~75% to
~125% of the capped value.
- `Date.now()` provides wall-clock measurement for `durationMs`; under a
no-op `sleep` injection the duration reflects only the operation work
itself, which is the desired behaviour for tests.
- The retrier deliberately makes no assumption about thread-safety — it is
fine to share an instance across requests as long as the underlying
operation and predicate are themselves reentrant.

View File

@@ -0,0 +1,125 @@
# tax
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory tax-rate registry and line-item calculator. Supports tax-inclusive
(price-includes-tax) and tax-exclusive (price-excludes-tax) pricing, falls
back through region → country → default when looking up rates, and produces a
typed `TaxCalculationResult` that downstream renderers (receipts, checkout
summaries) consume directly. Universal and instance-scoped — no module
state.
## Features
- `addRate` / `removeRate` / `getRate` over a `(country, region?)` key.
- `calculate(items, jurisdiction)` produces both per-line and aggregate
results carrying subtotal, tax amount, total, and the resolved tax-rate
metadata.
- Tax-inclusive mode subtracts the embedded tax portion from the line
total via `total * (rate / (1 + rate))`.
- Tax-exclusive mode adds `Math.round(amount * rate)` on top.
- Tax-exempt items are surfaced with a synthetic `"Exempt"` tax name and
zero tax amount.
- Default rate fallback (configurable on construction) when no
jurisdiction is registered.
## Usage: Basic
```ts
import { TaxCalculator } from "@suaveplan/payment/tax/tax";
const calc = new TaxCalculator();
calc.addRate({ country: "DE", rate: 0.19, name: "VAT" });
const result = calc.calculate(
[
{
description: "Widget",
amount: { amount: 1000n, currency: "EUR" },
quantity: 2,
},
],
{ country: "DE" }
);
console.log(result.totalTax, result.total);
```
## Usage: Advanced
```ts
import { TaxCalculator } from "@suaveplan/payment/tax/tax";
const calc = new TaxCalculator({
country: "DEFAULT",
rate: 0,
name: "No Tax",
});
calc.addRate({ country: "US", rate: 0, name: "No Federal Tax" });
calc.addRate({
country: "US",
region: "CA",
rate: 0.0725,
name: "CA Sales Tax",
});
calc.addRate({
country: "FR",
rate: 0.2,
name: "VAT-Inclusive",
inclusive: true,
});
const taxableFR = calc.calculate(
[
{
description: "Service",
amount: { amount: 12_000n, currency: "EUR" },
quantity: 1,
},
{
description: "Free voucher",
amount: { amount: 0n, currency: "EUR" },
quantity: 1,
taxExempt: true,
},
],
{ country: "FR" }
);
console.log(taxableFR.lineItems[0]?.taxAmount, taxableFR.inclusive);
console.log(calc.removeRate("US", "CA"));
```
## API Reference
- `TaxRate` — `{ country, region?, rate, name, inclusive? }`.
- `TaxLineItem` — `{ description, amount, quantity, taxExempt? }`.
- `TaxLineResult` — `{ description, subtotal, taxAmount, taxRate, taxName,
total }`.
- `TaxCalculationResult` — `{ lineItems, subtotal, totalTax, total, taxRate,
inclusive }`.
- `TaxJurisdiction` — `{ country, region? }`.
- `new TaxCalculator(defaultRate?)` → instance with default rate
`{ country: "DEFAULT", rate: 0, name: "No Tax" }` unless overridden.
- `calc.addRate(rate)` — register.
- `calc.removeRate(country, region?)` → `boolean`.
- `calc.getRate(jurisdiction)` → resolved `TaxRate`.
- `calc.calculate(items, jurisdiction)` → `TaxCalculationResult`.
## Implementation Notes
- Jurisdiction keys are flat strings (`"<country>"` or `"<country>:<region>"`)
to avoid nested `Map<string, Map<string, ...>>` indirection.
- The calculator currency defaults to `"USD"` when invoked with an empty
line-item list. Callers passing a single empty list get a result of
`{ lineItems: [], subtotal: 0n USD, totalTax: 0n USD, total: 0n USD }`.
- `BigInt(Math.round(...))` rounding mirrors the upstream behaviour — for
exact half-even rounding callers can pre-multiply or wrap the calculator.
- `inclusive` defaults to `false` on a registered rate unless explicitly
set; the default-rate fallback also defaults to exclusive.
- Per-line `taxRate` is the resolved numeric rate (`0.19`), per-line
`taxName` is the resolved rate's name (or `"Exempt"`).
- Region lookups fall back to country lookups only when the region key
misses; this matches the conventional "state overrides country" mental
model.

View File

@@ -0,0 +1,103 @@
# tokenization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects
malformed expiry data and expired cards as typed `PaymentError(INVALID_CARD)`,
and hashes the cleaned PAN with HMAC-SHA-256 (via `SubtleCrypto`) so the
original card number is never recoverable from the stored token. Only the
last four digits, detected brand, and expiry survive in plain text.
## Features
- `tokenize(card)` — validate + hash + project to a `TokenizedCard` record.
- `validateLuhn(input)` — strips non-digits, applies the Luhn algorithm,
rejects PANs shorter than 13 or longer than 19 digits.
- `detectBrand(input)` — frozen pattern table covering Visa, Mastercard,
Amex, Discover, Diners, JCB, and UnionPay; unknown PANs map to
`"unknown"`.
- Configurable token prefix (`"tok_"` by default).
- Injectable `now` clock and `SubtleCrypto` so the tokeniser is fully
deterministic in tests without touching globals.
- Invalid cards (Luhn fail, bad month, expired) raise typed
`PaymentError(PAYMENT.INVALID_CARD)` with a descriptive reason.
## Usage: Basic
```ts
import { CardTokenizer } from "@suaveplan/payment/tokenization/tokenization";
const tokeniser = new CardTokenizer({ secretKey: "hmac-secret" });
const token = await tokeniser.tokenize({
number: "4242 4242 4242 4242",
expiryMonth: 12,
expiryYear: 2030,
});
console.log(token.token, token.brand, token.last4);
```
## Usage: Advanced
```ts
import { CardTokenizer } from "@suaveplan/payment/tokenization/tokenization";
import {
PAYMENT_CODES,
PaymentError,
} from "@suaveplan/payment/error-codes/error-codes";
const tokeniser = new CardTokenizer({
secretKey: "hmac-secret",
prefix: "tok_v2_",
now: () => 1_700_000_000_000,
subtle: globalThis.crypto.subtle,
});
try {
await tokeniser.tokenize({
number: "4242 4242 4242 4241", // Luhn fail
expiryMonth: 12,
expiryYear: 2030,
});
} catch (err) {
if (err instanceof PaymentError && err.code === PAYMENT_CODES.INVALID_CARD) {
console.warn("invalid card:", err.message);
}
}
console.log(tokeniser.detectBrand("371449635398431")); // "amex"
console.log(tokeniser.validateLuhn("4242424242424242")); // true
```
## API Reference
- `CardInput` — `{ number, expiryMonth, expiryYear, holderName? }`.
- `TokenizedCard` — `{ token, last4, brand, expiryMonth, expiryYear,
createdAt }`.
- `CardTokenizerConfig` — `{ secretKey, prefix?, now?, subtle? }`.
- `new CardTokenizer(config)` → instance.
- `tokeniser.tokenize(card)` → `Promise<TokenizedCard>`; rejects with
`PaymentError(INVALID_CARD)` on Luhn / month / expiry failures.
- `tokeniser.validateLuhn(input)` → boolean.
- `tokeniser.detectBrand(input)` → `string`.
## Implementation Notes
- HMAC-SHA-256 is routed through `SubtleCrypto`, which is available in every
supported universal runtime (Node 18+, Bun, browsers, Web Workers).
- The hex encoder is hand-rolled (no `Buffer`) so no Node-specific API
leaks in.
- Whitespace and dashes in the input PAN are stripped before validation,
hashing, and last-four extraction.
- The detector is intentionally narrow — `unknown` is a valid return value
rather than a thrown error, so callers can decide whether to refuse
unknown brands.
- The injected `now` source is invoked once during `tokenize` and once per
`validate` (for the expiry comparison); under a frozen clock both reflect
the same wall time so tests stay deterministic.
- The token format is `"<prefix><hex-sha256>"` — 64 hex chars after the
prefix; consumers persisting in a fixed-width column should reserve
prefix-length + 64.

View File

@@ -0,0 +1,96 @@
# types/payment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared type contracts for the payment lifecycle: status union, payment-method
descriptors, refund and dispute records, the canonical `PaymentIntent` shape,
the `PaymentProvider` adapter interface, the `PaymentEventMap` consumed by the
`EventBus`-backed `PaymentEngine`, and the `VALID_TRANSITIONS` map enforcing
the state machine. Every other module in the package consumes types from here
so the public contract sits in one place and stays stable across follow-up
changes.
## Features
- `PaymentStatus` — the 9-state lifecycle union (`created` → `pending` →
`authorized` → `captured` → `partially_refunded` / `refunded` / `disputed` /
`canceled` / `failed`).
- `PaymentMethodType` / `CardBrand` / `PaymentMethod` — non-sensitive method
descriptors carrying only token / last-four / expiry data.
- `CreatePaymentIntentInput` / `RefundInput` — provider-facing input shapes
with optional `idempotencyKey` / `metadata` passthrough.
- `Refund` / `RefundStatus` / `RefundReason` — refund record + status union.
- `Dispute` / `DisputeEvidence` — chargeback record.
- `PaymentIntent` — the canonical object every provider returns.
- `PaymentProvider` — bring-your-own gateway contract.
- `PaymentEventMap` — type-keyed map consumed by the engine's `EventBus`.
- `VALID_TRANSITIONS` — frozen state-machine table.
## Usage: Basic
```ts
import type {
CreatePaymentIntentInput,
PaymentIntent,
} from "@suaveplan/payment/types/payment";
const input: CreatePaymentIntentInput = {
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
};
declare function readIntent(): PaymentIntent;
const intent = readIntent();
```
## Usage: Advanced
```ts
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
class MyProvider implements PaymentProvider {
readonly name = "my-provider";
async createIntent(_input) { /* ... */ throw new Error("stub"); }
async confirmIntent(_id) { throw new Error("stub"); }
async captureIntent(_id) { throw new Error("stub"); }
async cancelIntent(_id) { throw new Error("stub"); }
async refund(_input) { throw new Error("stub"); }
async getIntent(_id) { throw new Error("stub"); }
}
```
## API Reference
- `PaymentStatus` — string union, exhaustive.
- `PaymentMethodType` — `card | bank_transfer | wallet | crypto | manual`.
- `CardBrand` — 8 brand identifiers including `unknown`.
- `PaymentMethod` — `{ id, type, brand?, last4?, expiryMonth?, expiryYear?, holderName?, metadata? }`.
- `CreatePaymentIntentInput` — caller-supplied intent fields; `amount` required.
- `RefundInput` — `{ paymentIntentId, amount?, reason?, metadata?, idempotencyKey? }`.
- `Refund` — `{ id, paymentIntentId, amount, status, reason?, createdAt, metadata? }`.
- `Dispute` — `{ id, paymentIntentId, amount, reason, status, evidence?, createdAt, respondBy? }`.
- `PaymentIntent` — full intent record carrying current status, captured /
refunded totals, attached method, refund history, optional dispute.
- `PaymentProvider` — six async methods covering create / confirm / capture /
cancel / refund / get.
- `PaymentEventMap` — typed event-name → payload map.
- `VALID_TRANSITIONS` — `Readonly<Record<PaymentStatus, readonly PaymentStatus[]>>`,
frozen at module load.
## Implementation Notes
- Every record is `readonly` — providers and engine helpers return new objects
rather than mutating shared state.
- `VALID_TRANSITIONS` is `Object.freeze`d at module load so accidental
mutation is a structural error, not a silent bug.
- All money fields are `MoneyLike` (from `@suaveplan/money`) — never raw
numbers — so `bigint` precision flows end-to-end.
- The `PaymentEventMap` payloads are single values (not tuples) to match the
`EventBus<TMap>` shape from `@suaveplan/events`.
- Type-only module: no runtime export besides the frozen `VALID_TRANSITIONS`
table. Vitest's `*.types.ts` exclusion does not apply here because we ship
one runtime constant; the file is `types/payment.ts` so coverage treats the
table as touched by every engine test.

View File

@@ -0,0 +1,116 @@
# webhook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal webhook signature verifier. Computes an HMAC over a
`timestamp.body` envelope using `@suaveplan/crypto-hash` (so no `node:crypto`
import leaks in), compares with the constant-time helper from
`@suaveplan/crypto-utils`, enforces a configurable tolerance window to defeat
replay attacks, and parses the body into a generic-typed `WebhookPayload<T>`.
Designed to drop into a request handler — pass the raw body, the
provider-supplied signature header, and the provider-supplied timestamp
header, and the verifier returns the parsed payload or throws a discriminated
`PaymentError`.
## Features
- `sign(body, timestamp)` — emit the hex signature consumers can persist or
echo back when forwarding webhooks downstream.
- `verify<T>(body, signature, timestamp)` — full pipeline: expiry check →
constant-time signature compare → JSON parse.
- Configurable algorithm (`SHA-256` default, `SHA-384`, `SHA-512`).
- Configurable tolerance window (`toleranceMs`, default 5 minutes).
- Injectable `now()` clock for deterministic tests.
- Errors are typed `PaymentError` with the discriminated `ageMs` /
`WEBHOOK_*` codes — never `Error` instances.
## Usage: Basic
```ts
import { WebhookVerifier } from "@suaveplan/payment/webhook/webhook";
const verifier = new WebhookVerifier({ secret: process.env.WEBHOOK_SECRET! });
const { payload } = verifier.verify(
rawBody,
signatureHeader,
Number(timestampHeader)
);
console.log("event:", payload.type, payload.data);
```
## Usage: Advanced
```ts
import {
PAYMENT_CODES,
PaymentError,
} from "@suaveplan/payment/error-codes/error-codes";
import { WebhookVerifier } from "@suaveplan/payment/webhook/webhook";
// Deterministic clock for replay-window edge cases.
let frozenNow = 1_700_000_000_000;
const verifier = new WebhookVerifier({
secret: "whsec_test",
toleranceMs: 60_000,
algorithm: "SHA-512",
now: () => frozenNow,
});
const ts = frozenNow - 30_000; // 30 seconds old, inside the 60s tolerance
const body = JSON.stringify({
id: "evt_001",
type: "payment.captured",
timestamp: ts,
data: { intentId: "pi_001" },
});
const sig = verifier.sign(body, ts);
interface CaptureEvent {
readonly intentId: string;
}
try {
const { payload } = verifier.verify<CaptureEvent>(body, sig, ts);
console.log(payload.data.intentId);
} catch (err) {
if (err instanceof PaymentError && err.code === PAYMENT_CODES.WEBHOOK_EXPIRED) {
console.warn("replayed webhook", err.ageMs, "ms");
}
}
```
## API Reference
- `WebhookAlgorithm` — `"SHA-256" | "SHA-384" | "SHA-512"`.
- `WebhookVerifierConfig` — `{ secret, toleranceMs?, algorithm?, now? }`.
- `WebhookPayload<T>` — `{ id, type, timestamp, data: T }`.
- `VerifiedWebhook<T>` — `{ payload: WebhookPayload<T>, receivedAt: number }`.
- `new WebhookVerifier(config)` — encode the secret once, normalise defaults.
- `verifier.sign(body, timestamp)` → hex string.
- `verifier.verify<T>(body, signature, timestamp)` → `VerifiedWebhook<T>`,
throws `PaymentError` (`WEBHOOK_EXPIRED`, `WEBHOOK_INVALID_SIGNATURE`, or
`WEBHOOK_PARSE_ERROR`) on failure.
## Implementation Notes
- HMAC routes through `@suaveplan/crypto-hash`, whose primitive selector
ships pure-TS and native impls — both keep the package universal.
- The comparison is delegated to `constantTimeEqual` from
`@suaveplan/crypto-utils`; the verifier additionally rejects empty
decoded buffers (malformed hex) up front so timing leakage from a
truncated provider signature is impossible.
- Hex encode/decode is hand-rolled to avoid `Buffer` (which would force a
Node dependency). Decode rejects odd-length and non-hex chars by
returning an empty `Uint8Array`, which the length-check then rejects.
- The age check uses `Math.abs(now - timestamp)`, so both stale (`now > ts`)
and clock-skewed future timestamps (`ts > now`) outside the tolerance
are rejected with the same `WEBHOOK_EXPIRED` code.
- Parse errors thrown by `JSON.parse` are wrapped in `webhookParseError`
with the original `Error` preserved on `cause`; non-`Error` rejection
cases default to an undefined cause.
- The verifier holds the secret as a `Uint8Array` after construction —
callers can still pass the literal string at construction time, but the
encoded buffer is what's used per request.