first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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