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