first commit
This commit is contained in:
19
docs/money/payment/README.md
Normal file
19
docs/money/payment/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `payment`
|
||||
|
||||
Payment intent lifecycle, provider adapters, and fraud detection.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `payment`
|
||||
|
||||
7 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestration, r… |
|
||||
| [`@suaveplan/payment-provider-adyen`](./payment-provider-adyen/README.md) | Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`. Implements the `PaymentProvider` interface via the slim `@suaveplan/payment-providers` c… |
|
||||
| [`@suaveplan/payment-provider-mollie`](./payment-provider-mollie/README.md) | Universal Mollie Payments API v2 adapter for `@suaveplan/payment`. Sibling provider package on the slim `@suaveplan/payment-providers` core: implements `Paymen… |
|
||||
| [`@suaveplan/payment-provider-paypal`](./payment-provider-paypal/README.md) | PayPal sibling adapter for the slim `@suaveplan/payment-providers` foundation. Ships `PayPalProvider` — a `PaymentProvider` implementation against the PayPal O… |
|
||||
| [`@suaveplan/payment-provider-square`](./payment-provider-square/README.md) | Universal Square Payments API adapter for `@suaveplan/payment`. Ships the `SquareProvider` class (implements the structural `PaymentProvider` contract with lit… |
|
||||
| [`@suaveplan/payment-provider-stripe`](./payment-provider-stripe/README.md) | Universal Stripe adapter for `@suaveplan/payment`. Implements `PaymentProvider` against the Stripe REST API using the slim `@suaveplan/payment-providers` found… |
|
||||
| [`@suaveplan/payment-providers`](./payment-providers/README.md) | Universal **foundation** for the `@suaveplan/payment-provider-*` sibling-package family. This package ships only the shared abstractions every concrete adapter… |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/money/payment/_intro.md
Normal file
1
docs/money/payment/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Payment intent lifecycle, provider adapters, and fraud detection.
|
||||
94
docs/money/payment/payment-provider-adyen/README.md
Normal file
94
docs/money/payment/payment-provider-adyen/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/payment-provider-adyen - Universal Adyen Checkout API v71 adapter for @suaveplan/payment. Implements the PaymentProvider interface via the slim @suaveplan/payment-providers core (httpRequest + composeAbortSignal + MerchantKeyConfig + PaymentProviderError), forwarding X-API-Key auth on every call and injecting the configured merchantAccount into capture / cancel / refund request bodies. Generates default payment references via an injectable RandomIdFn so the package stays universal and deterministic under test.
|
||||
|
||||
Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`.
|
||||
Implements the `PaymentProvider` interface via the slim
|
||||
`@suaveplan/payment-providers` core (`httpRequest`,
|
||||
`composeAbortSignal`, `MerchantKeyConfig`, `PaymentProviderError`),
|
||||
forwarding `X-API-Key` auth on every call and injecting the configured
|
||||
`merchantAccount` into capture / cancel / refund request bodies.
|
||||
Generates default payment references via an injectable `RandomIdFn`
|
||||
so the package stays universal and deterministic under test.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
|
||||
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
|
||||
- [mapping](./src/adyen/mapping.md) — Pure mapping helpers that lift Adyen Checkout API v71 response shapes into the canonical `PaymentIntent`, `PaymentMetho…
|
||||
- [types](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
|
||||
- [adyen](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-adyen @suaveplan/payment @suaveplan/payment-providers
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(adyen);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("adyen") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5_000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Full `PaymentProvider` surface** — `createIntent` /
|
||||
`confirmIntent` / `captureIntent` / `cancelIntent` / `refund` /
|
||||
`getIntent`.
|
||||
- **`X-API-Key` authentication** with `Idempotency-Key` forwarding on
|
||||
`createIntent` and `refund`.
|
||||
- **`merchantAccount` context** injected into every modification body
|
||||
(`/captures`, `/cancels`, `/refunds`).
|
||||
- **Injectable `RandomIdFn`** for default `reference` generation —
|
||||
with `globalThis.crypto.randomUUID()` fallback at
|
||||
call time.
|
||||
- **Adyen status mapping** for all 10 documented v71 result codes
|
||||
plus `failed` fallback.
|
||||
- **Typed errors only** — every failure surfaces as
|
||||
`PaymentProviderError` (HTTP / timeout / parse) from the slim core.
|
||||
- **Universal tier** — zero `node:` / `bun:` / `fs` / `path` /
|
||||
`stream` / `net` / `http` / `crypto` imports; runs on Node, Bun,
|
||||
browsers, and Web Workers.
|
||||
- **Tree-shakeable** — `sideEffects: false` + per-feature subpath
|
||||
exports.
|
||||
- **100% test coverage** (Istanbul) + 100% docs coverage.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/adyen/adyen.md`](./src/adyen/adyen.md) — the `AdyenProvider`
|
||||
class.
|
||||
- [`src/adyen/mapping.md`](./src/adyen/mapping.md) — pure mappers
|
||||
(`mapStatus`, `mapRefundStatus`, `mapPaymentMethod`, `mapPayment`).
|
||||
- [`src/types/adyen.md`](./src/types/adyen.md) — `AdyenConfig` type
|
||||
alias for the slim-core `MerchantKeyConfig`.
|
||||
|
||||
## Related
|
||||
|
||||
- [`@suaveplan/payment-providers`](../payment-providers/README.md) —
|
||||
slim foundation: `httpRequest`, `composeAbortSignal`,
|
||||
`ProviderRegistry`, `PaymentProviderError`, shared config types.
|
||||
- [`@suaveplan/payment`](../payment/README.md) — `PaymentEngine`,
|
||||
`WebhookVerifier`, `PaymentProvider` interface.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
216
docs/money/payment/payment-provider-adyen/src/adyen/adyen.md
Normal file
216
docs/money/payment/payment-provider-adyen/src/adyen/adyen.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# `AdyenProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal adapter that implements the `PaymentProvider` interface
|
||||
from `@suaveplan/payment` against Adyen's Checkout API v71. The class
|
||||
authenticates every request via the `X-API-Key` header (rather than
|
||||
HTTP Basic auth like Stripe/Mollie or OAuth like PayPal), and injects
|
||||
the configured `merchantAccount` string into every request body Adyen
|
||||
requires it on (`/payments`, `/payments/{id}/captures`,
|
||||
`/payments/{id}/cancels`, `/payments/{id}/refunds`).
|
||||
|
||||
The class is the sole runtime export of
|
||||
`@suaveplan/payment-provider-adyen`. It depends only on the slim core
|
||||
`@suaveplan/payment-providers` (for `httpRequest`,
|
||||
`PaymentProviderError`, `RandomIdFn`, and `MerchantKeyConfig`) and on
|
||||
the type-only `@suaveplan/payment` interface — no Node, browser, or
|
||||
React imports, no `node:crypto`, and no module-scope wall-clock or
|
||||
RNG access. Default `reference` generation goes through an injectable
|
||||
`RandomIdFn`, falling back to `globalThis.crypto.randomUUID()` only at
|
||||
call time and only when available; tests inject a deterministic id
|
||||
source instead.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full `PaymentProvider` surface** — `createIntent`, `confirmIntent`,
|
||||
`captureIntent`, `cancelIntent`, `refund`, `getIntent`.
|
||||
- **`X-API-Key` authentication** — set on every request, including
|
||||
`GET`s.
|
||||
- **`Idempotency-Key` header forwarding** — when the caller supplies
|
||||
`idempotencyKey` on `createIntent` or `refund`, the same value is
|
||||
echoed as both the request-level `Idempotency-Key` header and (for
|
||||
`createIntent`) the `reference` field in the body.
|
||||
- **Injectable `randomId`** — default `reference` generation uses
|
||||
caller-supplied `RandomIdFn`; falls back to
|
||||
`globalThis.crypto.randomUUID()` only at call time so the package
|
||||
stays universal and never imports `node:crypto`.
|
||||
- **`merchantAccount` context injection** — every modification body
|
||||
(`/captures`, `/cancels`, `/refunds`) carries the configured value.
|
||||
- **Adyen status mapping** — `Authorised` / `Refused` / `Cancelled` /
|
||||
`Error` / `Pending` / `Received` / `RedirectShopper` /
|
||||
`IdentifyShopper` / `ChallengeShopper` / `PresentToShopper` plus
|
||||
unknown codes mapped to the canonical `PaymentStatus`.
|
||||
- **Refund status mapping** — `received` → `pending`, every other
|
||||
status → `failed`.
|
||||
- **Payment-method mapping** — `scheme` → `card`, everything else →
|
||||
`wallet`; brand lower-cased; `lastFour` preserved as `last4`.
|
||||
- **Typed errors** — every HTTP / timeout / parse failure surfaces as
|
||||
a `PaymentProviderError` (one of `PAYMENT_PROVIDER_CODES`) via the
|
||||
shared `httpRequest` helper; the class never throws plain `Error`.
|
||||
- **Universal purity** — zero `node:`, `bun:`, `fs`, `path`,
|
||||
`stream`, `net`, `http`, or `crypto` imports.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
});
|
||||
|
||||
const intent = await adyen.createIntent({
|
||||
amount: { amount: 4_999n, currency: "USD" },
|
||||
description: "Acme premium subscription",
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
import {
|
||||
ProviderRegistry,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
// Test-time injection: recording fetch + deterministic randomId.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
pspReference: "PSP_REC",
|
||||
resultCode: "Authorised",
|
||||
amount: { value: 4999, currency: "USD" },
|
||||
paymentMethod: { type: "scheme", brand: "visa", lastFour: "4242" },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
let refCounter = 0;
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: "AQE_test",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
baseUrl: "https://checkout-test.adyen.com/v71",
|
||||
fetch: recordingFetch,
|
||||
randomId: () => `ref_${++refCounter}`,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(adyen);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("adyen") });
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 4_999n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
});
|
||||
|
||||
// Verify the recording layer saw the right bytes:
|
||||
const body = JSON.parse(String(calls[0]?.init?.body));
|
||||
console.assert(body.merchantAccount === "AcmeMerchantECOM");
|
||||
console.assert(body.reference === "ref_1");
|
||||
console.assert(body.captureDelayHours === 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AdyenProvider(config: AdyenConfig)`
|
||||
|
||||
Construct an Adyen adapter. `config` is the `MerchantKeyConfig` alias
|
||||
documented in [`types/adyen.md`](../types/adyen.md). Required:
|
||||
`apiKey`, `merchantAccount`. Optional: `baseUrl`, `timeoutMs`,
|
||||
`signal`, `fetch`, `randomId`, `webhookHmacKey`. When `randomId` is
|
||||
omitted the class falls back to `globalThis.crypto.randomUUID()` at
|
||||
call time; if neither is available, the first auto-generated
|
||||
`reference` raises a `PaymentProviderError` with code
|
||||
`PAYMENT_PROVIDER.PARSE_ERROR`.
|
||||
|
||||
### `readonly name: "adyen"`
|
||||
|
||||
Stable identifier consumed by `ProviderRegistry` and any
|
||||
multi-provider routing layer.
|
||||
|
||||
### `createIntent(input): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments`. Request body always carries
|
||||
`merchantAccount`, `amount`, and `reference` (caller's `idempotencyKey`
|
||||
or `randomId()`). When `captureMethod === "manual"` the body adds
|
||||
`captureDelayHours: 0`. Optional `description` → `shopperStatement`,
|
||||
`returnUrl` → `returnUrl`, `paymentMethodId` →
|
||||
`paymentMethod.storedPaymentMethodId`, `metadata` → `metadata`.
|
||||
|
||||
### `confirmIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/details` with `{ details: {} }`.
|
||||
|
||||
### `captureIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/captures` with
|
||||
`{ merchantAccount, amount: {} }`. Returns a `PaymentIntent` with
|
||||
`status: "captured"` and `capturedAmount` reflecting the response.
|
||||
|
||||
### `cancelIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/cancels` with
|
||||
`{ merchantAccount }`. Returns a `PaymentIntent` with
|
||||
`status: "canceled"`.
|
||||
|
||||
### `refund(input): Promise<Refund>`
|
||||
|
||||
POST `${baseUrl}/payments/${input.paymentIntentId}/refunds` with
|
||||
`{ merchantAccount }` plus optional `amount` when supplied. Forwards
|
||||
`Idempotency-Key` when `input.idempotencyKey` is set. Refund `status`
|
||||
is mapped via `mapRefundStatus`.
|
||||
|
||||
### `getIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
GET `${baseUrl}/payments/${intentId}`.
|
||||
|
||||
### Errors
|
||||
|
||||
Every method propagates `PaymentProviderError` for HTTP non-2xx
|
||||
responses, network failures, request timeouts, and JSON parse
|
||||
failures — see `@suaveplan/payment-providers`
|
||||
[`error-codes.md`](../../../payment-providers/src/error-codes/error-codes.md).
|
||||
The class never throws plain `Error`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `baseUrl` is the **test** endpoint
|
||||
(`https://checkout-test.adyen.com/v71`). Live integrations MUST
|
||||
override `baseUrl` with the merchant-specific
|
||||
`https://<prefix>-checkout-live.adyenpayments.com/checkout/v71`
|
||||
URL. Adyen does not provide a single shared live endpoint.
|
||||
- The cancel response carries no `amount` (Adyen models cancellation
|
||||
as a zero-value state change), so `cancelIntent` returns
|
||||
`amount`/`capturedAmount`/`refundedAmount` all set to `{ amount: 0n,
|
||||
currency: <response.currency> }`. Consumers needing the original
|
||||
authorisation amount should refetch via `getIntent`.
|
||||
- `BigInt(p.amount.value)` is safe because Adyen returns integer minor
|
||||
units across the Checkout API; the conversion would silently round
|
||||
any floating-point input that ever appeared, which is not a
|
||||
documented v71 case.
|
||||
- The class holds no mutable state beyond the constructor seams; every
|
||||
method composes its own `headers` and request body fresh, so
|
||||
concurrent calls are safe.
|
||||
- `mapping.ts` is a sibling file holding the pure status / refund /
|
||||
payment-method / payment mappers; splitting them out keeps `adyen.ts`
|
||||
inside the 300-line hard limit (rule 3) and documents the mapping
|
||||
contract on its own page.
|
||||
- Webhook signature verification is **not** in scope for this package
|
||||
— it lives in `@suaveplan/payment`'s `WebhookVerifier`. The
|
||||
`webhookHmacKey` field on `AdyenConfig` is plumbed through so
|
||||
consumers can pass it to the verifier without re-typing it.
|
||||
123
docs/money/payment/payment-provider-adyen/src/adyen/mapping.md
Normal file
123
docs/money/payment/payment-provider-adyen/src/adyen/mapping.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Adyen response mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure mapping helpers that lift Adyen Checkout API v71 response shapes
|
||||
into the canonical `PaymentIntent`, `PaymentMethod`, `PaymentStatus`,
|
||||
and `RefundStatus` types exported by `@suaveplan/payment`. Splitting
|
||||
these helpers out of `adyen.ts` keeps the provider class under the
|
||||
300-line hard limit (rule 3) and keeps the mappers separately testable
|
||||
through the public provider surface — every helper is exercised by the
|
||||
`AdyenProvider` test suite via real recording `FetchLike` closures.
|
||||
|
||||
The mappers are deliberately total: every Adyen `resultCode` that the
|
||||
v71 contract documents has an explicit row in `RESULT_STATUS`, every
|
||||
unknown code falls through to `failed`, every non-`received` refund
|
||||
status collapses to `failed`, and every non-`scheme` payment-method
|
||||
type collapses to `wallet`. This matches the upstream behaviour
|
||||
verbatim and the spec delta covers each row with a dedicated test.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapStatus(resultCode)` — 10-entry result-code table + `failed`
|
||||
fallback.
|
||||
- `mapRefundStatus(status)` — single-row table (`received` → `pending`)
|
||||
with `failed` fallback.
|
||||
- `mapPaymentMethod(pspReference, method)` — emits `card` for `scheme`,
|
||||
`wallet` for everything else; lower-cases the brand; preserves
|
||||
`lastFour` as `last4`.
|
||||
- `mapPayment(payment, captureMethod, nowIso)` — composes the above
|
||||
into a full `PaymentIntent`. `capturedAmount` is always `0n` here
|
||||
because Adyen's `/payments` and `/payments/{id}` endpoints never
|
||||
return a captured state — capture is a separate `/captures` call
|
||||
modelled by `AdyenProvider.captureIntent`, which builds its own
|
||||
response.
|
||||
- Typed `AdyenAmount` / `AdyenPaymentMethod` / `AdyenPayment` /
|
||||
`AdyenModification` interfaces describing the response excerpts the
|
||||
provider consumes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { mapStatus, mapRefundStatus } from "@suaveplan/payment-provider-adyen/adyen/adyen";
|
||||
|
||||
mapStatus("Authorised"); // -> "authorized"
|
||||
mapStatus("RedirectShopper"); // -> "pending"
|
||||
mapStatus("WhoKnows"); // -> "failed"
|
||||
mapRefundStatus("received"); // -> "pending"
|
||||
mapRefundStatus("error"); // -> "failed"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mapPayment, type AdyenPayment } from "@suaveplan/payment-provider-adyen/adyen/adyen";
|
||||
|
||||
const raw: AdyenPayment = {
|
||||
pspReference: "PSP_999",
|
||||
resultCode: "Authorised",
|
||||
amount: { value: 12_500, currency: "USD" },
|
||||
merchantReference: "order_42",
|
||||
paymentMethod: { type: "scheme", brand: "VISA", lastFour: "4242" },
|
||||
};
|
||||
|
||||
const intent = mapPayment(raw, "manual", "2026-05-17T12:00:00.000Z");
|
||||
intent.amount.amount; // -> 12500n
|
||||
intent.amount.currency; // -> "USD"
|
||||
intent.capturedAmount.amount; // -> 0n (capture is a separate /captures call)
|
||||
intent.paymentMethod?.brand; // -> "visa"
|
||||
intent.paymentMethod?.last4; // -> "4242"
|
||||
intent.paymentMethod?.type; // -> "card"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapStatus(resultCode: string): PaymentStatus`
|
||||
|
||||
Look up the Adyen result code in the static `RESULT_STATUS` table.
|
||||
Returns `"failed"` for any code not in the table.
|
||||
|
||||
### `mapRefundStatus(status: string): RefundStatus`
|
||||
|
||||
Returns `"pending"` for `"received"`, `"failed"` for anything else.
|
||||
|
||||
### `mapPaymentMethod(pspReference: string, method?: AdyenPaymentMethod): PaymentMethod | undefined`
|
||||
|
||||
Returns `undefined` when `method` is `undefined`. Otherwise composes a
|
||||
`PaymentMethod` with `id` `"adyen_<pspReference>"`, `type` `"card"`
|
||||
when `method.type === "scheme"` and `"wallet"` otherwise, lower-cased
|
||||
`brand`, and `last4` taken from `method.lastFour`.
|
||||
|
||||
### `mapPayment(payment: AdyenPayment, captureMethod: "automatic" | "manual", nowIso: string): PaymentIntent`
|
||||
|
||||
Composes a full `PaymentIntent`. `nowIso` is supplied by the caller so
|
||||
the provider class controls the timestamp source; the helper does not
|
||||
read the wall clock itself. `capturedAmount` is always `0n` in the same
|
||||
currency — Adyen's `/payments` endpoint never reports a captured state,
|
||||
so the mapped intent records the captured value as zero and leaves
|
||||
capture reporting to `AdyenProvider.captureIntent`.
|
||||
|
||||
### Types
|
||||
|
||||
`AdyenAmount`, `AdyenPaymentMethod`, `AdyenPayment`, and
|
||||
`AdyenModification` are minimal `readonly` excerpts of the upstream v71
|
||||
shapes — only the fields the mappers consume are declared, so changes
|
||||
to the wider Adyen response shape do not invalidate the types here.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `RESULT_STATUS` table is frozen at module load time and exported
|
||||
only through the wrapping `mapStatus` function so consumers cannot
|
||||
mutate it.
|
||||
- `BigInt(p.amount.value)` is safe because Adyen always returns
|
||||
integer minor units — every Checkout response field is documented as
|
||||
an integer; floating-point input would silently round here.
|
||||
- The mappers are pure: same input → same output, no IO, no
|
||||
closures over module state. The provider class injects `nowIso` and
|
||||
`captureMethod` so callers can fix the timestamp in tests.
|
||||
- `mapPaymentMethod` widens the lower-cased `brand` to
|
||||
`PaymentMethod["brand"]` via a `as` cast. Adyen returns the brand
|
||||
free-form, and `PaymentMethod["brand"]` is a discriminated union;
|
||||
the cast is the boundary at which untyped wire data meets the typed
|
||||
domain model. The provider tests cover the common brands (`visa`,
|
||||
`applepay`) end-to-end.
|
||||
115
docs/money/payment/payment-provider-adyen/src/types/adyen.md
Normal file
115
docs/money/payment/payment-provider-adyen/src/types/adyen.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `AdyenConfig`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Type alias exported by `@suaveplan/payment-provider-adyen` describing
|
||||
the constructor argument accepted by `AdyenProvider`. It re-exports the
|
||||
slim-core `MerchantKeyConfig` verbatim so consumers importing only this
|
||||
sibling package do not need a second import from
|
||||
`@suaveplan/payment-providers`, and so future Adyen-specific fields can
|
||||
be added here without disturbing the foundation contract.
|
||||
|
||||
The type is structurally identical to `MerchantKeyConfig` — any value
|
||||
typed as `MerchantKeyConfig` is assignable to `AdyenConfig` and vice
|
||||
versa. This is deliberate: the slim-core shape already carries every
|
||||
Adyen-specific seam (`apiKey`, `merchantAccount`, `webhookHmacKey`,
|
||||
`randomId`), and forking the type would create a second source of
|
||||
truth for the same contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports `MerchantKeyConfig` under an Adyen-local name so callers
|
||||
see the right vocabulary.
|
||||
- Carries the injectable `randomId: RandomIdFn` seam used by
|
||||
`AdyenProvider.createIntent` to generate the default `reference`
|
||||
field when no `idempotencyKey` is supplied.
|
||||
- Carries the standard `ProviderCommonOptions` seams (`fetch`,
|
||||
`timeoutMs`, `signal`) so tests can swap in a recording `FetchLike`
|
||||
and exercise abort propagation without monkey-patching globals.
|
||||
- Carries `webhookHmacKey` for future webhook verification wiring
|
||||
(currently consumed by `@suaveplan/payment`'s `WebhookVerifier`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider, type AdyenConfig } from "@suaveplan/payment-provider-adyen";
|
||||
|
||||
const config: AdyenConfig = {
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
};
|
||||
|
||||
const adyen = new AdyenProvider(config);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AdyenProvider,
|
||||
type AdyenConfig,
|
||||
} from "@suaveplan/payment-provider-adyen";
|
||||
import type { FetchLike } from "@suaveplan/payment-providers";
|
||||
|
||||
// Deterministic reference generation + recording fetch for tests.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
pspReference: "PSP_TEST",
|
||||
resultCode: "Authorised",
|
||||
amount: { value: 5000, currency: "USD" },
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
const config: AdyenConfig = {
|
||||
apiKey: "AQE_test_key",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
baseUrl: "https://checkout-test.adyen.com/v71",
|
||||
fetch: recordingFetch,
|
||||
randomId: () => `gen_ref_${++counter}`,
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
|
||||
const adyen = new AdyenProvider(config);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type AdyenConfig`
|
||||
|
||||
Alias for `MerchantKeyConfig` from `@suaveplan/payment-providers`.
|
||||
Fields (all read-only):
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `apiKey` | `string` | yes | — | Adyen Checkout API key, sent as `X-API-Key`. |
|
||||
| `merchantAccount` | `string` | yes | — | Adyen merchant account code, injected into modification bodies. |
|
||||
| `baseUrl` | `string` | no | `https://checkout-test.adyen.com/v71` | Endpoint override (use `https://<prefix>-checkout-live.adyenpayments.com/checkout/v71` for live). |
|
||||
| `webhookHmacKey` | `string` | no | — | Reserved for webhook signature verification. |
|
||||
| `randomId` | `() => string` | no | `globalThis.crypto?.randomUUID` when available | Default-`reference` generator. |
|
||||
| `fetch` | `FetchLike` | no | `globalThis.fetch` | Injectable HTTP client. |
|
||||
| `timeoutMs` | `number` | no | `30_000` | Per-call timeout in milliseconds. |
|
||||
| `signal` | `AbortSignal` | no | — | External cancellation token. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `AdyenConfig` is intentionally a `type` alias rather than an
|
||||
`interface extends MerchantKeyConfig` — the latter would diverge the
|
||||
two types under `tsc --strict` if `MerchantKeyConfig` ever gains an
|
||||
optional field. Aliasing preserves structural equality forever.
|
||||
- The `randomId` seam is the universal escape hatch for default
|
||||
`reference` generation. Without it, the provider would have to call
|
||||
`crypto.randomUUID()` at request time and bring the entire
|
||||
`node:crypto` polyfill surface into bundlers targeting browsers and
|
||||
Web Workers. With it, callers in those environments can pass a
|
||||
deterministic id source (UUIDv7, NanoID, sequence counter) and the
|
||||
package stays pure.
|
||||
72
docs/money/payment/payment-provider-mollie/README.md
Normal file
72
docs/money/payment/payment-provider-mollie/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @suaveplan/payment-provider-mollie - Universal Mollie Payments API v2 adapter for @suaveplan/payment. Sibling provider package on the slim @suaveplan/payment-providers core: implements PaymentProvider with Mollie's string-amount marshalling ({ currency, value: "X.YY" }) round-trip via exported formatMollieAmount / parseMollieAmount helpers.
|
||||
|
||||
Universal Mollie Payments API v2 adapter for `@suaveplan/payment`.
|
||||
Sibling provider package on the slim `@suaveplan/payment-providers`
|
||||
core: implements `PaymentProvider` with Mollie's string-amount
|
||||
marshalling (`{ currency, value: "X.YY" }`) round-trip via the
|
||||
exported `formatMollieAmount` / `parseMollieAmount` helpers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
|
||||
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-mollie @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" })
|
||||
);
|
||||
|
||||
const mollie = registry.get("mollie");
|
||||
const intent = await mollie.createIntent({
|
||||
amount: { amount: 5000n, currency: "EUR" },
|
||||
description: "Order #42",
|
||||
returnUrl: "https://shop.example/return",
|
||||
});
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `MollieProvider` — full `PaymentProvider` lifecycle
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) on top of the slim `httpRequest` helper.
|
||||
- Pure, exported amount helpers — `formatMollieAmount(amount: bigint)`
|
||||
and `parseMollieAmount(value: string)` — that round-trip losslessly
|
||||
via `bigint` and cover the 1-cent, exact-major, large-value, and
|
||||
over-precision edges.
|
||||
- Total exported status maps — `mapMollieStatus` and
|
||||
`mapMollieRefundStatus` — each individually unit-testable without
|
||||
invoking the network path.
|
||||
- `Authorization: Bearer <apiKey>` header auto-applied on every call.
|
||||
- Universal — no Node-specific or browser-specific globals; runs
|
||||
on Node 18+, Bun, browsers, and Web Workers.
|
||||
- All failures surface as `PaymentProviderError` from
|
||||
`@suaveplan/payment-providers` carrying a `PAYMENT_PROVIDER_*` code.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/mollie/mollie.md`](./src/mollie/mollie.md) —
|
||||
`MollieProvider` class, helpers, and status maps.
|
||||
|
||||
## Sibling packages
|
||||
|
||||
Part of the `@suaveplan/payment-provider-*` family. See
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) for the
|
||||
shared foundation (`ProviderRegistry`, `httpRequest`,
|
||||
`composeAbortSignal`, `PaymentProviderError`, the `PAYMENT_PROVIDER_*`
|
||||
code registry, and the shared config shapes).
|
||||
|
||||
185
docs/money/payment/payment-provider-mollie/src/mollie/mollie.md
Normal file
185
docs/money/payment/payment-provider-mollie/src/mollie/mollie.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# MollieProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MollieProvider` is the sibling adapter that implements the
|
||||
`PaymentProvider` contract from `@suaveplan/payment` against the Mollie
|
||||
Payments API v2. It is the only `@suaveplan/payment-provider-*` sibling
|
||||
that has to marshal money amounts to and from a string representation:
|
||||
every Mollie request and response carries amounts as
|
||||
`{ currency: "EUR", value: "10.00" }`, while the rest of the suaveplan
|
||||
payment family models money as `bigint` minor-units. This package
|
||||
owns that round-trip end-to-end and exposes the underlying helpers
|
||||
(`formatMollieAmount`, `parseMollieAmount`) so callers can test or reuse
|
||||
them in their own integrations.
|
||||
|
||||
## Features
|
||||
|
||||
- `MollieProvider` class implementing `PaymentProvider` with name
|
||||
`"mollie"`.
|
||||
- Constructor takes `ApiKeyConfig` from `@suaveplan/payment-providers`
|
||||
(`apiKey`, optional `baseUrl`, `timeoutMs`, `fetch`, `signal`,
|
||||
`webhookSecret`).
|
||||
- Auto-sets `Authorization: Bearer <apiKey>` on every call.
|
||||
- Uses the slim-core `httpRequest` helper for JSON serialisation,
|
||||
per-call timeout, external `AbortSignal` composition, and typed
|
||||
error surfacing.
|
||||
- Defaults `description` to `"Payment"` and `redirectUrl` to
|
||||
`"https://localhost/return"` when omitted, matching upstream
|
||||
behaviour.
|
||||
- `confirmIntent` and `captureIntent` are read-throughs to `getIntent`
|
||||
because Mollie auto-captures.
|
||||
- Pure, exported amount helpers (`formatMollieAmount`,
|
||||
`parseMollieAmount`) and status maps (`mapMollieStatus`,
|
||||
`mapMollieRefundStatus`) — all individually unit-testable.
|
||||
- All failures surface as `PaymentProviderError` carrying a
|
||||
`PAYMENT_PROVIDER_*` code; raw `Error` is never thrown.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" }));
|
||||
|
||||
const mollie = registry.get("mollie");
|
||||
const intent = await mollie.createIntent({
|
||||
amount: { amount: 5000n, currency: "EUR" },
|
||||
description: "Order #42",
|
||||
returnUrl: "https://shop.example/return",
|
||||
});
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MollieProvider, formatMollieAmount, parseMollieAmount } from "@suaveplan/payment-provider-mollie";
|
||||
import type { FetchLike } from "@suaveplan/payment-providers";
|
||||
|
||||
// Inject a recording FetchLike for tests — no global monkey-patching.
|
||||
const recorded: { url: string; init?: RequestInit }[] = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
recorded.push({ url, init });
|
||||
return new Response(JSON.stringify({
|
||||
id: "tr_xyz",
|
||||
status: "paid",
|
||||
amount: { value: "12.34", currency: "EUR" },
|
||||
createdAt: "2026-01-01T00:00:00+00:00",
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
const provider = new MollieProvider({
|
||||
apiKey: "test_xyz",
|
||||
baseUrl: "https://mollie.mock/v2",
|
||||
timeoutMs: 5_000,
|
||||
fetch: recordingFetch,
|
||||
});
|
||||
|
||||
// Round-trip amount marshalling without hitting the network:
|
||||
const wire = formatMollieAmount(1234n); // "12.34"
|
||||
const back = parseMollieAmount(wire); // 1234n
|
||||
|
||||
// Drive the full lifecycle for assertions:
|
||||
const intent = await provider.createIntent({
|
||||
amount: { amount: 1234n, currency: "EUR" },
|
||||
});
|
||||
const captured = await provider.captureIntent(intent.id); // read-through
|
||||
const refund = await provider.refund({
|
||||
paymentIntentId: intent.id,
|
||||
amount: { amount: 1234n, currency: "EUR" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MollieProvider`
|
||||
|
||||
- `new MollieProvider(config: ApiKeyConfig)` — constructs the adapter.
|
||||
`config.apiKey` is required; `baseUrl`, `timeoutMs`, `fetch`,
|
||||
`signal`, and `webhookSecret` are optional. `baseUrl` defaults to
|
||||
`MOLLIE_DEFAULT_BASE_URL`; `timeoutMs` defaults to `30_000`.
|
||||
- `name: "mollie"` — literal-typed instance field.
|
||||
- `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>` —
|
||||
POST `${baseUrl}/payments`. Defaults `description` and `redirectUrl`
|
||||
when omitted.
|
||||
- `confirmIntent(intentId: string): Promise<PaymentIntent>` —
|
||||
read-through to `getIntent`.
|
||||
- `captureIntent(intentId: string): Promise<PaymentIntent>` —
|
||||
read-through to `getIntent` (Mollie auto-captures).
|
||||
- `cancelIntent(intentId: string): Promise<PaymentIntent>` — DELETE
|
||||
`${baseUrl}/payments/${intentId}`.
|
||||
- `refund(input: RefundInput): Promise<Refund>` — POST
|
||||
`${baseUrl}/payments/${intentId}/refunds`. Omits the body's `amount`
|
||||
field when `input.amount` is undefined (Mollie issues a full refund).
|
||||
- `getIntent(intentId: string): Promise<PaymentIntent>` — GET
|
||||
`${baseUrl}/payments/${intentId}`.
|
||||
- Throws: `PaymentProviderError` with code
|
||||
`PAYMENT_PROVIDER.HTTP_ERROR` / `PAYMENT_PROVIDER.TIMEOUT` /
|
||||
`PAYMENT_PROVIDER.PARSE_ERROR`.
|
||||
|
||||
### `MOLLIE_DEFAULT_BASE_URL: "https://api.mollie.com/v2"`
|
||||
|
||||
Production Mollie REST v2 base URL constant.
|
||||
|
||||
### `MOLLIE_PROVIDER_NAME: "mollie"`
|
||||
|
||||
Stable provider identifier.
|
||||
|
||||
### `formatMollieAmount(amount: bigint): string`
|
||||
|
||||
Format a minor-units `bigint` into a two-decimal string. Always
|
||||
produces exactly two minor digits, pads left for sub-major values
|
||||
(`1n` → `"0.01"`), and supports negative values.
|
||||
|
||||
### `parseMollieAmount(value: string): bigint`
|
||||
|
||||
Parse a Mollie wire-format decimal string into a minor-units `bigint`.
|
||||
Accepts no-decimal, single-decimal, two-decimal, or over-precision
|
||||
inputs; over-precision is truncated, never rounded.
|
||||
|
||||
### `mapMollieStatus(status: string): PaymentStatus`
|
||||
|
||||
Total map from Mollie's payment-status string to the canonical
|
||||
`PaymentStatus` union. Unknown values fall back to `"failed"`.
|
||||
|
||||
### `mapMollieRefundStatus(status: string): RefundStatus`
|
||||
|
||||
Total map from Mollie's refund-status string to the canonical
|
||||
`RefundStatus` union. Unknown values fall back to `"failed"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The package is universal — no Node-specific or browser-specific
|
||||
globals. The only runtime concept beyond the language standard is
|
||||
the WHATWG `fetch` surface, available in every supported runtime.
|
||||
- Tests inject a recording `FetchLike` closure into the constructor
|
||||
instead of mutating `globalThis.fetch`. This satisfies rule 8 (no
|
||||
mocks) and matches the binance / ecb sibling pattern.
|
||||
- `confirmIntent` and `captureIntent` are deliberate read-throughs to
|
||||
`getIntent`: Mollie auto-captures, so there is no separate confirm or
|
||||
capture step at the protocol level. Returning the current state
|
||||
preserves the `PaymentProvider` contract without falsifying the
|
||||
underlying behaviour.
|
||||
- `formatMollieAmount` and `parseMollieAmount` are inverses for every
|
||||
`bigint` produced from a Mollie response. Round-trip is exact for
|
||||
zero, single cent, exact major unit, and large values up to
|
||||
`2^53`-and-beyond range (`bigint` is arbitrary-precision; the helpers
|
||||
never go through `number`).
|
||||
- Over-precision in `parseMollieAmount` (`"10.123"` → `1012n`) is
|
||||
truncation, not rounding. Mollie emits exactly minor-unit precision;
|
||||
over-precision is upstream garbage and the safest action is to
|
||||
ignore the excess rather than to bias the result.
|
||||
- The card-brand normalisation accepts any of `visa` / `mastercard` /
|
||||
`amex` / `discover` / `diners` / `jcb` / `unionpay`; every other
|
||||
label collapses to `"unknown"` rather than leaking arbitrary strings
|
||||
into the `CardBrand` union.
|
||||
- Forwarded `AbortSignal` composition (caller `signal` + per-call
|
||||
timeout) is handled by the slim-core `httpRequest`; this package
|
||||
does not re-implement it.
|
||||
107
docs/money/payment/payment-provider-paypal/README.md
Normal file
107
docs/money/payment/payment-provider-paypal/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @suaveplan/payment-provider-paypal - PayPal payment provider for @suaveplan/payment-providers. Implements the PaymentProvider interface against the PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2 client-credentials token cache, injectable now/base64Encode/FetchLike seams, sandbox-endpoint shortcut, MoneyLike bigint marshalling, and structured PaymentProviderError surfaces. Universal — runs unchanged on Node, Bun, browsers, and Web Workers.
|
||||
|
||||
PayPal sibling adapter for the slim
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) foundation.
|
||||
Ships `PayPalProvider` — a `PaymentProvider` implementation against the
|
||||
PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2
|
||||
client-credentials token cache, injectable `now` / `base64Encode` /
|
||||
`FetchLike` seams, a `sandbox` shortcut, `MoneyLike` bigint marshalling,
|
||||
and structured `PaymentProviderError` surfaces. Universal — runs
|
||||
unchanged on Node, Bun, browsers, and Web Workers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
|
||||
- [mapping](./src/paypal/mapping.md) — Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders v2 + Payments Captures Refund wire shapes i…
|
||||
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-paypal @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new PayPalProvider({
|
||||
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
|
||||
sandbox: true,
|
||||
})
|
||||
);
|
||||
|
||||
const paypal = registry.get("paypal");
|
||||
const intent = await paypal.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
description: "Order #1234",
|
||||
captureMethod: "automatic",
|
||||
idempotencyKey: "order-1234",
|
||||
});
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `class PayPalProvider implements PaymentProvider` with literal-typed
|
||||
`name: "paypal"`
|
||||
- OAuth2 client-credentials token cache with 60 s clock-skew buffer,
|
||||
refreshed transparently on expiry
|
||||
- Injectable `now: NowFn`, `base64Encode: Base64EncodeFn`, and
|
||||
`fetch: FetchLike` for universal determinism and testability
|
||||
- `sandbox` flag → `https://api-m.sandbox.paypal.com`; explicit `baseUrl`
|
||||
always wins
|
||||
- `MoneyLike` bigint minor-unit amounts marshalled to PayPal's major-unit
|
||||
decimal-string format
|
||||
- `PayPal-Request-Id` idempotency-header support on `createIntent` and
|
||||
`refund`
|
||||
- Exhaustive PayPal-status → `PaymentStatus` / `RefundStatus`
|
||||
mapping; unknown statuses map to `failed`
|
||||
- Structured `PaymentProviderError` (from `@suaveplan/payment-providers`)
|
||||
on every failure path — `OAUTH_TOKEN_FAILURE`, `HTTP_ERROR`, `TIMEOUT`,
|
||||
`PARSE_ERROR` — always with `provider: "paypal"`
|
||||
- Universal: zero Node / browser / React imports; `sideEffects: false`
|
||||
- 100% Istanbul coverage; sibling `.md` per implementation file
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/paypal/paypal.md`](./src/paypal/paypal.md) — `PayPalProvider`
|
||||
class, `PayPalConfig` interface, lifecycle methods, OAuth token cache
|
||||
- [`src/paypal/mapping.md`](./src/paypal/mapping.md) — pure helpers:
|
||||
`mapStatus`, `mapRefundStatus`, `parseAmount`, `formatAmount`,
|
||||
`mapOrder`
|
||||
|
||||
## Foundation
|
||||
|
||||
This package depends on
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) for:
|
||||
|
||||
- `httpRequest`, `composeAbortSignal`, `FetchLike`
|
||||
- `OAuthConfig`, `NowFn`, `Base64EncodeFn`
|
||||
- `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `oauthTokenFailure`
|
||||
|
||||
and on [`@suaveplan/payment`](../payment/README.md) for the structural
|
||||
`PaymentProvider`, `PaymentIntent`, `Refund`, `PaymentStatus`,
|
||||
`RefundStatus`, `CreatePaymentIntentInput`, and `RefundInput` types.
|
||||
|
||||
## Related sibling packages
|
||||
|
||||
- `@suaveplan/payment-provider-stripe`
|
||||
- `@suaveplan/payment-provider-square`
|
||||
- `@suaveplan/payment-provider-mollie`
|
||||
- `@suaveplan/payment-provider-adyen`
|
||||
|
||||
All four follow the same pattern: depend on the slim core, expose a
|
||||
single `*Provider` class, register cleanly into `ProviderRegistry`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
146
docs/money/payment/payment-provider-paypal/src/paypal/mapping.md
Normal file
146
docs/money/payment/payment-provider-paypal/src/paypal/mapping.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# PayPal mapping helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders
|
||||
v2 + Payments Captures Refund wire shapes into the suaveplan-canonical
|
||||
`PaymentIntent` and `RefundStatus` types. Splitting them out of the
|
||||
provider class keeps the lifecycle methods focused on transport and lets
|
||||
the mapping rules be reviewed and tested in isolation. Every function is
|
||||
synchronous, side-effect-free, and total over the documented PayPal
|
||||
inputs (unknown statuses fall through to `failed`, missing decimals fall
|
||||
back to `00`).
|
||||
|
||||
## Features
|
||||
|
||||
- `mapStatus(ppStatus)` — exhaustive PayPal-order-status → suaveplan
|
||||
`PaymentStatus` mapping; unknown statuses (including the empty string)
|
||||
map to `failed`
|
||||
- `mapRefundStatus(status)` — PayPal-refund-status → suaveplan
|
||||
`RefundStatus` (`COMPLETED` → `succeeded`, `PENDING` → `pending`,
|
||||
everything else → `failed`)
|
||||
- `parseAmount(value)` — parses a PayPal major-unit decimal string into
|
||||
a `bigint` count of minor units (`"50.00"` → `5000n`, `"500"` → `50000n`)
|
||||
- `formatAmount(minorUnits)` — formats a non-negative `bigint` minor-unit
|
||||
amount into a PayPal major-unit decimal string with two fractional
|
||||
digits (`5000n` → `"50.00"`)
|
||||
- `mapOrder(order)` — full PayPal Orders v2 → `PaymentIntent` projection,
|
||||
summing every nested `payments.captures[].amount.value` into
|
||||
`capturedAmount`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatAmount,
|
||||
mapStatus,
|
||||
parseAmount,
|
||||
} from "@suaveplan/payment-provider-paypal/paypal/paypal";
|
||||
// (Re-exported via the package root.)
|
||||
|
||||
const status = mapStatus("COMPLETED"); // "captured"
|
||||
const minor = parseAmount("12.34"); // 1234n
|
||||
const wire = formatAmount(5000n); // "50.00"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`mapOrder` is the round-trip function — it consumes a raw PayPal Orders v2
|
||||
JSON shape and returns the `PaymentIntent` the rest of the
|
||||
codebase consumes:
|
||||
|
||||
```ts
|
||||
import type { PayPalOrder } from "@suaveplan/payment-provider-paypal";
|
||||
import { mapOrder } from "@suaveplan/payment-provider-paypal/paypal/paypal";
|
||||
|
||||
const raw: PayPalOrder = {
|
||||
id: "ORDER_42",
|
||||
status: "COMPLETED",
|
||||
intent: "AUTHORIZE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: { currency_code: "EUR", value: "99.99" },
|
||||
payments: {
|
||||
captures: [
|
||||
{
|
||||
id: "CAP1",
|
||||
amount: { value: "50.00", currency_code: "EUR" },
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: "CAP2",
|
||||
amount: { value: "49.99", currency_code: "EUR" },
|
||||
status: "COMPLETED",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
create_time: "2026-05-17T00:00:00Z",
|
||||
update_time: "2026-05-17T00:00:01Z",
|
||||
};
|
||||
|
||||
const intent = mapOrder(raw);
|
||||
intent.status; // "captured"
|
||||
intent.amount; // { amount: 9999n, currency: "EUR" }
|
||||
intent.capturedAmount; // { amount: 9999n, currency: "EUR" }
|
||||
intent.captureMethod; // "manual" (because intent === "AUTHORIZE")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapStatus(ppStatus: string): PaymentStatus`
|
||||
|
||||
| PayPal status | Returns |
|
||||
|---|---|
|
||||
| `CREATED` / `SAVED` | `"created"` |
|
||||
| `APPROVED` | `"authorized"` |
|
||||
| `VOIDED` | `"canceled"` |
|
||||
| `COMPLETED` | `"captured"` |
|
||||
| `PAYER_ACTION_REQUIRED` | `"pending"` |
|
||||
| anything else | `"failed"` |
|
||||
|
||||
### `mapRefundStatus(status: string): RefundStatus`
|
||||
|
||||
| PayPal refund status | Returns |
|
||||
|---|---|
|
||||
| `COMPLETED` | `"succeeded"` |
|
||||
| `PENDING` | `"pending"` |
|
||||
| anything else | `"failed"` |
|
||||
|
||||
### `parseAmount(value: string): bigint`
|
||||
|
||||
Returns the minor-unit count. Splits on the first `.`, pads the
|
||||
fractional half to exactly two digits (zero-decimal currencies like JPY
|
||||
have no `.` separator and pad to `"00"`), then composes
|
||||
`BigInt(major) * 100n + BigInt(minorPadded)`.
|
||||
|
||||
### `formatAmount(minorUnits: bigint): string`
|
||||
|
||||
Returns `"<major>.<minor>"` where `minor` is zero-padded to two digits.
|
||||
`MoneyLike.amount` is non-negative by contract, so no sign
|
||||
handling is performed.
|
||||
|
||||
### `mapOrder(order: PayPalOrder): PaymentIntent`
|
||||
|
||||
Returns the full `PaymentIntent` derived from a PayPal Orders v2
|
||||
response. The first purchase unit drives `amount` and currency; all
|
||||
captures across that unit sum into `capturedAmount`. `refundedAmount`
|
||||
is always reported as `0n` here — the provider's `refund` method
|
||||
returns standalone `Refund` records rather than nested refund totals.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All functions are synchronous and have no I/O, no logging, and no
|
||||
observable side effects. They are safe to call from any tier.
|
||||
- `mapOrder` deliberately omits PayPal's nested `payments.refunds[]`
|
||||
entries from the `PaymentIntent.refunds` list — that surface is
|
||||
reserved for future expansion when `@suaveplan/payment` adds a
|
||||
refund-list field to `PaymentIntent`.
|
||||
- The default currency `"USD"` is only reached when PayPal returns an
|
||||
order with no purchase units. That happens for void responses and for
|
||||
malformed test fixtures; production payment-intent responses always
|
||||
carry a purchase unit.
|
||||
- `formatAmount` and `parseAmount` are inverses for any non-negative
|
||||
minor-unit `bigint` ≤ `2^53` (PayPal max). Property-style round-trip
|
||||
tests live in `paypal.test.ts`.
|
||||
203
docs/money/payment/payment-provider-paypal/src/paypal/paypal.md
Normal file
203
docs/money/payment/payment-provider-paypal/src/paypal/paypal.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# PayPalProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PayPalProvider` is the PayPal-flavoured implementation of the
|
||||
`PaymentProvider` contract exported by `@suaveplan/payment`. It maps every
|
||||
suaveplan payment-lifecycle method onto the PayPal Orders v2 and Payments
|
||||
Captures Refund APIs, takes care of the OAuth2 client-credentials handshake
|
||||
once per token lifetime, and surfaces every failure as a typed
|
||||
`PaymentProviderError` raised by the slim core. The package exists so that
|
||||
applications can register a PayPal adapter into a `ProviderRegistry`
|
||||
without writing the token-cache, idempotency-header, status-mapping, or
|
||||
decimal-string-marshalling boilerplate the upstream API requires.
|
||||
|
||||
The provider is deliberately universal: it has no `node:*` /
|
||||
`bun:*` / `react` / DOM imports, and every runtime dependency (the
|
||||
HTTP client, the OAuth Basic header's base-64 encoder, and the clock the
|
||||
token cache uses) is injected through the constructor config. That means
|
||||
the same class runs unchanged on Node, Bun, modern browsers, and Web
|
||||
Workers — and that tests can replace the HTTP transport, the clock, and
|
||||
the base-64 encoder with closures, so the package needs no global
|
||||
mocking and no platform-specific polyfills.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `PaymentProvider` surface
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) with a literal-typed `name: "paypal"` discriminant
|
||||
- OAuth2 client-credentials token cache with 60 s clock-skew buffer;
|
||||
reused across every method call until expiry, refreshed on demand
|
||||
- Injectable `now: NowFn` for the token-cache TTL (tests can pin expiry
|
||||
without freezing global `Date`)
|
||||
- Injectable `base64Encode: Base64EncodeFn` for the `Authorization: Basic`
|
||||
header (defaults to `globalThis.btoa`; runtimes without `btoa` supply
|
||||
their own)
|
||||
- Injectable `fetch: FetchLike` for every HTTP call (tests pass a recorder
|
||||
closure; production passes a custom client when needed)
|
||||
- `sandbox` flag toggles the default `https://api-m.sandbox.paypal.com`
|
||||
endpoint; an explicit `baseUrl` always wins
|
||||
- `MoneyLike` `bigint` minor-unit amounts marshalled to PayPal's
|
||||
major-unit decimal-string format (`5000n` ↔ `"50.00"`)
|
||||
- Idempotency keys surface as PayPal's `PayPal-Request-Id` header on
|
||||
`createIntent` and `refund`
|
||||
- Every failure surfaces as `PaymentProviderError` with `provider: "paypal"`,
|
||||
the URL on the `url` discriminant, and the underlying cause attached
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
|
||||
|
||||
const paypal = new PayPalProvider({
|
||||
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
|
||||
});
|
||||
|
||||
const intent = await paypal.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
description: "Order #1234",
|
||||
captureMethod: "automatic",
|
||||
idempotencyKey: "order-1234",
|
||||
});
|
||||
console.log(intent.id, intent.status); // "ORDER_xxx" "created"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Combine the sandbox flag, an injected `FetchLike`, an injected `NowFn`,
|
||||
and a `ProviderRegistry` from the slim core:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type FetchLike,
|
||||
ProviderRegistry,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
|
||||
|
||||
const calls: string[] = [];
|
||||
const recorder: FetchLike = async (url, init) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
// Forward to the real PayPal sandbox via a custom client, add
|
||||
// logging / retry / circuit-breaker around it, etc.
|
||||
return fetch(url, init);
|
||||
};
|
||||
|
||||
let clock = Date.parse("2026-05-17T00:00:00Z");
|
||||
|
||||
const paypal = new PayPalProvider({
|
||||
clientId: process.env.PAYPAL_SANDBOX_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.PAYPAL_SANDBOX_CLIENT_SECRET ?? "",
|
||||
sandbox: true,
|
||||
fetch: recorder,
|
||||
now: () => clock,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(paypal);
|
||||
|
||||
const intent = await registry
|
||||
.get("paypal")
|
||||
.createIntent({
|
||||
amount: { amount: 999n, currency: "EUR" },
|
||||
captureMethod: "manual",
|
||||
});
|
||||
|
||||
// Advance the clock past the token expiry — the next call refreshes.
|
||||
clock += 4_000_000;
|
||||
const confirmed = await paypal.confirmIntent(intent.id);
|
||||
|
||||
console.log(calls);
|
||||
// → ["POST https://api-m.sandbox.paypal.com/v1/oauth2/token",
|
||||
// "POST https://api-m.sandbox.paypal.com/v2/checkout/orders",
|
||||
// "POST https://api-m.sandbox.paypal.com/v1/oauth2/token",
|
||||
// "POST https://api-m.sandbox.paypal.com/v2/checkout/orders/.../authorize"]
|
||||
console.log(confirmed.status); // "authorized"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class PayPalProvider implements PaymentProvider`
|
||||
|
||||
Constructor: `new PayPalProvider(config: PayPalConfig)`.
|
||||
|
||||
| Field of `PayPalConfig` | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `clientId` | `string` | — | PayPal REST app client ID |
|
||||
| `clientSecret` | `string` | — | PayPal REST app client secret |
|
||||
| `baseUrl` | `string?` | derived | Wins over `sandbox` when set |
|
||||
| `sandbox` | `boolean?` | `false` | Selects `https://api-m.sandbox.paypal.com` when `true` |
|
||||
| `timeoutMs` | `number?` | `30_000` | Per-HTTP-call timeout |
|
||||
| `fetch` | `FetchLike?` | `globalThis.fetch` | Injectable HTTP client |
|
||||
| `signal` | `AbortSignal?` | — | External signal composed with the per-call timeout |
|
||||
| `now` | `NowFn?` | `Date.now` | Clock for the token cache + `cancelIntent` timestamps |
|
||||
| `base64Encode` | `Base64EncodeFn?` | `globalThis.btoa` | OAuth Basic-header encoder |
|
||||
| `webhookSecret` | `string?` | — | Carried through for symmetry; not yet wired to a verifier |
|
||||
|
||||
Public methods:
|
||||
|
||||
- `name: "paypal"` — literal-typed discriminant
|
||||
- `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`
|
||||
- `confirmIntent(intentId: string): Promise<PaymentIntent>`
|
||||
- `captureIntent(intentId: string): Promise<PaymentIntent>`
|
||||
- `cancelIntent(intentId: string): Promise<PaymentIntent>` — returns a
|
||||
synthesised intent because PayPal's void endpoint returns no body
|
||||
- `refund(input: RefundInput): Promise<Refund>`
|
||||
- `getIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
Errors: every method may throw `PaymentProviderError` with one of
|
||||
`PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE`, `…HTTP_ERROR`, `…TIMEOUT`, or
|
||||
`…PARSE_ERROR`. The `provider` discriminant is always `"paypal"`.
|
||||
|
||||
### `interface PayPalConfig extends OAuthConfig`
|
||||
|
||||
See the table above. Re-exported from `@suaveplan/payment-provider-paypal`
|
||||
and from the `./paypal/paypal` subpath.
|
||||
|
||||
### Status mapping
|
||||
|
||||
| PayPal status | `PaymentStatus` |
|
||||
|---|---|
|
||||
| `CREATED` / `SAVED` | `created` |
|
||||
| `APPROVED` | `authorized` |
|
||||
| `VOIDED` | `canceled` |
|
||||
| `COMPLETED` | `captured` |
|
||||
| `PAYER_ACTION_REQUIRED` | `pending` |
|
||||
| anything else | `failed` |
|
||||
|
||||
| PayPal refund status | `RefundStatus` |
|
||||
|---|---|
|
||||
| `COMPLETED` | `succeeded` |
|
||||
| `PENDING` | `pending` |
|
||||
| anything else | `failed` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The OAuth token is cached in-memory on the provider instance. Two
|
||||
instances do not share a cache; build a singleton at the application
|
||||
composition root if you want a process-wide cache.
|
||||
- The token TTL is `tokenAcquiredAt + (expires_in * 1000) - 60_000` to
|
||||
guard against PayPal-side clock skew. Tests pin the expiry by injecting
|
||||
`now` and asserting the recorded call count after the boundary.
|
||||
- `cancelIntent` returns a synthesised `PaymentIntent` because PayPal's
|
||||
void endpoint returns no body. The timestamps come from
|
||||
`new Date(now()).toISOString()` so tests can pin them by injecting a
|
||||
deterministic `now`.
|
||||
- Amount marshalling assumes the `MoneyLike` `amount` is denominated in
|
||||
the smallest indivisible unit of `currency` (e.g. cents for USD). Zero-
|
||||
decimal currencies (JPY) are not yet auto-detected — pass the major-unit
|
||||
value as the bigint when using them and trim the `.00` suffix in your
|
||||
consumer if needed; future work tracked in the parity checklist.
|
||||
- The `Refund` returned from `refund` carries the caller's
|
||||
`paymentIntentId` verbatim, not the PayPal `capture_id`, because the
|
||||
`Refund.paymentIntentId` field semantically points to the
|
||||
capture / intent the caller addressed.
|
||||
- Adding new failure paths: surface them through the slim core's
|
||||
factories (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`)
|
||||
so the `provider` discriminant stays consistent. Never raise a bare
|
||||
`Error` from this package.
|
||||
104
docs/money/payment/payment-provider-square/README.md
Normal file
104
docs/money/payment/payment-provider-square/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @suaveplan/payment-provider-square - Universal Square Payments API adapter for @suaveplan/payment. Ships SquareProvider (implements PaymentProvider with literal name "square"), SquareConfig (extends ApiKeyConfig with locationId, sandbox, apiVersion, randomId), and the Square-specific payment/refund mappers. Built on the @suaveplan/payment-providers foundation (httpRequest, composeAbortSignal, PAYMENT_PROVIDER_CODES, PaymentProviderError).
|
||||
|
||||
Universal Square Payments API adapter for `@suaveplan/payment`.
|
||||
Ships the `SquareProvider` class (implements the structural
|
||||
`PaymentProvider` contract with literal `name: "square"`), the
|
||||
`SquareConfig` interface (extends `ApiKeyConfig` from
|
||||
`@suaveplan/payment-providers` with the Square-specific `locationId`,
|
||||
`sandbox`, `apiVersion`, and `randomId` fields), and the
|
||||
Square-specific payment / refund mappers (`mapPayment`, `mapRefund`,
|
||||
`mapPaymentStatus`, `mapRefundStatus`).
|
||||
|
||||
Built on the `@suaveplan/payment-providers` foundation — every
|
||||
outbound HTTP call goes through `httpRequest`, which handles JSON
|
||||
serialisation, the per-call timeout signal, external `AbortSignal`
|
||||
composition, and typed `PaymentProviderError` raising.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [payment](./src/mapping/payment.md) — Translates between Square's `Payment` envelope (as returned by `POST /v2/payments`, `POST /v2/payments/{id}/complete`,…
|
||||
- [refund](./src/mapping/refund.md) — Translates Square's `Refund` envelope (as returned by `POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. T…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [types](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
- [config](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-square @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new SquareProvider({
|
||||
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
|
||||
locationId: process.env.SQUARE_LOCATION_ID ?? "",
|
||||
})
|
||||
);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("square") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`SquareProvider`** — full Square Payments API lifecycle
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) with literal `name: "square"`.
|
||||
- **`SquareConfig`** — extends `ApiKeyConfig`; adds required
|
||||
`locationId` plus optional `sandbox`, `apiVersion`, `randomId`.
|
||||
- **Sandbox toggle** — `sandbox: true` flips the default base URL to
|
||||
`https://connect.squareupsandbox.com/v2`. Explicit `baseUrl`
|
||||
always wins.
|
||||
- **Pinned `Square-Version`** — defaults to `2024-01-18`; override via
|
||||
`apiVersion` to opt into a newer API surface.
|
||||
- **Injectable `randomId`** — fills missing `idempotency_key` fields
|
||||
on `CreatePayment` + `RefundPayment` calls. Tests pass a
|
||||
deterministic counter; production defaults to `crypto.randomUUID()`.
|
||||
- **Universal** — no `node:`, `bun:`, `fs`, `path`, `crypto`
|
||||
imports; runs on Node, Bun, browsers, and Web Workers.
|
||||
- **`PaymentProviderError`-only** — every failure surfaces as a
|
||||
typed `PaymentProviderError` via the foundation's `httpRequest`.
|
||||
- **100% test coverage** (Istanbul) + sibling `.md` per
|
||||
implementation file + 5 subpath exports.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/square/square.md`](./src/square/square.md) — `SquareProvider`
|
||||
class and lifecycle methods.
|
||||
- [`src/types/config.md`](./src/types/config.md) — `SquareConfig`,
|
||||
`SQUARE_DEFAULT_BASE_URL`, `SQUARE_SANDBOX_BASE_URL`,
|
||||
`SQUARE_DEFAULT_API_VERSION`.
|
||||
- [`src/mapping/payment.md`](./src/mapping/payment.md) — `mapPayment`,
|
||||
`mapPaymentStatus`, `SquarePaymentDto`, `SquareMoney`, `SquareCard`.
|
||||
- [`src/mapping/refund.md`](./src/mapping/refund.md) — `mapRefund`,
|
||||
`mapRefundStatus`, `SquareRefundDto`.
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment-providers` — `ProviderRegistry`,
|
||||
`definePaymentProvider`, `httpRequest`, `composeAbortSignal`,
|
||||
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`,
|
||||
`ApiKeyConfig`/`OAuthConfig`/`MerchantKeyConfig`,
|
||||
`FetchLike`/`RandomIdFn`/`NowFn`/`Base64EncodeFn`.
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface, the canonical `PaymentIntent` /
|
||||
`Refund` / `PaymentStatus` / `RefundStatus` shapes.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Square payment mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates between Square's `Payment` envelope (as returned by
|
||||
`POST /v2/payments`, `POST /v2/payments/{id}/complete`,
|
||||
`POST /v2/payments/{id}/cancel`, and `GET /v2/payments/{id}`) and
|
||||
`@suaveplan/payment`'s `PaymentIntent` shape. Square's status values
|
||||
are upper-snake-case (`COMPLETED`, `APPROVED`, ...) while uses
|
||||
the lower-case `PaymentStatus` union; the mapping pins this contract
|
||||
in one place so the provider class stays focused on transport.
|
||||
|
||||
The mapper also captures the two implicit branches Square encodes via
|
||||
optional fields rather than dedicated states: `approved_money`
|
||||
present ↔ manual capture, `card_details` present ↔ card-shaped
|
||||
`PaymentMethod`.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapPaymentStatus(squareStatus)` — exhaustive five-status map with a
|
||||
defensive `"failed"` fallback for forward-rolled API versions.
|
||||
- `mapPayment(p)` — full envelope-to-`PaymentIntent` shape including
|
||||
`capturedAmount` derivation (`p.total_money` on `COMPLETED`, `0n`
|
||||
otherwise), `captureMethod` derivation (`approved_money` ↔
|
||||
`"manual"`), and optional `PaymentMethod` derivation from
|
||||
`card_details`.
|
||||
- `SquareCard` / `SquareMoney` / `SquarePaymentDto` — typed views of
|
||||
the Square API envelope that downstream code can rely on instead of
|
||||
threading `unknown` through call sites.
|
||||
- Card-brand normalisation — Square returns brands upper-case; the
|
||||
mapper lower-cases and validates against the `CardBrand`
|
||||
union, falling back to `"unknown"` for non-canonical brands
|
||||
(Square gift cards, JCB sub-brands, etc.).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
mapPaymentStatus("COMPLETED"); // "captured"
|
||||
|
||||
const intent = mapPayment({
|
||||
id: "sq_pay_123",
|
||||
status: "COMPLETED",
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
console.log(intent.status); // "captured"
|
||||
console.log(intent.capturedAmount.amount); // 5000n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Map an authorized-but-not-captured payment with card metadata:
|
||||
|
||||
```ts
|
||||
import { mapPayment } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const intent = mapPayment({
|
||||
id: "sq_pay_456",
|
||||
status: "APPROVED",
|
||||
amount_money: { amount: 9999, currency: "EUR" },
|
||||
total_money: { amount: 9999, currency: "EUR" },
|
||||
approved_money: { amount: 9999, currency: "EUR" },
|
||||
customer_id: "cust_42",
|
||||
note: "Pre-authorisation for hold",
|
||||
card_details: { card: { card_brand: "MASTERCARD", last_4: "4242" } },
|
||||
created_at: "2026-02-01T10:00:00Z",
|
||||
updated_at: "2026-02-01T10:00:00Z",
|
||||
});
|
||||
|
||||
console.log(intent.captureMethod); // "manual"
|
||||
console.log(intent.status); // "authorized"
|
||||
console.log(intent.capturedAmount.amount); // 0n
|
||||
console.log(intent.paymentMethod);
|
||||
// { id: "sq_card_sq_pay_456", type: "card", brand: "mastercard", last4: "4242" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapPaymentStatus(squareStatus: string): PaymentStatus`
|
||||
|
||||
Maps Square status → suaveplan status. Known mappings:
|
||||
|
||||
| Square | suaveplan |
|
||||
|---|---|
|
||||
| `APPROVED` | `"authorized"` |
|
||||
| `COMPLETED` | `"captured"` |
|
||||
| `CANCELED` | `"canceled"` |
|
||||
| `FAILED` | `"failed"` |
|
||||
| `PENDING` | `"pending"` |
|
||||
| _other_ | `"failed"` |
|
||||
|
||||
### `mapPayment(p: SquarePaymentDto): PaymentIntent`
|
||||
|
||||
Shape the envelope. `capturedAmount.amount` is
|
||||
`BigInt(p.total_money.amount)` when `p.status === "COMPLETED"`,
|
||||
otherwise `0n`. `captureMethod` is `"manual"` when `p.approved_money`
|
||||
is set. `paymentMethod` is set when `p.card_details` is present.
|
||||
`refundedAmount.amount` is `0n` (refund totals are tracked by
|
||||
`PaymentEngine`, not derived here). `refunds` is an empty array.
|
||||
|
||||
### Types
|
||||
|
||||
- `SquareMoney` — `{ amount: number; currency: string }`.
|
||||
- `SquareCard` — `{ card_brand: string; last_4: string }`.
|
||||
- `SquarePaymentDto` — the subset of Square's `Payment` envelope this
|
||||
package consumes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The status map is frozen at module init so accidental mutation
|
||||
throws in strict mode.
|
||||
- The card-brand normaliser is intentionally conservative: any brand
|
||||
Square introduces that does not match the `CardBrand` union
|
||||
falls back to `"unknown"` so downstream code never receives an
|
||||
invalid discriminant.
|
||||
- `paymentMethod.id` is synthesised as `sq_card_<payment-id>` because
|
||||
Square does not expose a stable card-fingerprint on the inline
|
||||
`card_details.card` object. Consumers needing a true stable card id
|
||||
should hydrate it from Square's Cards API and overlay it.
|
||||
120
docs/money/payment/payment-provider-square/src/mapping/refund.md
Normal file
120
docs/money/payment/payment-provider-square/src/mapping/refund.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Square refund mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates Square's `Refund` envelope (as returned by
|
||||
`POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. The
|
||||
mapping is small but load-bearing: Square encodes refund lifecycle
|
||||
states as upper-snake-case strings while the `RefundStatus`
|
||||
union is lower-case, and Square's `reason` is a free-form string
|
||||
where the `RefundReason` union is a closed discriminator.
|
||||
|
||||
The reason mapping is intentionally narrow — Square never reports
|
||||
machine-readable reason codes, so any non-empty reason collapses to
|
||||
`"requested_by_customer"` and an absent / empty reason collapses to
|
||||
`"other"`. Consumers that need richer reason granularity must layer
|
||||
their own mapping on top.
|
||||
|
||||
## Features
|
||||
|
||||
- `mapRefundStatus(squareStatus)` — exhaustive three-state map
|
||||
(`COMPLETED`, `PENDING`, everything-else) with `"failed"` covering
|
||||
Square's `REJECTED` and `FAILED` plus any forward-rolled values.
|
||||
- `mapRefund(r)` — full envelope-to-`Refund` shape including bigint
|
||||
amount coercion and reason defaulting.
|
||||
- `SquareRefundDto` — typed view of the Square refund envelope.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
mapRefund,
|
||||
mapRefundStatus,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
mapRefundStatus("COMPLETED"); // "succeeded"
|
||||
|
||||
const refund = mapRefund({
|
||||
id: "sq_ref_123",
|
||||
payment_id: "sq_pay_123",
|
||||
amount_money: { amount: 2000, currency: "USD" },
|
||||
status: "COMPLETED",
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
reason: "Customer changed mind",
|
||||
});
|
||||
|
||||
console.log(refund.status); // "succeeded"
|
||||
console.log(refund.reason); // "requested_by_customer"
|
||||
console.log(refund.amount.amount); // 2000n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Show all three branches in turn — including the reason fallback and
|
||||
the `REJECTED → "failed"` collapse:
|
||||
|
||||
```ts
|
||||
import { mapRefund } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const base = {
|
||||
id: "sq_ref_a",
|
||||
payment_id: "sq_pay_a",
|
||||
amount_money: { amount: 500, currency: "GBP" },
|
||||
created_at: "2026-03-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const pending = mapRefund({ ...base, status: "PENDING" });
|
||||
console.log(pending.status); // "pending"
|
||||
console.log(pending.reason); // "other" (no reason supplied)
|
||||
|
||||
const rejected = mapRefund({ ...base, status: "REJECTED", reason: "" });
|
||||
console.log(rejected.status); // "failed"
|
||||
console.log(rejected.reason); // "other" (empty string)
|
||||
|
||||
const succeeded = mapRefund({
|
||||
...base,
|
||||
status: "COMPLETED",
|
||||
reason: "duplicate charge",
|
||||
});
|
||||
console.log(succeeded.status); // "succeeded"
|
||||
console.log(succeeded.reason); // "requested_by_customer"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `mapRefundStatus(squareRefundStatus: string): RefundStatus`
|
||||
|
||||
| Square | suaveplan |
|
||||
|---|---|
|
||||
| `COMPLETED` | `"succeeded"` |
|
||||
| `PENDING` | `"pending"` |
|
||||
| _other_ (`REJECTED`, `FAILED`, ...) | `"failed"` |
|
||||
|
||||
### `mapRefund(r: SquareRefundDto): Refund`
|
||||
|
||||
Produces a `Refund` whose `amount` mirrors `r.amount_money` (with
|
||||
the amount widened to `bigint`), whose `status` comes from
|
||||
`mapRefundStatus`, whose `reason` is `"requested_by_customer"` when
|
||||
`r.reason` is a non-empty string and `"other"` otherwise, and whose
|
||||
`createdAt` is `r.created_at` verbatim. `paymentIntentId` is set to
|
||||
`r.payment_id`.
|
||||
|
||||
### Types
|
||||
|
||||
- `SquareRefundDto` — the subset of Square's `Refund` envelope this
|
||||
package consumes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The reason fallback to `"other"` is deliberate: Square's free-form
|
||||
`reason` carries operational notes ("customer called", "fraud
|
||||
flagged by ops"), not the discriminated reasons suaveplan tracks.
|
||||
Treating any non-empty value as `"requested_by_customer"` matches
|
||||
the upstream package's behaviour verbatim.
|
||||
- `mapRefundStatus` is exported separately so callers consuming raw
|
||||
Square webhooks (where the refund envelope arrives outside an HTTP
|
||||
response) can reuse the same mapping logic without round-tripping
|
||||
through `mapRefund`.
|
||||
- `r.amount_money.amount` is coerced via `BigInt()`. Square uses
|
||||
smallest-unit integers, so the coercion is exact for every
|
||||
documented currency.
|
||||
175
docs/money/payment/payment-provider-square/src/square/square.md
Normal file
175
docs/money/payment/payment-provider-square/src/square/square.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# SquareProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SquareProvider` is the Square Payments API adapter for
|
||||
`@suaveplan/payment`. It implements the structural `PaymentProvider`
|
||||
contract with `name: "square"` (narrowed at the type level) and
|
||||
routes every outbound HTTP call through the
|
||||
`@suaveplan/payment-providers` foundation's `httpRequest`, which
|
||||
handles JSON serialisation, the per-call timeout signal, external
|
||||
`AbortSignal` composition, and typed `PaymentProviderError` raising.
|
||||
|
||||
The provider preserves the upstream `square.ts` surface verbatim — the
|
||||
six lifecycle methods (`createIntent`, `confirmIntent`, `captureIntent`,
|
||||
`cancelIntent`, `refund`, `getIntent`), the canonical request bodies,
|
||||
and the status mapping — while wiring the previously-hard-coded
|
||||
`globalThis.crypto.randomUUID()` call onto the foundation's injectable
|
||||
`RandomIdFn` seam. Tests pass a deterministic counter instead of
|
||||
monkey-patching `globalThis.fetch`.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `PaymentProvider` with literal `name: "square"`.
|
||||
- Sends `Authorization: Bearer <apiKey>` and `Square-Version` on every
|
||||
request (`Square-Version` defaults to `2024-01-18`; override via
|
||||
`SquareConfig.apiVersion`).
|
||||
- Sandbox toggle (`sandbox: true`) flips the default base URL to
|
||||
`https://connect.squareupsandbox.com/v2`; an explicit `baseUrl`
|
||||
always wins.
|
||||
- Idempotency keys filled from the injected `randomId` seam when the
|
||||
caller omits one — deterministic in tests, `crypto.randomUUID()` by
|
||||
default in production.
|
||||
- Multi-location merchants supported via the required
|
||||
`SquareConfig.locationId` field, sent as `location_id` on every
|
||||
`CreatePayment` body.
|
||||
- All errors raised as `PaymentProviderError` (foundation contract);
|
||||
the package itself never throws `Error` instances directly.
|
||||
- Universal — no `node:`, `bun:`, `fs`, `path`, `crypto` imports.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const square = new SquareProvider({
|
||||
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
|
||||
locationId: process.env.SQUARE_LOCATION_ID ?? "",
|
||||
});
|
||||
|
||||
const intent = await square.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
description: "Order #123",
|
||||
});
|
||||
|
||||
const captured = await square.captureIntent(intent.id);
|
||||
console.log(captured.status); // "captured"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Inject deterministic seams (FetchLike + RandomIdFn) for testing,
|
||||
target the sandbox, pin the API version, and compose with an external
|
||||
`AbortSignal`:
|
||||
|
||||
```ts
|
||||
import type { FetchLike } from "@suaveplan/payment-providers";
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const controller = new AbortController();
|
||||
const fakeFetch: FetchLike = async (url, init) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
payment: {
|
||||
id: "sq_pay_1",
|
||||
status: "COMPLETED",
|
||||
amount_money: { amount: 5000, currency: "USD" },
|
||||
total_money: { amount: 5000, currency: "USD" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
const square = new SquareProvider({
|
||||
apiKey: "sandbox-token",
|
||||
locationId: "L1",
|
||||
sandbox: true,
|
||||
apiVersion: "2024-09-19",
|
||||
fetch: fakeFetch,
|
||||
signal: controller.signal,
|
||||
randomId: () => "deterministic-idempotency-key",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const intent = await square.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SquareProvider implements PaymentProvider`
|
||||
|
||||
#### `constructor(config: SquareConfig)`
|
||||
|
||||
Accepts a `SquareConfig` (see `../types/config.md`). Required:
|
||||
`apiKey`, `locationId`. Optional: `baseUrl`, `sandbox`, `apiVersion`,
|
||||
`randomId`, `fetch`, `signal`, `timeoutMs`, `webhookSecret`.
|
||||
|
||||
#### `readonly name: "square"`
|
||||
|
||||
Literal — narrows at the type level for discriminated unions.
|
||||
|
||||
#### `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments` with `amount_money`, `idempotency_key`
|
||||
(falls back to `randomId()`), `autocomplete` (`true` unless
|
||||
`captureMethod === "manual"`), `location_id`, and the optional
|
||||
`customer_id`, `note`, `source_id` fields. Returns the mapped
|
||||
`PaymentIntent`.
|
||||
|
||||
#### `confirmIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
No-op for Square (payments are auto-confirmed). Delegates to
|
||||
`getIntent(intentId)`.
|
||||
|
||||
#### `captureIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments/<intentId>/complete` with no body.
|
||||
|
||||
#### `cancelIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
POSTs `<baseUrl>/payments/<intentId>/cancel` with no body.
|
||||
|
||||
#### `refund(input: RefundInput): Promise<Refund>`
|
||||
|
||||
POSTs `<baseUrl>/refunds` with `payment_id`, `idempotency_key`
|
||||
(falls back to `randomId()`), optional `amount_money`, and optional
|
||||
`reason`. Returns the mapped `Refund`.
|
||||
|
||||
#### `getIntent(intentId: string): Promise<PaymentIntent>`
|
||||
|
||||
GETs `<baseUrl>/payments/<intentId>`.
|
||||
|
||||
### Throws
|
||||
|
||||
Every method throws `PaymentProviderError` on HTTP failure, timeout,
|
||||
or JSON parse failure (raised by the foundation's `httpRequest`).
|
||||
`err.provider` is always `"square"`; `err.httpStatus` is set when the
|
||||
failure originated from a non-ok response.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The provider stores its `FetchLike` / `AbortSignal` / `timeoutMs` /
|
||||
`randomId` once at construction and passes them through to every
|
||||
`httpRequest` call so behaviour is deterministic across method
|
||||
invocations.
|
||||
- `location_id` is sent on `CreatePayment` only — Square does not
|
||||
require it on `CompletePayment` / `CancelPayment` / `Refund` calls.
|
||||
- The default `randomId` is a closure that calls `crypto.randomUUID()`
|
||||
at call time. Runtimes without a global `crypto` MUST inject their
|
||||
own `RandomIdFn`.
|
||||
- The class does not duplicate webhook verification logic; Square
|
||||
webhook signatures are verified by `@suaveplan/payment`'s
|
||||
`WebhookVerifier`, which is provider-agnostic.
|
||||
- All formatting flows through the foundation: caller-supplied
|
||||
`Content-Type` / `Accept` are preserved by `httpRequest`'s shallow
|
||||
header merge, but the Square-specific `Authorization` and
|
||||
`Square-Version` headers always win on collision.
|
||||
102
docs/money/payment/payment-provider-square/src/types/config.md
Normal file
102
docs/money/payment/payment-provider-square/src/types/config.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# SquareConfig and default constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the configuration shape `SquareProvider` accepts and the three
|
||||
default constants the provider falls back to when the caller omits the
|
||||
corresponding optional field. The interface extends the shared
|
||||
`ApiKeyConfig` from `@suaveplan/payment-providers` so every common
|
||||
injectable seam — `fetch`, `timeoutMs`, `signal`, `baseUrl`,
|
||||
`webhookSecret` — is inherited rather than redeclared.
|
||||
|
||||
## Features
|
||||
|
||||
- `SquareConfig extends ApiKeyConfig` with `locationId: string`
|
||||
(required), `sandbox?: boolean`, `apiVersion?: string`, and
|
||||
`randomId?: RandomIdFn`.
|
||||
- Three exported constants pin the production base URL, the sandbox
|
||||
base URL, and the default `Square-Version` header value so they
|
||||
show up exactly once across the package and are auditable.
|
||||
- Type-only — the file ships no runtime objects beyond the constants;
|
||||
the interface is erased at build time.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SquareConfig } from "@suaveplan/payment-provider-square";
|
||||
|
||||
const config: SquareConfig = {
|
||||
apiKey: "sq0atp-...",
|
||||
locationId: "L1234567890",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Pin every optional field — useful for the sandbox + deterministic
|
||||
testing combination:
|
||||
|
||||
```ts
|
||||
import type { SquareConfig } from "@suaveplan/payment-provider-square";
|
||||
import {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
} from "@suaveplan/payment-provider-square";
|
||||
|
||||
const config: SquareConfig = {
|
||||
apiKey: "sq0atp-sandbox-...",
|
||||
locationId: "L_SANDBOX",
|
||||
sandbox: true,
|
||||
apiVersion: SQUARE_DEFAULT_API_VERSION,
|
||||
randomId: () => "deterministic-id",
|
||||
timeoutMs: 5_000,
|
||||
baseUrl: SQUARE_SANDBOX_BASE_URL, // optional — sandbox flag picks this up
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface SquareConfig extends ApiKeyConfig`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `apiKey` | `string` | yes | Inherited from `ApiKeyConfig`. |
|
||||
| `locationId` | `string` | yes | Square `location_id` sent on every `CreatePayment` body. |
|
||||
| `sandbox` | `boolean` | no | When `true`, flips the default base URL to `SQUARE_SANDBOX_BASE_URL`. |
|
||||
| `apiVersion` | `string` | no | Value of the `Square-Version` header. Defaults to `SQUARE_DEFAULT_API_VERSION`. |
|
||||
| `randomId` | `RandomIdFn` | no | Generator for missing idempotency keys. Defaults to `crypto.randomUUID()`. |
|
||||
| `baseUrl` | `string` | no | Inherited from `ApiKeyConfig`. Wins over `sandbox`. |
|
||||
| `webhookSecret` | `string` | no | Inherited from `ApiKeyConfig`. Not used by the provider itself. |
|
||||
| `fetch` | `FetchLike` | no | Inherited from `ProviderCommonOptions`. |
|
||||
| `timeoutMs` | `number` | no | Inherited from `ProviderCommonOptions`. |
|
||||
| `signal` | `AbortSignal` | no | Inherited from `ProviderCommonOptions`. |
|
||||
|
||||
### `const SQUARE_DEFAULT_BASE_URL`
|
||||
|
||||
`"https://connect.squareup.com/v2"` — Square production base URL.
|
||||
|
||||
### `const SQUARE_SANDBOX_BASE_URL`
|
||||
|
||||
`"https://connect.squareupsandbox.com/v2"` — Square sandbox base URL.
|
||||
|
||||
### `const SQUARE_DEFAULT_API_VERSION`
|
||||
|
||||
`"2024-01-18"` — default `Square-Version` header value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `locationId` is intentionally required (the upstream
|
||||
`payment-providers` Square adapter omitted it; consumers had to
|
||||
wrap the provider to inject it). Multi-location merchants are the
|
||||
common case, and Square's `CreatePayment` endpoint rejects requests
|
||||
that target the wrong location implicitly.
|
||||
- The `sandbox` flag is a convenience — callers who need exotic base
|
||||
URLs (Square Connect for the EU, an internal proxy, etc.) should
|
||||
set `baseUrl` directly.
|
||||
- `randomId` is shaped as a synchronous `() => string` rather than a
|
||||
promise-returning function. Square's idempotency keys are opaque;
|
||||
any unique-enough string works.
|
||||
111
docs/money/payment/payment-provider-stripe/README.md
Normal file
111
docs/money/payment/payment-provider-stripe/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @suaveplan/payment-provider-stripe - Universal Stripe adapter for @suaveplan/payment. Implements PaymentProvider against the Stripe REST API via the slim @suaveplan/payment-providers foundation: Bearer auth on every call, Idempotency-Key forwarding on every mutating call, exhaustive Stripe status/refund-status/refund-reason mapping, payment-method normalisation, every error raised as PaymentProviderError with provider: 'stripe' discriminant. No Node / browser / React deps; every HTTP call flows through the injectable FetchLike from the slim core.
|
||||
|
||||
Universal Stripe adapter for `@suaveplan/payment`. Implements
|
||||
`PaymentProvider` against the Stripe REST API using the slim
|
||||
`@suaveplan/payment-providers` foundation: Bearer auth on every call,
|
||||
`Idempotency-Key` forwarding on every mutating call, exhaustive
|
||||
Stripe → domain status / refund-status / refund-reason mapping,
|
||||
payment-method normalisation, every error raised as
|
||||
`PaymentProviderError` with `provider: "stripe"` discriminant.
|
||||
|
||||
This package is one of the five sibling adapters that re-landed
|
||||
after `2026-05-17-slim-payment-providers` split the bundled
|
||||
`@suaveplan/payment-providers` into a foundation-with-sibling-plugins
|
||||
shape.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
|
||||
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-stripe \
|
||||
@suaveplan/payment-providers \
|
||||
@suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const provider = new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
});
|
||||
|
||||
const engine = new PaymentEngine({ provider });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
```
|
||||
|
||||
## Compose with the foundation registry
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new StripeProvider({ apiKey: process.env.STRIPE_SECRET_KEY ?? "" })
|
||||
);
|
||||
|
||||
const stripe = registry.get("stripe"); // typed as PaymentProvider
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`StripeProvider`** — implements `PaymentProvider` from
|
||||
`@suaveplan/payment` (literal `name: "stripe"`, six lifecycle
|
||||
methods).
|
||||
- **Bearer authentication** on every request (read + write).
|
||||
- **`Idempotency-Key` forwarding** — when the caller supplies
|
||||
`idempotencyKey` on `CreatePaymentIntentInput` or `RefundInput`,
|
||||
the provider forwards it verbatim to Stripe so retries collapse
|
||||
into a single side effect.
|
||||
- **Exhaustive status mappings** — seven Stripe payment-intent
|
||||
statuses, three Stripe refund statuses, four Stripe refund reasons,
|
||||
every unmapped value falls back to `failed` / `other`.
|
||||
- **Payment-method normalisation** — `card` carries `brand` + `last4`;
|
||||
any other Stripe `type` maps to `wallet`; absent payment method
|
||||
maps to `undefined`.
|
||||
- **Typed errors** — every failure is a `PaymentProviderError` from
|
||||
the slim core with `provider: "stripe"` discriminant.
|
||||
- **Universal tier** — zero Node, browser, React, or
|
||||
platform-specific imports. Runs unchanged on Node, Bun, browsers,
|
||||
and Web Workers.
|
||||
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
|
||||
exports.
|
||||
- **100% test coverage** (Istanbul) and **100% docs coverage**.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/stripe/stripe.md`](./src/stripe/stripe.md) — `StripeProvider`
|
||||
class, `StripeConfig`, `StripeIntent` / `StripeRefund` wire-format
|
||||
interfaces.
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface, `PaymentIntent` / `Refund` / status
|
||||
enums.
|
||||
- `@suaveplan/payment-providers` — slim foundation with
|
||||
`ProviderRegistry`, `httpRequest`, `composeAbortSignal`,
|
||||
`PaymentProviderError`, shared config shapes.
|
||||
- `@suaveplan/payment-provider-paypal`,
|
||||
`@suaveplan/payment-provider-square`,
|
||||
`@suaveplan/payment-provider-mollie`,
|
||||
`@suaveplan/payment-provider-adyen` — sibling provider packages.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
201
docs/money/payment/payment-provider-stripe/src/stripe/stripe.md
Normal file
201
docs/money/payment/payment-provider-stripe/src/stripe/stripe.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# StripeProvider
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It
|
||||
implements the `PaymentProvider` contract (six methods covering the
|
||||
payment-intent + refund lifecycle) against the Stripe REST API, using
|
||||
the slim `@suaveplan/payment-providers` foundation for every HTTP
|
||||
call. The class adds nothing beyond what `PaymentProvider` requires:
|
||||
no Stripe-specific surface bleeds into the consumer; no in-flight
|
||||
state is held between calls. Provider construction takes the same
|
||||
`ApiKeyConfig` shape every API-key-based sibling uses (`apiKey`,
|
||||
optional `baseUrl`, optional `webhookSecret`, plus the
|
||||
`ProviderCommonOptions` injectable seams — `fetch`, `timeoutMs`,
|
||||
`signal`).
|
||||
|
||||
Every mutating Stripe call (`createIntent`, `refund`) forwards the
|
||||
caller-supplied `idempotencyKey` as the standard
|
||||
`Idempotency-Key` HTTP header so retries against Stripe collapse
|
||||
into a single side effect. Bearer auth (`Authorization: Bearer
|
||||
<apiKey>`) is sent on every call, including read-only `getIntent`,
|
||||
so the provider works against either a Stripe live secret or test
|
||||
secret without any further wiring.
|
||||
|
||||
Stripe lifecycle states are mapped exhaustively into the
|
||||
domain enums from `@suaveplan/payment`: seven explicit Stripe
|
||||
status strings collapse into the five user-visible `PaymentStatus`
|
||||
values, an unknown status maps to `failed`, refund statuses collapse
|
||||
to `succeeded` / `pending` / `failed`, refund reasons collapse to the
|
||||
four `RefundReason` values with `other` as the fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `PaymentProvider` from `@suaveplan/payment` — class
|
||||
with literal `name: "stripe"`, all six methods, every return value
|
||||
conforms to `PaymentIntent` / `Refund`.
|
||||
- Routes every HTTP call through the slim-core `httpRequest` helper
|
||||
— inherits per-call timeout, `AbortSignal` composition, typed
|
||||
errors, empty-body handling, JSON parse-error handling.
|
||||
- Bearer authentication on every request (read + write).
|
||||
- `Idempotency-Key` header forwarded verbatim when the caller
|
||||
supplies `idempotencyKey` on `CreatePaymentIntentInput` or
|
||||
`RefundInput`. Omitted on read calls and on writes where the
|
||||
caller did not supply a key.
|
||||
- Exhaustive Stripe → domain status / refund-status / refund-reason
|
||||
mappers; unknown values fall back to `failed` / `other`.
|
||||
- Payment-method normalisation: Stripe `payment_method.type === "card"`
|
||||
maps to `"card"` with `brand` and `last4`; any other type maps to
|
||||
`"wallet"`; absent payment method maps to `undefined`.
|
||||
- Every raised `PaymentProviderError` carries `provider: "stripe"` so
|
||||
consumers running multiple providers can discriminate without
|
||||
parsing messages.
|
||||
- Universal — no `node:*`, no `bun:*`, no platform-specific
|
||||
imports anywhere under `src/`. Runs unchanged on Node, Bun,
|
||||
browsers, and Web Workers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const provider = new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
});
|
||||
|
||||
const engine = new PaymentEngine({ provider });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose multiple providers via the slim-core `ProviderRegistry`,
|
||||
inject a custom `FetchLike` for tracing/retries, scope every call to
|
||||
a caller-supplied `AbortSignal`, and forward an `Idempotency-Key`
|
||||
through the engine to collapse retried `createIntent` calls into a
|
||||
single Stripe side effect:
|
||||
|
||||
```ts
|
||||
import {
|
||||
ProviderRegistry,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const tracingFetch: FetchLike = async (url, init) => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await globalThis.fetch(url, init);
|
||||
} finally {
|
||||
// emit a span with (url, performance.now() - start)
|
||||
}
|
||||
};
|
||||
|
||||
const ac = new AbortController();
|
||||
const provider = new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
baseUrl: "https://api.stripe.com/v1",
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
fetch: tracingFetch,
|
||||
timeoutMs: 10_000,
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(provider);
|
||||
|
||||
const intent = await registry.get("stripe").createIntent({
|
||||
amount: { amount: 12_99n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class StripeProvider implements PaymentProvider`
|
||||
|
||||
```ts
|
||||
new StripeProvider(config: StripeConfig)
|
||||
```
|
||||
|
||||
- **`name`** — `"stripe"` (literal type, frozen on the class).
|
||||
- **`createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>`**
|
||||
— `POST /payment_intents`. Maps `input.amount.amount` to Stripe's
|
||||
minor-unit integer (`Number`), lowercases the currency, forwards
|
||||
`customer` / `description` / `metadata` / `payment_method` when
|
||||
supplied. Forwards `Idempotency-Key` from `input.idempotencyKey`.
|
||||
- **`confirmIntent(intentId): Promise<PaymentIntent>`** — `POST
|
||||
/payment_intents/:id/confirm`. No body, no `Idempotency-Key`.
|
||||
- **`captureIntent(intentId): Promise<PaymentIntent>`** — `POST
|
||||
/payment_intents/:id/capture`. No body, no `Idempotency-Key`.
|
||||
- **`cancelIntent(intentId): Promise<PaymentIntent>`** — `POST
|
||||
/payment_intents/:id/cancel`. No body, no `Idempotency-Key`.
|
||||
- **`refund(input: RefundInput): Promise<Refund>`** — `POST
|
||||
/refunds`. Forwards `amount` / `reason` / `metadata` when supplied.
|
||||
Forwards `Idempotency-Key` from `input.idempotencyKey`.
|
||||
- **`getIntent(intentId): Promise<PaymentIntent>`** — `GET
|
||||
/payment_intents/:id`. No body, no `Idempotency-Key`.
|
||||
|
||||
### `type StripeConfig = ApiKeyConfig`
|
||||
|
||||
Construction shape — alias for the shared `ApiKeyConfig` from
|
||||
`@suaveplan/payment-providers`. Re-exported by name so consumers can
|
||||
pin construction shape locally:
|
||||
|
||||
- `apiKey: string` — Stripe secret key (live or test).
|
||||
- `baseUrl?: string` — defaults to `https://api.stripe.com/v1`.
|
||||
- `webhookSecret?: string` — surfaced for downstream
|
||||
`WebhookVerifier` wiring; not used by the provider itself.
|
||||
- `fetch?: FetchLike` — defaults to `globalThis.fetch`.
|
||||
- `timeoutMs?: number` — per-call timeout (default 30 000 ms).
|
||||
- `signal?: AbortSignal` — caller cancellation, composed with the
|
||||
internal timeout signal.
|
||||
|
||||
### Error semantics
|
||||
|
||||
Every failure raised under `StripeProvider` is a `PaymentProviderError`
|
||||
from the slim core. Possible codes:
|
||||
|
||||
- `PAYMENT_PROVIDER.HTTP_ERROR` — Stripe returned non-2xx; `httpStatus`
|
||||
carries the status code, `bodyExcerpt` carries up to 256 chars of
|
||||
the response.
|
||||
- `PAYMENT_PROVIDER.TIMEOUT` — the per-call timeout fired before
|
||||
Stripe responded.
|
||||
- `PAYMENT_PROVIDER.PARSE_ERROR` — Stripe returned a malformed JSON
|
||||
response.
|
||||
|
||||
Every raised error carries `provider: "stripe"` so consumers
|
||||
discriminate without parsing messages.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The provider holds no in-flight Promise cache and no token cache —
|
||||
Stripe uses static Bearer auth, so there's nothing to memoise.
|
||||
Idempotency is delegated to Stripe via the `Idempotency-Key`
|
||||
header; the provider just forwards the caller-supplied key.
|
||||
- The status / refund-status / refund-reason mappings are exhaustive
|
||||
and tested per-row. The fallback `failed` / `other` branches are
|
||||
exercised by passing an unmapped Stripe enum value.
|
||||
- `mapPaymentMethod` exists as a small dedicated helper so the
|
||||
card vs. wallet vs. absent branches stay testable in isolation;
|
||||
consumers can rely on `paymentMethod.type === "card"` always
|
||||
carrying `brand` and `last4` when Stripe surfaced them.
|
||||
- The `commonOptions` private accessor exists so every method
|
||||
forwards `fetch`, `signal`, `timeoutMs`, and the `provider` field
|
||||
in lockstep. Calling sites stay symmetric — adding a new
|
||||
`PaymentProvider` method only needs to spread `this.commonOptions()`
|
||||
into the `httpRequest` call.
|
||||
- `package.json` declares `"sideEffects": false` and the only
|
||||
runtime imports are `@suaveplan/payment` (types-only) and
|
||||
`@suaveplan/payment-providers` (one named symbol: `httpRequest`).
|
||||
The dep-graph test pins universal purity.
|
||||
213
docs/money/payment/payment-providers/README.md
Normal file
213
docs/money/payment/payment-providers/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# @suaveplan/payment-providers - Slim universal foundation for the @suaveplan/payment-provider-* sibling packages. Ships ProviderRegistry, definePaymentProvider factory, shared httpRequest/composeAbortSignal helpers, FetchLike, the ApiKeyConfig / OAuthConfig / MerchantKeyConfig / HttpOptions / ProviderResponse types, and the PAYMENT_PROVIDER_CODES + PaymentProviderError error model. Concrete Stripe / PayPal / Square / Mollie / Adyen adapters ship as separate @suaveplan/payment-provider-<name> packages that depend on this foundation.
|
||||
|
||||
Universal **foundation** for the `@suaveplan/payment-provider-*`
|
||||
sibling-package family. This package ships only the shared abstractions
|
||||
every concrete adapter needs — the `ProviderRegistry`, the
|
||||
`definePaymentProvider` factory, the `httpRequest` +
|
||||
`composeAbortSignal` HTTP helpers, the `FetchLike` type, the shared
|
||||
config shapes (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
|
||||
`HttpOptions`, `ProviderResponse`), and the `PAYMENT_PROVIDER_CODES`
|
||||
registry + `PaymentProviderError` class. Concrete adapters
|
||||
(Stripe, PayPal, Square, Mollie, Adyen) live in **sibling packages** so
|
||||
consumers pay only for the providers they install.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
|
||||
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
|
||||
- [define](./src/registry/define.md) — Factory helper for sibling `@suaveplan/payment-provider-*` packages that want to expose a `PaymentProvider` (from `@sua…
|
||||
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
|
||||
- [types](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
|
||||
- [provider](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-providers @suaveplan/payment
|
||||
# plus one or more of:
|
||||
bun add @suaveplan/payment-provider-stripe
|
||||
bun add @suaveplan/payment-provider-paypal
|
||||
bun add @suaveplan/payment-provider-square
|
||||
bun add @suaveplan/payment-provider-mollie
|
||||
bun add @suaveplan/payment-provider-adyen
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
}));
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("stripe") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`ProviderRegistry`** — process-local, synchronous,
|
||||
insertion-ordered registry of `PaymentProvider` instances keyed by
|
||||
`name`; strict `get` with typed miss, duplicate-safe `register`,
|
||||
idempotent `unregister`, frozen `list()` snapshot
|
||||
- **`definePaymentProvider<TName>(spec)`** — factory that returns a
|
||||
frozen, literal-typed `PaymentProvider` for sibling packages that
|
||||
prefer a closure to a class
|
||||
- **`httpRequest<T>(options)`** — universal `FetchLike` wrapper with
|
||||
per-call timeout, JSON serialisation, custom-header passthrough,
|
||||
typed errors, empty-body handling, and external-`AbortSignal`
|
||||
composition
|
||||
- **`composeAbortSignal(internal, external?)`** — reusable
|
||||
abort-signal composer (extracted from `httpRequest` so sibling
|
||||
packages can compose their own out-of-band HTTP calls with
|
||||
caller-supplied cancellation)
|
||||
- **`PAYMENT_PROVIDER_CODES`** + **`PaymentProviderError`** — 6-code
|
||||
registry (`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`,
|
||||
`OAUTH_TOKEN_FAILURE`, `PROVIDER_NOT_REGISTERED`,
|
||||
`PROVIDER_ALREADY_REGISTERED`) with optional `provider` /
|
||||
`httpStatus` / `url` / `bodyExcerpt` discriminants
|
||||
- **Shared config shapes** — `ApiKeyConfig`, `OAuthConfig`,
|
||||
`MerchantKeyConfig`, `HttpOptions`, `ProviderResponse`,
|
||||
`ProviderCommonOptions`, plus `NowFn` / `RandomIdFn` /
|
||||
`Base64EncodeFn` injectable seams
|
||||
- **Universal tier** — zero Node, browser, React, or `node:crypto`
|
||||
imports; runs unchanged on Node, Bun, browsers, and Web Workers
|
||||
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
|
||||
exports
|
||||
- **100% test coverage** (Istanbul) + 100% docs coverage (sibling
|
||||
`.md` per implementation file)
|
||||
|
||||
## Sibling provider packages
|
||||
|
||||
The five concrete provider adapters that used to ship in this package
|
||||
now live in dedicated sibling packages, each depending on this
|
||||
foundation:
|
||||
|
||||
- `@suaveplan/payment-provider-stripe` — `StripeProvider`
|
||||
- `@suaveplan/payment-provider-paypal` — `PayPalProvider`
|
||||
- `@suaveplan/payment-provider-square` — `SquareProvider` + the
|
||||
Square-specific `SquareConfig` type
|
||||
- `@suaveplan/payment-provider-mollie` — `MollieProvider`
|
||||
- `@suaveplan/payment-provider-adyen` — `AdyenProvider`
|
||||
|
||||
Install one or more alongside this foundation. Every sibling exports
|
||||
the same class name + `PaymentProvider`-interface contract as before
|
||||
the slim-down, so migration from a bundled import is a one-line
|
||||
package swap (see `## Migration` below).
|
||||
|
||||
## Migration
|
||||
|
||||
If you were importing a concrete provider from
|
||||
`@suaveplan/payment-providers`, switch to the matching sibling
|
||||
package. The class names are preserved verbatim:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
import { StripeProvider } from "@suaveplan/payment-providers";
|
||||
|
||||
// After
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
```
|
||||
|
||||
All shared types (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
|
||||
`HttpOptions`, `ProviderResponse`, `NowFn`, `RandomIdFn`,
|
||||
`Base64EncodeFn`), `httpRequest`, `composeAbortSignal`, the
|
||||
`PaymentProviderError` class, and the `PAYMENT_PROVIDER_CODES`
|
||||
registry continue to be exported from
|
||||
`@suaveplan/payment-providers` unchanged. The Square-specific
|
||||
`SquareConfig` type now ships with
|
||||
`@suaveplan/payment-provider-square`.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/types/provider.md`](./src/types/provider.md) — `ApiKeyConfig`,
|
||||
`OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
|
||||
`ProviderResponse`, plus `NowFn` / `RandomIdFn` / `Base64EncodeFn`
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
|
||||
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`, factories
|
||||
(`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`,
|
||||
`providerNotRegistered`, `providerAlreadyRegistered`)
|
||||
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
|
||||
`composeAbortSignal` + `FetchLike`
|
||||
- [`src/registry/registry.md`](./src/registry/registry.md) —
|
||||
`ProviderRegistry`
|
||||
- [`src/registry/define.md`](./src/registry/define.md) —
|
||||
`definePaymentProvider` factory
|
||||
|
||||
## Building a custom provider
|
||||
|
||||
Sibling provider packages — and any consumer adding a non-standard
|
||||
provider — implement the `PaymentProvider` interface from
|
||||
`@suaveplan/payment` and use `httpRequest` from this foundation for
|
||||
every HTTP call:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type ApiKeyConfig,
|
||||
httpRequest,
|
||||
PaymentProviderError,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
PaymentProvider,
|
||||
} from "@suaveplan/payment";
|
||||
|
||||
export class CustomProvider implements PaymentProvider {
|
||||
public readonly name = "custom";
|
||||
private readonly config: ApiKeyConfig;
|
||||
|
||||
public constructor(config: ApiKeyConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
input: CreatePaymentIntentInput
|
||||
): Promise<PaymentIntent> {
|
||||
const { data } = await httpRequest<{ id: string }>({
|
||||
method: "POST",
|
||||
url: `${this.config.baseUrl ?? "https://api.custom.com"}/intents`,
|
||||
headers: { "X-API-Key": this.config.apiKey },
|
||||
body: { amount: Number(input.amount.amount) },
|
||||
fetch: this.config.fetch,
|
||||
signal: this.config.signal,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
provider: this.name,
|
||||
});
|
||||
return mapToPaymentIntent(data);
|
||||
}
|
||||
// ...implement remaining PaymentProvider methods
|
||||
}
|
||||
|
||||
function mapToPaymentIntent(_raw: unknown): PaymentIntent {
|
||||
throw new PaymentProviderError(
|
||||
"PAYMENT_PROVIDER.PARSE_ERROR" as never,
|
||||
"implement mapping"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface
|
||||
- `@suaveplan/money` — currency-aware arithmetic
|
||||
- `@suaveplan/error` — base error class + `defineCodes` registry helper
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Payment-provider error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error registry and typed error class for
|
||||
`@suaveplan/payment-providers`. Every production failure path — non-ok
|
||||
HTTP response, request timeout, malformed JSON body, OAuth
|
||||
token-acquisition failure, unknown-name lookup against the registry,
|
||||
duplicate registration against the registry — raises a
|
||||
`PaymentProviderError` carrying one of the namespaced codes declared
|
||||
via `defineCodes`. Consumers programmatically dispatch on `error.code`
|
||||
(e.g. `"PAYMENT_PROVIDER.HTTP_ERROR"`) or on the optional discriminants
|
||||
`provider`, `httpStatus`, `url`, and `bodyExcerpt` rather than parsing
|
||||
free-form `error.message` strings.
|
||||
|
||||
## Features
|
||||
|
||||
- 6-code registry via `defineCodes("PAYMENT_PROVIDER", […])`
|
||||
- `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
|
||||
- Discriminants: `provider`, `httpStatus`, `url`, `bodyExcerpt`
|
||||
(capped at 256 chars + ellipsis)
|
||||
- Cause-preserving factories for parse and OAuth failures
|
||||
- Registry-aware factories `providerNotRegistered` /
|
||||
`providerAlreadyRegistered` raised by `ProviderRegistry`
|
||||
- Custom message override on `httpError` for callers that have a more
|
||||
user-friendly diagnostic than the default `HTTP {status} from {provider}`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
httpError,
|
||||
PAYMENT_PROVIDER_CODES,
|
||||
PaymentProviderError,
|
||||
} from "@suaveplan/payment-providers";
|
||||
|
||||
const err = httpError("stripe", 404, "https://api.stripe.com/v1/x", "missing");
|
||||
console.log(err.code); // "PAYMENT_PROVIDER.HTTP_ERROR"
|
||||
console.log(err.httpStatus); // 404
|
||||
console.log(err.provider); // "stripe"
|
||||
if (err instanceof PaymentProviderError) {
|
||||
// narrow on code below
|
||||
}
|
||||
if (err.code === PAYMENT_PROVIDER_CODES.HTTP_ERROR) {
|
||||
// typed branch
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseError, oauthTokenFailure } from "@suaveplan/payment-providers";
|
||||
|
||||
try {
|
||||
const json = JSON.parse("{not json");
|
||||
void json;
|
||||
} catch (cause) {
|
||||
// Preserve the original parse failure as the .cause so OTel and
|
||||
// log appenders can walk the chain.
|
||||
throw parseError("stripe", "https://api.stripe.com/v1/x", "{not json", cause);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchPayPalToken();
|
||||
} catch (cause) {
|
||||
throw oauthTokenFailure("paypal", "https://api.paypal.com/v1/oauth2/token", cause);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Signature |
|
||||
|---|---|---|
|
||||
| `PAYMENT_PROVIDER_CODES` | `const` | `{ HTTP_ERROR, TIMEOUT, PARSE_ERROR, OAUTH_TOKEN_FAILURE, PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED }` (each value is `"PAYMENT_PROVIDER.<NAME>"`) |
|
||||
| `PaymentProviderErrorCode` | `type` | union of registry values |
|
||||
| `PaymentProviderError` | `class` | extends `SuaveplanError<PaymentProviderErrorCode>` |
|
||||
| `PaymentProviderErrorOptions` | `interface` | `{ provider?, httpStatus?, url?, bodyExcerpt? } & SuaveplanErrorOptions` |
|
||||
| `httpError(provider, status, url, body, message?)` | `function` | returns `PaymentProviderError(HTTP_ERROR)` |
|
||||
| `timeoutError(provider, url, timeoutMs, cause?)` | `function` | returns `PaymentProviderError(TIMEOUT)` |
|
||||
| `parseError(provider, url, body, cause)` | `function` | returns `PaymentProviderError(PARSE_ERROR)`, preserves cause |
|
||||
| `oauthTokenFailure(provider, url, cause)` | `function` | returns `PaymentProviderError(OAUTH_TOKEN_FAILURE)`, preserves cause |
|
||||
| `providerNotRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_NOT_REGISTERED)` carrying `name` on `provider` |
|
||||
| `providerAlreadyRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_ALREADY_REGISTERED)` carrying `name` on `provider` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bodyExcerpt` is truncated at 256 characters with a trailing `…` so the
|
||||
error is safe to log without flooding sinks. The codes are registered
|
||||
at module-load time via `defineCodes`, which freezes the registry
|
||||
object — runtime mutations are silently ignored under strict mode and
|
||||
fail loudly with `TypeError` under non-strict. The class itself
|
||||
deliberately does not expose any extra serialisation beyond what
|
||||
`SuaveplanError` provides; consumers wanting wire format should call
|
||||
the base-class `.toJSON()` which already encodes `code`, `message`,
|
||||
`cause` chain, and the discriminant fields.
|
||||
105
docs/money/payment/payment-providers/src/http/http.md
Normal file
105
docs/money/payment/payment-providers/src/http/http.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `httpRequest` helper
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal HTTP helper used by every provider to issue typed, cancellable
|
||||
JSON requests. The helper is a thin wrapper over an injectable
|
||||
`FetchLike` (defaulting to `globalThis.fetch`) that JSON-serialises
|
||||
non-string bodies, applies a per-call timeout via `AbortSignal`,
|
||||
composes a caller-supplied `signal` with the internal timeout signal,
|
||||
and converts every failure path into a typed `PaymentProviderError` so
|
||||
callers can branch on `error.code` instead of parsing strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `FetchLike` (`fetch?: FetchLike`) — defaults to
|
||||
`globalThis.fetch`
|
||||
- JSON serialisation of object bodies (string bodies pass through
|
||||
unchanged, enabling `application/x-www-form-urlencoded` flows)
|
||||
- Per-call `timeoutMs` (default `30_000`) via internal `AbortController`
|
||||
- External `signal?: AbortSignal` composed with the internal timeout
|
||||
- Typed errors: `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`
|
||||
- Empty-body handling: 204 / empty responses resolve to `data: {}`
|
||||
- `provider?: string` discriminant surfaced on every raised error
|
||||
- `composeAbortSignal(internal, external)` public helper — reusable by
|
||||
sibling provider packages that issue out-of-band HTTP calls (OAuth
|
||||
token fetches, webhook signature fetches) and want the same
|
||||
caller-signal composition without re-implementing the wiring
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/payment-providers";
|
||||
|
||||
const { status, data } = await httpRequest<{ id: string }>({
|
||||
method: "POST",
|
||||
url: "https://api.example.com/charges",
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
body: { amount: 1000 },
|
||||
timeoutMs: 10_000,
|
||||
provider: "example",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
|
||||
|
||||
// Inject a recording FetchLike for tests — no globalThis monkey-patching.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
};
|
||||
|
||||
const external = new AbortController();
|
||||
const promise = httpRequest({
|
||||
method: "GET",
|
||||
url: "https://api.example.com/intent/1",
|
||||
fetch,
|
||||
signal: external.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
// Cancel externally — either signal firing aborts the request.
|
||||
external.abort();
|
||||
await promise.catch((err) => console.log(err.code));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|---|---|---|
|
||||
| `FetchLike` | `type` | `(input: string, init?: RequestInit) => Promise<Response>` |
|
||||
| `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>` | `function` | issues the HTTP call, returns parsed JSON |
|
||||
| `composeAbortSignal(internal, external?): AbortSignal` | `function` | returns `internal` when `external` is undefined; otherwise returns a fresh signal that aborts when either input aborts, with already-aborted external propagating synchronously |
|
||||
|
||||
### `HttpOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `method` | `"GET" \| "POST" \| "DELETE" \| "PATCH"` | — | HTTP verb |
|
||||
| `url` | `string` | — | request URL |
|
||||
| `headers` | `Record<string, string>` | `{}` | extra headers (override the JSON defaults if set) |
|
||||
| `body` | `unknown` | — | JSON-serialised unless already a `string` |
|
||||
| `timeoutMs` | `number` | `30_000` | per-call timeout |
|
||||
| `fetch` | `FetchLike` | `globalThis.fetch` | injectable HTTP client |
|
||||
| `signal` | `AbortSignal` | — | external cancellation |
|
||||
| `provider` | `string` | `"unknown"` | discriminant placed on raised errors |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `AbortController` always wins on timeout — its `abort()` is
|
||||
called from a `setTimeout` whose handle is cleared in `finally`, so no
|
||||
listener leaks past the call. The composed `AbortSignal` is created
|
||||
inline via a third `AbortController`; both source signals
|
||||
(`timeoutController.signal` and the caller's `signal`) propagate to it
|
||||
via `addEventListener("abort", …, { once: true })`, plus an upfront
|
||||
check for the already-aborted case. Non-`Error` throws from the
|
||||
injected `FetchLike` (e.g. raw strings) become `HTTP_ERROR` with a
|
||||
stringified body excerpt — the helper never lets an untyped failure
|
||||
escape.
|
||||
119
docs/money/payment/payment-providers/src/registry/define.md
Normal file
119
docs/money/payment/payment-providers/src/registry/define.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# `definePaymentProvider`
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory helper for sibling `@suaveplan/payment-provider-*` packages
|
||||
that want to expose a `PaymentProvider` (from `@suaveplan/payment`)
|
||||
without authoring a full class. The factory returns a frozen object
|
||||
that satisfies the structural `PaymentProvider` contract and carries
|
||||
the caller-supplied method references verbatim — closures are
|
||||
preserved, no `this` rebinding happens. Sibling packages MAY use this
|
||||
factory or return a class instance from their `new XProvider(...)`
|
||||
constructor; both register identically into
|
||||
{@link ProviderRegistry}. The factory exists so providers without
|
||||
per-instance state (deterministic test providers, in-memory mocks,
|
||||
thin wrappers around an HTTP closure) can skip the class boilerplate.
|
||||
|
||||
## Features
|
||||
|
||||
- Literal-typed `name` — the result narrows to
|
||||
`PaymentProvider & { readonly name: TName }` so consumers can
|
||||
discriminate at the type level (`provider.name === "stripe"` narrows
|
||||
the rest of the object)
|
||||
- Frozen output — accidental mutation downstream throws in strict mode
|
||||
- Method-identity preservation — the function passed in is the function
|
||||
dispatched at call time (useful for spies / instrumentation that
|
||||
identify methods by reference)
|
||||
- Zero runtime cost beyond a single `Object.freeze`
|
||||
- Universal — no Node, browser, or React imports
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { definePaymentProvider, ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
|
||||
const stub = definePaymentProvider({
|
||||
name: "stub",
|
||||
createIntent: async (input) => ({
|
||||
id: "intent_1",
|
||||
provider: "stub",
|
||||
amount: input.amount,
|
||||
currency: input.amount.currency,
|
||||
status: "created",
|
||||
captureMethod: input.captureMethod ?? "automatic",
|
||||
refunds: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
confirmIntent: async () => { throw new Error("not used"); },
|
||||
captureIntent: async () => { throw new Error("not used"); },
|
||||
cancelIntent: async () => { throw new Error("not used"); },
|
||||
refund: async () => { throw new Error("not used"); },
|
||||
getIntent: async () => { throw new Error("not used"); },
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(stub);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
definePaymentProvider,
|
||||
ProviderRegistry,
|
||||
} from "@suaveplan/payment-providers";
|
||||
|
||||
// A deterministic test provider that records every call by reference
|
||||
// — note that `recordedCreate` IS the function exposed on
|
||||
// `provider.createIntent`, so spies / assertions can identify it by
|
||||
// equality.
|
||||
const calls: string[] = [];
|
||||
const recordedCreate = async (input: never): Promise<never> => {
|
||||
calls.push("create");
|
||||
void input;
|
||||
throw new Error("test-only stub");
|
||||
};
|
||||
|
||||
const provider = definePaymentProvider({
|
||||
name: "test",
|
||||
createIntent: recordedCreate,
|
||||
confirmIntent: recordedCreate,
|
||||
captureIntent: recordedCreate,
|
||||
cancelIntent: recordedCreate,
|
||||
refund: recordedCreate,
|
||||
getIntent: recordedCreate,
|
||||
});
|
||||
|
||||
// Type narrowing on the literal name.
|
||||
if (provider.name === "test") {
|
||||
// `provider` here is narrowed to `{ name: "test", ... }`
|
||||
}
|
||||
|
||||
// The factory result is frozen.
|
||||
expect(Object.isFrozen(provider)).toBe(true);
|
||||
expect(provider.createIntent).toBe(recordedCreate);
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(provider);
|
||||
expect(registry.get("test")).toBe(provider);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Signature |
|
||||
|---|---|---|
|
||||
| `PaymentProviderSpec<TName>` | `interface` | `{ name: TName; createIntent; confirmIntent; captureIntent; cancelIntent; refund; getIntent }` — every method type matches the `PaymentProvider` interface from `@suaveplan/payment` |
|
||||
| `definePaymentProvider<TName>(spec)` | `function` | returns `PaymentProvider & { readonly name: TName }`, frozen |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`definePaymentProvider` is intentionally minimal — it does not
|
||||
register the result anywhere, does not wrap methods in instrumentation,
|
||||
and does not perform validation beyond TypeScript's structural checks.
|
||||
Validation belongs to the layer that decides what to do with a
|
||||
provider (the `ProviderRegistry`, the `PaymentEngine`, etc.); the
|
||||
factory's only job is to package a spec into a frozen, literal-typed
|
||||
`PaymentProvider`. The `Object.freeze` call freezes the top-level
|
||||
object only; the methods themselves remain mutable function objects so
|
||||
sibling packages can attach their own metadata if needed.
|
||||
117
docs/money/payment/payment-providers/src/registry/registry.md
Normal file
117
docs/money/payment/payment-providers/src/registry/registry.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# `ProviderRegistry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Process-local, synchronous registry that indexes `PaymentProvider`
|
||||
instances (from `@suaveplan/payment`) by their `name` field. The
|
||||
registry is the foundation contract that sibling
|
||||
`@suaveplan/payment-provider-*` packages plug into — consumers
|
||||
explicitly construct + register the provider instances they want to
|
||||
route to and look them up by string name. The registry has no
|
||||
persistence layer, no auto-discovery, and no async lifecycle so it can
|
||||
ship and run unchanged on every JavaScript runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) `register`, `get`, `has`, `unregister` via an internal
|
||||
`Map<string, PaymentProvider>`
|
||||
- Insertion-ordered enumeration via `list()` and a frozen array
|
||||
snapshot to prevent external mutation
|
||||
- Strict `get` semantics — throws
|
||||
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)` on
|
||||
miss so callers never silently route through `undefined`
|
||||
- Duplicate-safe `register` — throws
|
||||
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
|
||||
when the same `name` is supplied twice
|
||||
- Idempotent `unregister` — returns `true` on hit and `false` on miss,
|
||||
never throws
|
||||
- `size` getter mirrors `Map`/`Set` semantics (numeric property, not a
|
||||
method)
|
||||
- Provider-agnostic — the registry knows nothing about Stripe / PayPal /
|
||||
Square specifics; it only sees the `PaymentProvider` interface from
|
||||
`@suaveplan/payment`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new StripeProvider({ apiKey: process.env.STRIPE_KEY ?? "" }));
|
||||
|
||||
const stripe = registry.get("stripe");
|
||||
const intent = await stripe.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
PAYMENT_PROVIDER_CODES,
|
||||
PaymentProviderError,
|
||||
ProviderRegistry,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new StripeProvider({ apiKey: STRIPE_KEY }));
|
||||
registry.register(new PayPalProvider({ clientId: PP_ID, clientSecret: PP_SECRET }));
|
||||
|
||||
// Route by name with strict semantics.
|
||||
function route(name: string) {
|
||||
try {
|
||||
return registry.get(name);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof PaymentProviderError &&
|
||||
err.code === PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
|
||||
) {
|
||||
// Fall back to a soft default — but only after observing the
|
||||
// strict miss, so logs distinguish "unconfigured" from
|
||||
// "configured but failed".
|
||||
return registry.get("stripe");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate for diagnostics — the result is frozen so this code can
|
||||
// hand the array straight to a logger without worrying about callers
|
||||
// mutating registry state.
|
||||
for (const provider of registry.list()) {
|
||||
console.log(`provider=${provider.name}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Kind | Signature | Description |
|
||||
|---|---|---|---|
|
||||
| `register(provider)` | method | `(provider: PaymentProvider) => void` | Index by `provider.name`. Throws `PROVIDER_ALREADY_REGISTERED` on duplicate. |
|
||||
| `unregister(name)` | method | `(name: string) => boolean` | Remove by name; returns `true` if present. |
|
||||
| `get(name)` | method | `(name: string) => PaymentProvider` | Strict lookup. Throws `PROVIDER_NOT_REGISTERED` on miss. |
|
||||
| `has(name)` | method | `(name: string) => boolean` | Soft membership check. |
|
||||
| `list()` | method | `() => readonly PaymentProvider[]` | Frozen insertion-ordered snapshot. |
|
||||
| `size` | getter | `number` | Number of registered providers. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is backed by an `ES Map` keyed by `provider.name` to keep
|
||||
operations O(1) and to inherit `Map`'s insertion-order iteration
|
||||
guarantee. `list()` returns a frozen `Array.from` snapshot — copying
|
||||
on read keeps the public surface immutable without giving up the
|
||||
`Map` storage. The two registry-related error codes
|
||||
(`PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`) live with
|
||||
the other `PAYMENT_PROVIDER_CODES` so consumers do a single
|
||||
`switch (err.code)` over the whole namespace. The class is deliberately
|
||||
**not** a singleton — applications that need multiple registries (e.g.
|
||||
test isolation, multi-tenant routing) construct multiple instances.
|
||||
102
docs/money/payment/payment-providers/src/types/provider.md
Normal file
102
docs/money/payment/payment-providers/src/types/provider.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Provider configuration types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared configuration interfaces and injectable seams consumed by every
|
||||
sibling `@suaveplan/payment-provider-*` adapter package. These types
|
||||
live in the slim foundation so each sibling can declare its concrete
|
||||
config (`StripeConfig`, `PayPalConfig`, `SquareConfig`,
|
||||
`AdyenConfig`, …) by extending one of the three base shapes here. They
|
||||
are universal — no runtime code, no browser or Node imports, no
|
||||
dependencies beyond a type-only `FetchLike` re-import from the sibling
|
||||
`http/http.ts` module.
|
||||
|
||||
## Features
|
||||
|
||||
- `ApiKeyConfig` — Bearer-auth providers (Stripe, Mollie, Square via
|
||||
the sibling package)
|
||||
- `OAuthConfig` — client-credentials providers (PayPal via the sibling
|
||||
package) with injectable `now` and `base64Encode`
|
||||
- `MerchantKeyConfig` — header-keyed providers carrying a merchant
|
||||
account (Adyen via the sibling package) with injectable `randomId`
|
||||
- `ProviderResponse<T>` — generic HTTP response wrapper returned by
|
||||
`httpRequest`
|
||||
- `HttpOptions` — input shape of `httpRequest`
|
||||
- `ProviderCommonOptions` — shared injectable seams (`fetch`,
|
||||
`timeoutMs`, `signal`)
|
||||
- `NowFn` / `RandomIdFn` / `Base64EncodeFn` — function-type aliases for
|
||||
the injectable seams
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ApiKeyConfig } from "@suaveplan/payment-providers/types/provider";
|
||||
|
||||
const config: ApiKeyConfig = {
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
baseUrl: "https://api.stripe.com/v1",
|
||||
timeoutMs: 10_000,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MerchantKeyConfig,
|
||||
OAuthConfig,
|
||||
} from "@suaveplan/payment-providers/types/provider";
|
||||
|
||||
// OAuth provider (PayPal sibling): inject a deterministic clock so the
|
||||
// token-cache TTL is reproducible in tests, and a Base-64 encoder that
|
||||
// works on every runtime (Node, Bun, browsers, Web Workers).
|
||||
const oauth: OAuthConfig = {
|
||||
clientId: "id",
|
||||
clientSecret: "secret",
|
||||
now: () => 0,
|
||||
base64Encode: (s) => Buffer.from(s, "utf8").toString("base64"),
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
|
||||
// Merchant-keyed provider (Adyen sibling): inject a deterministic id
|
||||
// generator so auto-generated `reference` values are predictable in
|
||||
// tests.
|
||||
const merchant: MerchantKeyConfig = {
|
||||
apiKey: "k",
|
||||
merchantAccount: "TestMerchant",
|
||||
randomId: () => "deterministic-id",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|---|---|---|
|
||||
| `ApiKeyConfig` | `interface` | `{ apiKey, baseUrl?, webhookSecret?, fetch?, timeoutMs?, signal? }` |
|
||||
| `OAuthConfig` | `interface` | adds `clientId`, `clientSecret`, `now?`, `base64Encode?` |
|
||||
| `MerchantKeyConfig` | `interface` | `{ apiKey, merchantAccount, baseUrl?, webhookHmacKey?, randomId?, …common }` |
|
||||
| `ProviderResponse<T>` | `interface` | `{ status: number; data: T }` |
|
||||
| `HttpOptions` | `interface` | `{ method, url, headers?, body?, provider?, …common }` |
|
||||
| `ProviderCommonOptions` | `interface` | `{ fetch?, timeoutMs?, signal? }` |
|
||||
| `NowFn` | `type` | `() => number` |
|
||||
| `RandomIdFn` | `type` | `() => string` |
|
||||
| `Base64EncodeFn` | `type` | `(input: string) => string` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fetch`, `now`, `randomId`, and `base64Encode` default at the sibling
|
||||
package level to `globalThis.fetch`, `Date.now`,
|
||||
`globalThis.crypto.randomUUID`, and `globalThis.btoa` respectively.
|
||||
Each sibling provider resolves its seams at construction time rather
|
||||
than at call time so tests can inject a single deterministic fixture
|
||||
per instance. `signal` composes with the per-call internal
|
||||
`AbortSignal` driven by `timeoutMs` via the foundation's
|
||||
`composeAbortSignal` helper, so callers cancelling a request from
|
||||
outside cancel the in-flight `fetch` and free the `setTimeout`.
|
||||
Provider-specific shapes (`StripeConfig`, `SquareConfig`, `PayPalConfig`,
|
||||
etc.) live in their respective sibling packages — the foundation only
|
||||
ships shapes that apply to every provider class.
|
||||
189
docs/money/payment/payment/README.md
Normal file
189
docs/money/payment/payment/README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# @suaveplan/payment - Universal payment intent lifecycle, state-machine enforcement, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestration, reconciliation, and card tokenization. Provider-agnostic — concrete gateway adapters (Stripe, PayPal, Test) ship in this package; bring-your-own PaymentProvider for everything else.
|
||||
|
||||
Universal payment intent lifecycle, webhook signature verification,
|
||||
receipt generation, fraud scoring, refund tracking, tax calculation, retry
|
||||
orchestration, reconciliation, and PCI-conscious card tokenisation.
|
||||
Provider-agnostic — drop in `TestGateway`, `StripeGateway`, `PayPalGateway`,
|
||||
or any custom `PaymentProvider`. Zero browser- or Node-only imports; runs
|
||||
on Bun, Node ≥ 18, modern browsers, and Web Workers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [engine](./src/engine/engine.md) — Orchestrates the full payment intent lifecycle on top of an injected `PaymentProvider`. Owns the state machine (`VALID_…
|
||||
- [engine](./src/engine/engine.md) — Orchestrates the full payment intent lifecycle on top of an injected `PaymentProvider`. Owns the state machine (`VALID_…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, namespaced error registry and discriminated `PaymentError` class for the payment package. Every failure path in…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable, namespaced error registry and discriminated `PaymentError` class for the payment package. Every failure path in…
|
||||
- [fraud](./src/fraud/fraud.md) — Risk-scoring helper that flags suspicious payment attempts before they reach the provider. Four built-in rules — veloci…
|
||||
- [fraud](./src/fraud/fraud.md) — Risk-scoring helper that flags suspicious payment attempts before they reach the provider. Four built-in rules — veloci…
|
||||
- [paypal](./src/gateway/paypal.md) — PayPal REST adapter for the abstract `PaymentGateway` contract. Talks to the Orders API v2 via injected `fetch`, caches…
|
||||
- [stripe](./src/gateway/stripe.md) — Stripe REST adapter for the abstract `PaymentGateway` contract. Talks to the Payment Intents API directly via injected…
|
||||
- [test](./src/gateway/test.md) — In-memory `PaymentGateway` implementation used by tests, fixtures, and local development. Records every operation in ch…
|
||||
- [types](./src/gateway/types.md) — Defines the abstract `PaymentGateway` contract every concrete adapter (the in-memory `TestGateway`, the Stripe REST ada…
|
||||
- [hooks](./src/hooks/hooks.md) — Tiny generic hooks bus owned by the payment package. It powers the `payment:processing` pipeline that fires inside `Pay…
|
||||
- [hooks](./src/hooks/hooks.md) — Tiny generic hooks bus owned by the payment package. It powers the `payment:processing` pipeline that fires inside `Pay…
|
||||
- [receipt](./src/receipt/receipt.md) — Generates structured `Receipt` objects from `PaymentIntent` plus caller- supplied line items, then provides a minimal p…
|
||||
- [receipt](./src/receipt/receipt.md) — Generates structured `Receipt` objects from `PaymentIntent` plus caller- supplied line items, then provides a minimal p…
|
||||
- [reconciliation](./src/reconciliation/reconciliation.md) — Reconciles a batch of internal payment records against gateway transaction records, matches them by id, surfaces amount…
|
||||
- [reconciliation](./src/reconciliation/reconciliation.md) — Reconciles a batch of internal payment records against gateway transaction records, matches them by id, surfaces amount…
|
||||
- [refund](./src/refund/refund.md) — Tracks per-intent refund history with balance validation, partial-refund support, and reason categorisation. The `Refun…
|
||||
- [refund](./src/refund/refund.md) — Tracks per-intent refund history with balance validation, partial-refund support, and reason categorisation. The `Refun…
|
||||
- [retry](./src/retry/retry.md) — Generic retry helper for payment operations. Wraps any async function with exponential backoff + jitter, honours a non-…
|
||||
- [retry](./src/retry/retry.md) — Generic retry helper for payment operations. Wraps any async function with exponential backoff + jitter, honours a non-…
|
||||
- [tax](./src/tax/tax.md) — In-memory tax-rate registry and line-item calculator. Supports tax-inclusive (price-includes-tax) and tax-exclusive (pr…
|
||||
- [tax](./src/tax/tax.md) — In-memory tax-rate registry and line-item calculator. Supports tax-inclusive (price-includes-tax) and tax-exclusive (pr…
|
||||
- [tokenization](./src/tokenization/tokenization.md) — PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects malformed expiry data and expired cards as…
|
||||
- [tokenization](./src/tokenization/tokenization.md) — PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects malformed expiry data and expired cards as…
|
||||
- [types](./src/types/payment.md) — Shared type contracts for the payment lifecycle: status union, payment-method descriptors, refund and dispute records,…
|
||||
- [payment](./src/types/payment.md) — Shared type contracts for the payment lifecycle: status union, payment-method descriptors, refund and dispute records,…
|
||||
- [webhook](./src/webhook/webhook.md) — Universal webhook signature verifier. Computes an HMAC over a `timestamp.body` envelope using `@suaveplan/crypto-h…
|
||||
- [webhook](./src/webhook/webhook.md) — Universal webhook signature verifier. Computes an HMAC over a `timestamp.body` envelope using `@suaveplan/crypto-h…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
PaymentEngine,
|
||||
ReceiptGenerator,
|
||||
TestGateway,
|
||||
WebhookVerifier,
|
||||
} from "@suaveplan/payment";
|
||||
|
||||
const gateway = new TestGateway();
|
||||
const engine = new PaymentEngine({
|
||||
provider: {
|
||||
name: gateway.name,
|
||||
createIntent: async (input) => ({
|
||||
id: "pi_001",
|
||||
status: "created",
|
||||
amount: input.amount,
|
||||
capturedAmount: { amount: 0n, currency: input.amount.currency },
|
||||
refundedAmount: { amount: 0n, currency: input.amount.currency },
|
||||
captureMethod: input.captureMethod ?? "automatic",
|
||||
refunds: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
confirmIntent: async () => {
|
||||
throw new Error("not used in quick start");
|
||||
},
|
||||
captureIntent: async () => {
|
||||
throw new Error("not used in quick start");
|
||||
},
|
||||
cancelIntent: async () => {
|
||||
throw new Error("not used in quick start");
|
||||
},
|
||||
refund: async () => {
|
||||
throw new Error("not used in quick start");
|
||||
},
|
||||
getIntent: async () => {
|
||||
throw new Error("not used in quick start");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
engine.on("intent.created", (intent) =>
|
||||
console.log("created", intent.id, intent.amount)
|
||||
);
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
|
||||
const verifier = new WebhookVerifier({ secret: "whsec_test" });
|
||||
const ts = Date.now();
|
||||
const body = JSON.stringify({
|
||||
id: "evt_001",
|
||||
type: "payment.captured",
|
||||
timestamp: ts,
|
||||
data: { intentId: intent.id },
|
||||
});
|
||||
const sig = verifier.sign(body, ts);
|
||||
const { payload } = verifier.verify(body, sig, ts);
|
||||
|
||||
const receiptGen = new ReceiptGenerator({ merchantName: "Acme" });
|
||||
const receipt = receiptGen.generate({
|
||||
intent,
|
||||
lineItems: [
|
||||
{
|
||||
description: "Widget",
|
||||
quantity: 1,
|
||||
unitPrice: intent.amount,
|
||||
total: intent.amount,
|
||||
},
|
||||
],
|
||||
subtotal: intent.amount,
|
||||
total: intent.amount,
|
||||
});
|
||||
console.log(receiptGen.toPlainText(receipt), payload.type);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`engine/engine`](./src/engine/engine.md) — `PaymentEngine`, state-machine
|
||||
enforcement, lifecycle events.
|
||||
- [`webhook/webhook`](./src/webhook/webhook.md) — `WebhookVerifier`
|
||||
(HMAC + replay window).
|
||||
- [`receipt/receipt`](./src/receipt/receipt.md) — `ReceiptGenerator` and
|
||||
plain-text renderer.
|
||||
- [`gateway/types`](./src/gateway/types.md) — `PaymentGateway` abstract
|
||||
contract.
|
||||
- [`gateway/test`](./src/gateway/test.md) — in-memory `TestGateway`.
|
||||
- [`gateway/stripe`](./src/gateway/stripe.md) — Stripe REST adapter.
|
||||
- [`gateway/paypal`](./src/gateway/paypal.md) — PayPal REST adapter.
|
||||
- [`fraud/fraud`](./src/fraud/fraud.md) — `FraudDetector` scoring.
|
||||
- [`refund/refund`](./src/refund/refund.md) — `RefundManager` ledger.
|
||||
- [`retry/retry`](./src/retry/retry.md) — `PaymentRetrier` with backoff +
|
||||
jitter.
|
||||
- [`tax/tax`](./src/tax/tax.md) — `TaxCalculator` jurisdiction registry.
|
||||
- [`tokenization/tokenization`](./src/tokenization/tokenization.md) —
|
||||
`CardTokenizer` (Luhn + HMAC).
|
||||
- [`reconciliation/reconciliation`](./src/reconciliation/reconciliation.md)
|
||||
— `PaymentReconciliation` matcher / report.
|
||||
- [`hooks/hooks`](./src/hooks/hooks.md) — `Hooks` + `paymentHooks`
|
||||
singleton + `payment:processing` pipeline.
|
||||
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) —
|
||||
`PAYMENT_CODES`, `PaymentError`, factory functions.
|
||||
- [`types/payment`](./src/types/payment.md) — `PaymentIntent`,
|
||||
`PaymentProvider`, `PaymentEventMap`, `VALID_TRANSITIONS`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Payment intent lifecycle** — 9-state machine (`created` → `pending` →
|
||||
`authorized` → `captured` → `partially_refunded` / `refunded` /
|
||||
`disputed` / `canceled` / `failed`) with `VALID_TRANSITIONS` enforcement.
|
||||
- **Typed lifecycle events** — `intent.created`, `intent.confirmed`,
|
||||
`intent.captured`, `intent.canceled`, `intent.failed`, `refund.created`,
|
||||
`refund.succeeded`, `refund.failed`, `dispute.opened`, `dispute.closed`.
|
||||
- **Webhook verification** — HMAC over `timestamp.body`, configurable
|
||||
algorithm (SHA-256/384/512), constant-time compare, replay window.
|
||||
- **Receipt generation** — structured records + plain-text fallback.
|
||||
- **Concrete gateway adapters** — `StripeGateway` (Payment Intents API),
|
||||
`PayPalGateway` (Orders API v2 + OAuth token cache), `TestGateway`
|
||||
in-memory.
|
||||
- **Fraud scoring** — velocity, amount, customer, new-method heuristics.
|
||||
- **Refund manager** — partial refunds, balance ledger, reason filtering.
|
||||
- **Retry orchestration** — exponential backoff + jitter, non-retryable
|
||||
blocklist.
|
||||
- **Tax calculator** — region/country lookup, inclusive + exclusive
|
||||
pricing, exempt items.
|
||||
- **Card tokenisation** — Luhn check, brand detection, HMAC-SHA-256 token
|
||||
via Web Crypto.
|
||||
- **Reconciliation** — match / discrepancy / unmatched report with
|
||||
normalised status compare.
|
||||
- **Typed errors** — `PaymentError extends SuaveplanError<PaymentErrorCode>`
|
||||
carrying optional `intentId` / `currentStatus` / `targetStatus` / `ageMs`
|
||||
discriminants.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
145
docs/money/payment/payment/src/engine/engine.md
Normal file
145
docs/money/payment/payment/src/engine/engine.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Orchestrates the full payment intent lifecycle on top of an injected
|
||||
`PaymentProvider`. Owns the state machine (`VALID_TRANSITIONS`), the typed
|
||||
`EventBus<PaymentEventMap>` that fires lifecycle events, the refund-balance
|
||||
guard that rejects refunds exceeding the captured total, and the
|
||||
`payment:processing` hook pipeline that lets callers transform intent input
|
||||
before it reaches the provider. Provider-agnostic — Stripe, PayPal, the
|
||||
in-memory test gateway, or any custom implementation drops in unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `createIntent` / `confirmIntent` / `captureIntent` / `cancelIntent` /
|
||||
`refund` / `getIntent` — every lifecycle entry point.
|
||||
- `markFailed` / `openDispute` / `closeDispute` — explicit emitters for
|
||||
out-of-band states the provider may not surface directly.
|
||||
- State-machine enforcement via `canTransition` + private `assertTransition`;
|
||||
illegal transitions raise `PaymentError(PAYMENT.INVALID_TRANSITION)` with
|
||||
`currentStatus` / `targetStatus` discriminants set.
|
||||
- Refund-balance guard — `refund({ amount })` whose amount exceeds the
|
||||
remaining (captured − refunded) raises
|
||||
`PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)`.
|
||||
- Provider errors are wrapped — `PaymentError(PAYMENT.PROVIDER_ERROR)` with
|
||||
the original error preserved as `cause`; non-`Error` throws are stringified
|
||||
into the message.
|
||||
- Typed `on(event, listener)` → `Unsubscribe` for every entry in
|
||||
`PaymentEventMap`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentEngine } from "@suaveplan/payment/engine/engine";
|
||||
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
|
||||
|
||||
declare const provider: PaymentProvider;
|
||||
|
||||
const engine = new PaymentEngine({ provider });
|
||||
engine.on("intent.captured", (intent) => {
|
||||
console.log("captured", intent.id, intent.capturedAmount);
|
||||
});
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
await engine.confirmIntent(intent.id);
|
||||
await engine.captureIntent(intent.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PaymentEngine } from "@suaveplan/payment/engine/engine";
|
||||
import {
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
import { onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
|
||||
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
|
||||
|
||||
declare const provider: PaymentProvider;
|
||||
|
||||
// Stack a hook that normalises currency before the provider call.
|
||||
onPaymentProcessing((data) => ({
|
||||
...data,
|
||||
input: {
|
||||
...data.input,
|
||||
amount: {
|
||||
amount: data.input.amount.amount,
|
||||
currency: data.input.amount.currency.toUpperCase(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const engine = new PaymentEngine({ provider });
|
||||
|
||||
try {
|
||||
await engine.refund({
|
||||
paymentIntentId: "pi_001",
|
||||
amount: { amount: 9999n, currency: "USD" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof PaymentError &&
|
||||
err.code === PAYMENT_CODES.REFUND_EXCEEDS_CAPTURED
|
||||
) {
|
||||
console.warn("refund denied", err.intentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentEngineConfig` — `{ provider: PaymentProvider }`.
|
||||
- `new PaymentEngine(config)` — construct an engine.
|
||||
- `engine.providerName` → string getter exposing `provider.name`.
|
||||
- `engine.on(event, listener)` → `Unsubscribe`. Listener payload is typed
|
||||
per `PaymentEventMap`.
|
||||
- `engine.createIntent(input)` → `Promise<PaymentIntent>`. Fires the
|
||||
`payment:processing` hook chain, then emits `intent.created`.
|
||||
- `engine.confirmIntent(id)` → `Promise<PaymentIntent>`. Reads current
|
||||
status; rejects illegal transitions; emits `intent.confirmed`.
|
||||
- `engine.captureIntent(id, amount?)` → `Promise<PaymentIntent>`. Emits
|
||||
`intent.captured`. `amount` allows partial capture when the provider
|
||||
supports it.
|
||||
- `engine.cancelIntent(id)` → `Promise<PaymentIntent>`. Emits
|
||||
`intent.canceled`.
|
||||
- `engine.refund(input)` → `Promise<Refund>`. Validates current status is
|
||||
refundable, validates amount ≤ remaining, then emits one of
|
||||
`refund.succeeded` / `refund.failed` / `refund.created` depending on the
|
||||
provider's reported `status`.
|
||||
- `engine.getIntent(id)` → `Promise<PaymentIntent>`. Wraps provider
|
||||
failures as `PaymentError(PAYMENT.NOT_FOUND)`.
|
||||
- `engine.markFailed(intent)` — emit `intent.failed`.
|
||||
- `engine.openDispute(dispute)` — emit `dispute.opened`.
|
||||
- `engine.closeDispute(dispute)` — emit `dispute.closed`.
|
||||
- `engine.canTransition(from, to)` → boolean. Pure lookup on
|
||||
`VALID_TRANSITIONS`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The engine owns a private `EventBus<PaymentEventMap>` — listeners
|
||||
subscribed via `engine.on(...)` receive payloads via the bus's async
|
||||
dispatch path, which awaits each listener in registration order.
|
||||
- `wrapProviderError` strips empty messages so non-`Error` throws like `42`
|
||||
surface as `"42"`, `null` as `"null"`, and Error instances pass through
|
||||
with `message` preserved. The original error is always attached as `cause`
|
||||
when present.
|
||||
- `getIntent` swallows any provider error and re-raises as
|
||||
`PAYMENT.NOT_FOUND` so consumers can dispatch on a single code rather than
|
||||
matching on substring messages.
|
||||
- The `refundable` array (`["captured", "partially_refunded"]`) is declared
|
||||
inline so adding a new refundable status is one-line. `VALID_TRANSITIONS`
|
||||
encodes the broader machine; the array enforces the refund-specific
|
||||
guard.
|
||||
- `markFailed`, `openDispute`, and `closeDispute` are explicit emitters so
|
||||
the event map can stay closed (no `[key: string]: unknown[]` escape
|
||||
hatch). Consumers that need to surface those states call these methods
|
||||
directly.
|
||||
115
docs/money/payment/payment/src/error-codes/error-codes.md
Normal file
115
docs/money/payment/payment/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable, namespaced error registry and discriminated `PaymentError` class for
|
||||
the payment package. Every failure path in the engine, webhook verifier,
|
||||
gateways, refund manager, tokenizer, and reconciler raises a `PaymentError`
|
||||
carrying one of these codes so callers can dispatch programmatically without
|
||||
parsing message strings. Built on `defineCodes("PAYMENT", […])` from
|
||||
`@suaveplan/error` to keep the namespacing convention consistent with every
|
||||
other package.
|
||||
|
||||
## Features
|
||||
|
||||
- `PAYMENT_CODES` — frozen registry with 13 namespaced codes.
|
||||
- `PaymentErrorCode` — string union derived from the registry.
|
||||
- `PaymentError extends SuaveplanError<PaymentErrorCode>` — discriminated
|
||||
error class with optional `intentId` / `currentStatus` / `targetStatus` /
|
||||
`ageMs` discriminants.
|
||||
- 12 thin factory functions (`paymentNotFound`, `invalidTransition`,
|
||||
`alreadyCaptured`, `alreadyRefunded`, `refundExceedsCaptured`,
|
||||
`providerError`, `webhookInvalidSignature`, `webhookExpired`,
|
||||
`webhookParseError`, `amountMismatch`, `invalidCard`, `gatewayError`,
|
||||
`refundIntentNotRegistered`) — one per registry entry.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
paymentNotFound,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
throw paymentNotFound("pi_001");
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentError && err.code === PAYMENT_CODES.NOT_FOUND) {
|
||||
console.warn("intent missing", err.intentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
invalidTransition,
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
webhookExpired,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
|
||||
function handle(err: unknown): void {
|
||||
if (!(err instanceof PaymentError)) throw err;
|
||||
switch (err.code) {
|
||||
case PAYMENT_CODES.INVALID_TRANSITION:
|
||||
console.warn("illegal transition", err.currentStatus, "→", err.targetStatus);
|
||||
break;
|
||||
case PAYMENT_CODES.WEBHOOK_EXPIRED:
|
||||
console.warn("webhook age (ms)", err.ageMs);
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
handle(invalidTransition("pi_002", "captured", "pending"));
|
||||
handle(webhookExpired(420_000));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PAYMENT_CODES` — `Readonly<{ NOT_FOUND, INVALID_TRANSITION, ALREADY_CAPTURED,
|
||||
ALREADY_REFUNDED, REFUND_EXCEEDS_CAPTURED, PROVIDER_ERROR,
|
||||
WEBHOOK_INVALID_SIGNATURE, WEBHOOK_EXPIRED, WEBHOOK_PARSE_ERROR,
|
||||
AMOUNT_MISMATCH, INVALID_CARD, GATEWAY_ERROR, REFUND_INTENT_NOT_REGISTERED }>`,
|
||||
every value namespaced as `"PAYMENT.<KEY>"`.
|
||||
- `PaymentErrorCode` — `(typeof PAYMENT_CODES)[keyof typeof PAYMENT_CODES]`.
|
||||
- `PaymentErrorOptions` — `{ intentId?, currentStatus?, targetStatus?, ageMs?,
|
||||
cause?, context?, clock?, traceId? }`.
|
||||
- `PaymentError(code, message, options?)` — extends `SuaveplanError`; exposes
|
||||
`code`, `message`, `cause`, `timestamp`, and the four discriminants.
|
||||
- `paymentNotFound(intentId)` → `PAYMENT.NOT_FOUND`.
|
||||
- `invalidTransition(id, current, target)` → `PAYMENT.INVALID_TRANSITION`.
|
||||
- `alreadyCaptured(id)` → `PAYMENT.ALREADY_CAPTURED`.
|
||||
- `alreadyRefunded(id)` → `PAYMENT.ALREADY_REFUNDED`.
|
||||
- `refundExceedsCaptured(id)` → `PAYMENT.REFUND_EXCEEDS_CAPTURED`.
|
||||
- `providerError(message, cause?)` → `PAYMENT.PROVIDER_ERROR`.
|
||||
- `webhookInvalidSignature()` → `PAYMENT.WEBHOOK_INVALID_SIGNATURE`.
|
||||
- `webhookExpired(ageMs)` → `PAYMENT.WEBHOOK_EXPIRED`, sets `err.ageMs`.
|
||||
- `webhookParseError(cause?)` → `PAYMENT.WEBHOOK_PARSE_ERROR`.
|
||||
- `amountMismatch(id)` → `PAYMENT.AMOUNT_MISMATCH`.
|
||||
- `invalidCard(reason)` → `PAYMENT.INVALID_CARD`.
|
||||
- `gatewayError(message, cause?)` → `PAYMENT.GATEWAY_ERROR`.
|
||||
- `refundIntentNotRegistered(id)` → `PAYMENT.REFUND_INTENT_NOT_REGISTERED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `PAYMENT_CODES` is frozen by `defineCodes`; mutation throws under strict
|
||||
mode and is impossible under structured-clone serialisation.
|
||||
- `PaymentError` carries optional discriminants that callers can read without
|
||||
consulting `err.context`. Each is conditionally assigned so reflection
|
||||
helpers see `undefined` rather than enumerable `undefined` entries.
|
||||
- `cause` is forwarded through to `SuaveplanError`, which forwards to the
|
||||
underlying `Error.cause` mechanism — the full cause chain is preserved.
|
||||
- No factory throws by itself; every factory returns a `PaymentError` so call
|
||||
sites stay grep-able (`throw paymentNotFound(...)`).
|
||||
- The registry intentionally namespaces every code with `PAYMENT.` even for
|
||||
webhook-scoped failures; the suffix (`WEBHOOK_*`) carries the sub-category.
|
||||
Consumers that route on category prefix can split on the second dot.
|
||||
103
docs/money/payment/payment/src/fraud/fraud.md
Normal file
103
docs/money/payment/payment/src/fraud/fraud.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# fraud
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Risk-scoring helper that flags suspicious payment attempts before they reach
|
||||
the provider. Four built-in rules — velocity per customer, high-amount
|
||||
anomaly, missing-customer-id heuristic, and first-seen payment method — sum
|
||||
into a 0-100 score that the detector turns into an `allow` / `review` /
|
||||
`block` recommendation. Every threshold is configurable and the time source
|
||||
is injectable, so the detector is deterministic in tests.
|
||||
|
||||
## Features
|
||||
|
||||
- `FraudDetector.check(input)` — runs every rule and returns a typed result.
|
||||
- Configurable thresholds for review (default 30) and block (default 70).
|
||||
- Velocity check: bounded sliding window per customer (default 5 attempts
|
||||
per hour).
|
||||
- High-amount anomaly: `bigint` threshold (default 100,000 minor units).
|
||||
- Missing-customer-id heuristic (default 15 points).
|
||||
- New-payment-method heuristic (default 10 points, learns over time).
|
||||
- Injected `now()` clock for reproducible velocity windows.
|
||||
- `reset()` clears the velocity log and known-method memo.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FraudDetector } from "@suaveplan/payment/fraud/fraud";
|
||||
|
||||
const detector = new FraudDetector();
|
||||
const result = detector.check({
|
||||
amount: { amount: 500n, currency: "USD" },
|
||||
paymentMethod: { id: "pm_1", type: "card", last4: "4242" },
|
||||
customerId: "cust_001",
|
||||
});
|
||||
console.log(result.recommendation, result.score);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FraudDetector } from "@suaveplan/payment/fraud/fraud";
|
||||
|
||||
let now = 0;
|
||||
const detector = new FraudDetector({
|
||||
reviewThreshold: 20,
|
||||
blockThreshold: 60,
|
||||
velocityMaxAttempts: 3,
|
||||
velocityWindowMs: 60_000,
|
||||
highAmountThreshold: 50_000n,
|
||||
now: () => now,
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
now += 1_000;
|
||||
detector.check({
|
||||
amount: { amount: 1000n, currency: "USD" },
|
||||
paymentMethod: { id: "pm_repeat", type: "card", last4: "4242" },
|
||||
customerId: "cust_x",
|
||||
});
|
||||
}
|
||||
now += 1_000;
|
||||
const fourth = detector.check({
|
||||
amount: { amount: 60_000n, currency: "USD" },
|
||||
paymentMethod: { id: "pm_repeat", type: "card", last4: "4242" },
|
||||
customerId: "cust_x",
|
||||
});
|
||||
console.log(fourth.recommendation); // "block" — velocity + high-amount
|
||||
detector.reset();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FraudRecommendation` — `"allow" | "review" | "block"`.
|
||||
- `FraudFlag` — `{ rule, description, score }`.
|
||||
- `FraudCheckResult` — `{ score, flags, recommendation, checkedAt }`.
|
||||
- `FraudCheckInput` — `{ amount, paymentMethod, customerId?, ip?, email?,
|
||||
metadata? }`.
|
||||
- `FraudDetectorConfig` — every rule weight + threshold + the injected
|
||||
`now`.
|
||||
- `new FraudDetector(config?)` → instance.
|
||||
- `detector.check(input)` → `FraudCheckResult`.
|
||||
- `detector.reset()` — clear velocity history and known-method memo.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The velocity log grows unbounded by design — the upstream contract trades
|
||||
memory for simplicity. Long-running consumers call `reset()` periodically
|
||||
(a real production deployment would swap in a sliding-window structure).
|
||||
- Recommendation thresholds use `>=`: a score equal to `blockThreshold`
|
||||
blocks; equal to `reviewThreshold` reviews. Tests in this package pin
|
||||
every boundary.
|
||||
- Score is capped at 100 — many rules firing simultaneously cannot push the
|
||||
recommendation past `block`.
|
||||
- The detector intentionally bills "missing customer" as a flag rather than
|
||||
raising an error — caller's choice whether to refuse anonymous payments
|
||||
outright. Calling code can inspect `result.flags` to act on individual
|
||||
rules.
|
||||
- `ip` / `email` / `metadata` are accepted on the input but not consumed by
|
||||
any of the built-in rules; they are preserved on the input shape so
|
||||
downstream rule extensions can use them without a contract break.
|
||||
115
docs/money/payment/payment/src/gateway/paypal.md
Normal file
115
docs/money/payment/payment/src/gateway/paypal.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# gateway/paypal
|
||||
|
||||
## Purpose
|
||||
|
||||
PayPal REST adapter for the abstract `PaymentGateway` contract. Talks to the
|
||||
Orders API v2 via injected `fetch`, caches OAuth access tokens until 60s
|
||||
before expiry to amortise the auth round-trip, and maps PayPal-specific
|
||||
status values onto the canonical `GatewayChargeStatus` union. Universal
|
||||
(no PayPal SDK) so the package keeps its runtime contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `createCharge` posts to `/v2/checkout/orders` with `intent=CAPTURE` /
|
||||
`AUTHORIZE` depending on `options.capture`.
|
||||
- `captureCharge` posts to `/v2/checkout/orders/{id}/capture`, optionally
|
||||
supplying a partial amount.
|
||||
- `refundCharge` reads the order, locates the first capture id, then posts
|
||||
to `/v2/payments/captures/{id}/refund` with optional partial amount.
|
||||
- `getCharge` retrieves the order and sums capture / refund totals into a
|
||||
normalised `GatewayCharge`.
|
||||
- `validatePaymentMethod` performs basic structural validation.
|
||||
- Token cache uses the injected `now` source so test transcripts stay
|
||||
deterministic.
|
||||
- Injectable `fetch` + `now` + `btoa` for the universal promise: the
|
||||
default uses the runtime globals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PayPalGateway } from "@suaveplan/payment/gateway/paypal";
|
||||
|
||||
const gateway = new PayPalGateway({
|
||||
clientId: process.env.PP_CLIENT_ID!,
|
||||
clientSecret: process.env.PP_CLIENT_SECRET!,
|
||||
sandbox: true,
|
||||
});
|
||||
const result = await gateway.createCharge(
|
||||
{ amount: 1000n, currency: "USD" },
|
||||
{ id: "pm_wallet", type: "wallet" }
|
||||
);
|
||||
console.log(result.chargeId, result.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PayPalGateway } from "@suaveplan/payment/gateway/paypal";
|
||||
|
||||
let currentTime = 1_700_000_000_000;
|
||||
const transcript: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fakeFetch = async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
transcript.push({ url, init });
|
||||
if (url.endsWith("/v1/oauth2/token")) {
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "tok_x", token_type: "Bearer", expires_in: 3600 }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "ord_1",
|
||||
status: "COMPLETED",
|
||||
purchase_units: [{ amount: { currency_code: "USD", value: "10.00" } }],
|
||||
create_time: "2026-05-17T00:00:00Z",
|
||||
update_time: "2026-05-17T00:00:01Z",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const gateway = new PayPalGateway({
|
||||
clientId: "test",
|
||||
clientSecret: "secret",
|
||||
sandbox: true,
|
||||
fetch: fakeFetch,
|
||||
now: () => currentTime,
|
||||
btoa: (raw) => Buffer.from(raw, "utf8").toString("base64"),
|
||||
});
|
||||
const result = await gateway.createCharge(
|
||||
{ amount: 1000n, currency: "USD" },
|
||||
{ id: "pm_wallet", type: "wallet" }
|
||||
);
|
||||
console.log(transcript.length, result.status);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PayPalGatewayConfig` — `{ clientId, clientSecret, sandbox?, baseUrl?,
|
||||
timeout?, now?, fetch?, btoa? }`.
|
||||
- `new PayPalGateway(config)` → `PaymentGateway` implementation.
|
||||
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
|
||||
`refundCharge`, `getCharge`, `validatePaymentMethod`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The token cache stores `accessToken` + `tokenExpiresAt`; the next request
|
||||
re-uses the token until 60s before its declared expiry, at which point a
|
||||
refresh round-trip is automatic.
|
||||
- The frozen `STATUS_MAP` translates PayPal's vocabulary
|
||||
(`CREATED`/`SAVED`/`APPROVED`/`VOIDED`/`COMPLETED`/
|
||||
`PAYER_ACTION_REQUIRED`) into the canonical `GatewayChargeStatus`. Unknown
|
||||
statuses map to `"failed"`.
|
||||
- Amounts are formatted as `"<dollars>.<cents>"` via
|
||||
`formatAmount(money) → (Number(amount) / 100).toFixed(2)`; PayPal requires
|
||||
the decimal-string format on the wire.
|
||||
- `refundCharge` looks up the order's first capture id to issue the refund
|
||||
against. When no capture exists (e.g. authorise-only flow), a `failed`
|
||||
result is returned with `errorCode = "NO_CAPTURE"`.
|
||||
- Auth and request failures both raise `PaymentError(PAYMENT.GATEWAY_ERROR)`
|
||||
with the response status and body in the message.
|
||||
- Empty response bodies are tolerated — `request` returns an empty object
|
||||
cast as `R` when the body is empty (e.g. 204 No Content).
|
||||
- `btoa` is injected because it has different semantics across runtimes
|
||||
(browser-native; Node 16+ ships it; Bun ships it). Tests that need to
|
||||
enforce a specific encoding pass `Buffer.from(...).toString("base64")`.
|
||||
113
docs/money/payment/payment/src/gateway/stripe.md
Normal file
113
docs/money/payment/payment/src/gateway/stripe.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# gateway/stripe
|
||||
|
||||
## Purpose
|
||||
|
||||
Stripe REST adapter for the abstract `PaymentGateway` contract. Talks to the
|
||||
Payment Intents API directly via injected `fetch` so no Stripe SDK ships in
|
||||
the bundle — keeps `@suaveplan/payment` universal and lets every test
|
||||
drive deterministic transcripts without real HTTP. Maps Stripe-specific
|
||||
status values onto the canonical `GatewayChargeStatus` union.
|
||||
|
||||
## Features
|
||||
|
||||
- `createCharge` posts to `/v1/payment_intents` with `confirm=true`, honours
|
||||
`capture` (manual vs automatic), forwards `description`, `customerId`, and
|
||||
arbitrary `metadata[…]` entries, and threads `Idempotency-Key` through the
|
||||
HTTP header when supplied.
|
||||
- `captureCharge` posts to `/v1/payment_intents/{id}/capture` with an
|
||||
optional `amount_to_capture` for partial captures.
|
||||
- `refundCharge` posts to `/v1/refunds` keyed by the payment-intent id with
|
||||
optional `amount` and `reason`.
|
||||
- `getCharge` retrieves the intent, sums all refund amounts across the
|
||||
underlying charges, and returns a normalised `GatewayCharge`.
|
||||
- `validatePaymentMethod` performs static card validation (last4, expiry
|
||||
month / year, future expiry) without network calls.
|
||||
- Wall-clock-based `AbortSignal.timeout` enforces the request budget.
|
||||
- Failures raise `PaymentError(PAYMENT.GATEWAY_ERROR)` with the response
|
||||
body and status preserved in the message.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { StripeGateway } from "@suaveplan/payment/gateway/stripe";
|
||||
|
||||
const gateway = new StripeGateway({ secretKey: process.env.STRIPE_KEY! });
|
||||
const result = await gateway.createCharge(
|
||||
{ amount: 1000n, currency: "USD" },
|
||||
{ id: "pm_test", type: "card", last4: "4242" }
|
||||
);
|
||||
console.log(result.chargeId, result.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
type FetchLike,
|
||||
StripeGateway,
|
||||
} from "@suaveplan/payment/gateway/stripe";
|
||||
|
||||
const transcript: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fakeFetch: FetchLike = async (url, init) => {
|
||||
transcript.push({ url, init });
|
||||
if (url.endsWith("/v1/payment_intents")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "pi_001",
|
||||
status: "succeeded",
|
||||
amount: 1000,
|
||||
amount_received: 1000,
|
||||
currency: "usd",
|
||||
created: 1_700_000_000,
|
||||
charges: { data: [{ amount_refunded: 0 }] },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const gateway = new StripeGateway({
|
||||
secretKey: "sk_test",
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
timeout: 5_000,
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
const charge = await gateway.createCharge(
|
||||
{ amount: 1000n, currency: "USD" },
|
||||
{ id: "pm_test", type: "card", last4: "4242" },
|
||||
{ idempotencyKey: "ord_42", metadata: { source: "stage" } }
|
||||
);
|
||||
console.log(transcript.length, charge.status);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FetchLike` — `(input: string, init?: RequestInit) => Promise<Response>`.
|
||||
- `StripeGatewayConfig` — `{ secretKey, baseUrl?, apiVersion?, timeout?,
|
||||
fetch? }`.
|
||||
- `new StripeGateway(config)` → `PaymentGateway` implementation.
|
||||
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
|
||||
`refundCharge`, `getCharge`, `validatePaymentMethod`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The injected `fetch` defaults to the global `fetch` — universal in Node 18+,
|
||||
Bun, browsers, and Web Workers.
|
||||
- Stripe status mapping uses a frozen lookup table; unknown values map to
|
||||
`"failed"` (`mapStripeStatus`).
|
||||
- Non-2xx responses raise `gatewayError(...)` with the response status and
|
||||
body; the caller's invariant — that adapter errors surface as
|
||||
`PaymentError` — is preserved.
|
||||
- `GET` requests fold the URLSearchParams into the URL when populated;
|
||||
`POST` requests serialise the params into the body and set
|
||||
`Content-Type: application/x-www-form-urlencoded`.
|
||||
- The default `apiVersion` is `"2024-12-18.acacia"`; pinning the value in the
|
||||
`Stripe-Version` header avoids surprise breakage when Stripe ships a new
|
||||
default API version.
|
||||
- `validatePaymentMethod` is purely client-side — it does not hit Stripe.
|
||||
Real method tokens come from Stripe.js / Elements; the validation here
|
||||
catches malformed inputs before they hit the network.
|
||||
- `refundCharge` defaults the currency to `"USD"` when only `refund.amount`
|
||||
is available on the response; callers that need exact currency pass an
|
||||
explicit `amount` argument.
|
||||
91
docs/money/payment/payment/src/gateway/test.md
Normal file
91
docs/money/payment/payment/src/gateway/test.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# gateway/test
|
||||
|
||||
## Purpose
|
||||
|
||||
In-memory `PaymentGateway` implementation used by tests, fixtures, and local
|
||||
development. Records every operation in chronological order so assertions can
|
||||
verify the engine called the gateway with the expected arguments, supports
|
||||
configurable failure modes for negative-path tests, and tracks stored charges
|
||||
in a `Map` so refunds and captures behave realistically against earlier
|
||||
creates.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `PaymentGateway` implementation — `createCharge`, `captureCharge`,
|
||||
`refundCharge`, `getCharge`, `validatePaymentMethod`.
|
||||
- Operation log (`gateway.operations`) — readonly array of recorded calls.
|
||||
- Configurable failure mode (`shouldFail` + `failureCode` + `failureMessage`).
|
||||
- Configurable artificial delay (`delayMs`) to exercise async handling.
|
||||
- Injectable `now()` clock so timestamps stay reproducible in tests.
|
||||
- `resetChargeCounter()` resets the module-level sequence so charge ids are
|
||||
stable across runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TestGateway } from "@suaveplan/payment/gateway/test";
|
||||
|
||||
const gateway = new TestGateway();
|
||||
const result = await gateway.createCharge(
|
||||
{ amount: 5000n, currency: "USD" },
|
||||
{ id: "pm_test", type: "card", last4: "4242" }
|
||||
);
|
||||
console.log(result.status, gateway.chargeCount);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { resetChargeCounter, TestGateway } from "@suaveplan/payment/gateway/test";
|
||||
|
||||
resetChargeCounter();
|
||||
const gateway = new TestGateway({
|
||||
shouldFail: false,
|
||||
delayMs: 0,
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const created = await gateway.createCharge(
|
||||
{ amount: 2000n, currency: "EUR" },
|
||||
{ id: "pm_card", type: "card", last4: "0004", expiryMonth: 12, expiryYear: 2030 },
|
||||
{ capture: false, metadata: { source: "stage" } }
|
||||
);
|
||||
const captured = await gateway.captureCharge(created.chargeId);
|
||||
const refunded = await gateway.refundCharge(created.chargeId, {
|
||||
amount: 500n,
|
||||
currency: "EUR",
|
||||
});
|
||||
console.log(captured.status, refunded.status, gateway.operations.length);
|
||||
gateway.clearOperations();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GatewayOperation` — `{ type, chargeId?, amount?, timestamp }`.
|
||||
- `TestGatewayConfig` — `{ shouldFail?, failureMessage?, failureCode?,
|
||||
delayMs?, now? }`.
|
||||
- `new TestGateway(config?)` → instance.
|
||||
- `gateway.operations` → readonly log.
|
||||
- `gateway.chargeCount` → number of stored charges.
|
||||
- `gateway.clearOperations()` → reset the log.
|
||||
- `resetChargeCounter()` → reset the module-level counter.
|
||||
- Full `PaymentGateway` surface — `createCharge`, `captureCharge`,
|
||||
`refundCharge`, `getCharge`, `validatePaymentMethod`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The gateway stores charges in a private `Map<string, StoredCharge>` keyed
|
||||
by the generated charge id. Refund and capture mutate the stored entry
|
||||
rather than replacing it so subsequent `getCharge` calls reflect the
|
||||
latest balances.
|
||||
- `shouldFail` causes every operation to return a `success: false`
|
||||
`GatewayResult`. The charge id is empty for the create case and echoed
|
||||
back for capture / refund so callers can correlate.
|
||||
- Validation rejects card methods missing `last4` or expiry, and treats
|
||||
past expiries as invalid — reproducing the behaviour callers expect from
|
||||
a real adapter.
|
||||
- `getCharge` throws a plain `Error` (not a `PaymentError`) on missing
|
||||
charges so the gateway behaves like a raw provider; consumers compose
|
||||
`paymentNotFound` themselves when promoting that failure.
|
||||
- `now` is invoked once per recorded operation so the operation log and
|
||||
charge timestamps stay aligned even under a frozen clock.
|
||||
123
docs/money/payment/payment/src/gateway/types.md
Normal file
123
docs/money/payment/payment/src/gateway/types.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# gateway/types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the abstract `PaymentGateway` contract every concrete adapter (the
|
||||
in-memory `TestGateway`, the Stripe REST adapter, the PayPal REST adapter, or
|
||||
caller-provided implementations) must satisfy. Centralising the contract here
|
||||
keeps the engine, retry, and reconciliation modules agnostic to which gateway
|
||||
backs them and ensures every adapter speaks the same status / result /
|
||||
validation vocabulary.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentGateway` interface — five async methods covering create, capture,
|
||||
refund, get, and validate.
|
||||
- `ChargeOptions` — caller-supplied `description`, `customerId`,
|
||||
`capture`, `metadata`, and `idempotencyKey` passthrough.
|
||||
- `GatewayResult` — `{ success, chargeId, status, amount, errorCode?,
|
||||
errorMessage?, metadata?, createdAt }`.
|
||||
- `GatewayCharge` — full charge snapshot retrieved from the gateway.
|
||||
- `GatewayChargeStatus` union — seven canonical states the gateway can
|
||||
report, mapped per-adapter from provider-specific vocabulary.
|
||||
- `ValidationResult` — `{ valid, errors }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { PaymentGateway } from "@suaveplan/payment/gateway/types";
|
||||
|
||||
declare const gateway: PaymentGateway;
|
||||
const result = await gateway.createCharge(
|
||||
{ amount: 1000n, currency: "USD" },
|
||||
{ id: "pm_test", type: "card", last4: "4242" }
|
||||
);
|
||||
console.log(result.status, result.chargeId);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
GatewayCharge,
|
||||
GatewayResult,
|
||||
PaymentGateway,
|
||||
ValidationResult,
|
||||
} from "@suaveplan/payment/gateway/types";
|
||||
import type { MoneyLike } from "@suaveplan/money";
|
||||
import type { PaymentMethod } from "@suaveplan/payment/types/payment";
|
||||
|
||||
class NoopGateway implements PaymentGateway {
|
||||
readonly name = "noop";
|
||||
async createCharge(
|
||||
amount: MoneyLike,
|
||||
_method: PaymentMethod
|
||||
): Promise<GatewayResult> {
|
||||
return {
|
||||
success: true,
|
||||
chargeId: "ch_noop",
|
||||
status: "captured",
|
||||
amount,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
async captureCharge(chargeId: string): Promise<GatewayResult> {
|
||||
return {
|
||||
success: true,
|
||||
chargeId,
|
||||
status: "captured",
|
||||
amount: { amount: 0n, currency: "USD" },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
async refundCharge(chargeId: string): Promise<GatewayResult> {
|
||||
return {
|
||||
success: true,
|
||||
chargeId,
|
||||
status: "refunded",
|
||||
amount: { amount: 0n, currency: "USD" },
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
async getCharge(chargeId: string): Promise<GatewayCharge> {
|
||||
return {
|
||||
chargeId,
|
||||
status: "captured",
|
||||
amount: { amount: 0n, currency: "USD" },
|
||||
capturedAmount: { amount: 0n, currency: "USD" },
|
||||
refundedAmount: { amount: 0n, currency: "USD" },
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
async validatePaymentMethod(): Promise<ValidationResult> {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentGateway` — `{ name, createCharge, captureCharge, refundCharge,
|
||||
getCharge, validatePaymentMethod }`.
|
||||
- `ChargeOptions` — `{ description?, customerId?, capture?, metadata?,
|
||||
idempotencyKey? }`.
|
||||
- `GatewayChargeStatus` — `pending | authorized | captured | refunded |
|
||||
partially_refunded | failed | canceled`.
|
||||
- `GatewayResult` — operation result.
|
||||
- `GatewayCharge` — full charge snapshot.
|
||||
- `ValidationResult` — `{ valid, errors }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `createCharge` accepts an optional `paymentMethod` even when the gateway
|
||||
doesn't need one (e.g. PayPal Orders) so the engine and adapter signatures
|
||||
align.
|
||||
- `captureCharge(amount)` allows partial capture — adapters that don't
|
||||
support it must ignore the parameter and capture the full authorised
|
||||
amount (Stripe behaviour); adapters that do support it honour the value.
|
||||
- `refundCharge(amount, reason)` likewise allows partial refunds; missing
|
||||
arguments mean "full refund".
|
||||
- `errorCode` and `errorMessage` only appear on `success: false` results.
|
||||
- The interface is intentionally narrow — gateway-specific webhook handling
|
||||
lives in `webhook/`, not here, so adapters stay easy to swap.
|
||||
108
docs/money/payment/payment/src/hooks/hooks.md
Normal file
108
docs/money/payment/payment/src/hooks/hooks.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# hooks
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Tiny generic hooks bus owned by the payment package. It powers the
|
||||
`payment:processing` pipeline that fires inside `PaymentEngine.createIntent`
|
||||
before the input reaches the provider, giving callers a place to enrich,
|
||||
normalise, or validate intent data without monkey-patching the engine. A
|
||||
self-contained primitive (no `@suaveplan/events/hooks` dependency exists on
|
||||
suaveplan) so the payment package stays universal and additively
|
||||
extensible.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `Hooks<TMap extends Record<string, unknown>>` class — pluggable map
|
||||
of keys → data envelopes.
|
||||
- Synchronous registration, async sequential application.
|
||||
- Priority-ordered execution (lower priority runs earlier; default `0`).
|
||||
- Unsubscribe handle returned from every `hook(...)` call.
|
||||
- `clear(key)` to drop every handler for a key; `count(key)` for diagnostics.
|
||||
- Singleton `paymentHooks` + `onPaymentProcessing` shorthand for the canonical
|
||||
`payment:processing` pipeline.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
|
||||
|
||||
const unsubscribe = onPaymentProcessing((data) => ({
|
||||
...data,
|
||||
input: {
|
||||
...data.input,
|
||||
metadata: { ...data.input.metadata, source: "webapp" },
|
||||
},
|
||||
}));
|
||||
|
||||
// later, when shutting down a request:
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Hooks, onPaymentProcessing } from "@suaveplan/payment/hooks/hooks";
|
||||
|
||||
// Use the generic class for a custom map outside the singleton.
|
||||
interface MyMap extends Record<string, unknown> {
|
||||
"refund:rerouting": { customerId: string; reroute: boolean };
|
||||
}
|
||||
const refundHooks = new Hooks<MyMap>();
|
||||
refundHooks.hook("refund:rerouting", async (data) => {
|
||||
const reroute = await isHighValue(data.customerId);
|
||||
return { ...data, reroute };
|
||||
});
|
||||
|
||||
// Composition: stack two ordered handlers on the singleton.
|
||||
onPaymentProcessing(
|
||||
(data) => ({
|
||||
...data,
|
||||
input: {
|
||||
...data.input,
|
||||
description: data.input.description?.trim(),
|
||||
},
|
||||
}),
|
||||
{ priority: -10 } // runs before any default-priority handler
|
||||
);
|
||||
onPaymentProcessing((data) => ({
|
||||
...data,
|
||||
metadata: { ...data.metadata, normalised: true },
|
||||
}));
|
||||
|
||||
declare function isHighValue(id: string): Promise<boolean>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentProcessingHookData` — `{ input: CreatePaymentIntentInput, metadata:
|
||||
Record<string, unknown> }`.
|
||||
- `PaymentHookMap` — `{ "payment:processing": PaymentProcessingHookData }`.
|
||||
- `HookHandler<TData>` — `(data: TData) => TData | Promise<TData>`.
|
||||
- `HookHandlerOptions` — `{ priority?: number }`.
|
||||
- `Hooks<TMap>.hook(key, handler, options?)` → unsubscribe function.
|
||||
- `Hooks<TMap>.applyHooksAsync(key, data)` → resolves to the data envelope
|
||||
after every handler has run in priority order. With zero registered
|
||||
handlers, returns `data` unchanged.
|
||||
- `Hooks<TMap>.clear(key)` → drops every handler for `key`.
|
||||
- `Hooks<TMap>.count(key)` → number of handlers.
|
||||
- `paymentHooks` — pre-instantiated `Hooks<PaymentHookMap>` singleton.
|
||||
- `onPaymentProcessing(handler, options?)` → shorthand wrapper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Handlers run sequentially, not in parallel: the contract is "each handler
|
||||
sees the output of the previous one". This matches a transformation
|
||||
pipeline far better than fire-and-forget pub/sub.
|
||||
- The `priority` field is stable-sorted; handlers with equal priority retain
|
||||
registration order.
|
||||
- Returning a Promise from a handler is fully supported; the bus awaits it
|
||||
before invoking the next handler.
|
||||
- Removing a handler during execution: the snapshot is taken via the array
|
||||
iterator at call-time; mid-flight `unsubscribe()` calls take effect on the
|
||||
next `applyHooksAsync` invocation.
|
||||
- The `Hooks` class is intentionally minimal — no built-in error envelope, no
|
||||
per-handler timeout. Callers needing those features wrap their handler.
|
||||
126
docs/money/payment/payment/src/receipt/receipt.md
Normal file
126
docs/money/payment/payment/src/receipt/receipt.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# receipt
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Generates structured `Receipt` objects from `PaymentIntent` plus caller-
|
||||
supplied line items, then provides a minimal plain-text renderer for log
|
||||
trails, email fallbacks, and CLI surfaces. Receipts are pure data — HTML /
|
||||
PDF / locale-aware money formatting are left to consumers so the package
|
||||
stays universal.
|
||||
|
||||
## Features
|
||||
|
||||
- `ReceiptGenerator` class with injected `merchantName`, address, contact,
|
||||
logo, footer, and tax label.
|
||||
- Injectable `now` clock and `nextId` generator for deterministic tests.
|
||||
- `generate(input)` — builds a `Receipt` carrying merchant identity,
|
||||
customer details, line items, subtotal / tax / total, captured / refunded
|
||||
totals, the attached payment method, the refund history, and the receipt
|
||||
creation timestamp.
|
||||
- `toPlainText(receipt)` — human-readable rendering with the merchant
|
||||
header, line items, totals, payment method last-four, and footer.
|
||||
- `resetReceiptCounter()` — reset the module-level sequence counter; used
|
||||
by tests to keep receipt ids stable across runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ReceiptGenerator,
|
||||
type ReceiptLineItem,
|
||||
} from "@suaveplan/payment/receipt/receipt";
|
||||
import type { PaymentIntent } from "@suaveplan/payment/types/payment";
|
||||
|
||||
declare const captured: PaymentIntent;
|
||||
const generator = new ReceiptGenerator({ merchantName: "Acme Store" });
|
||||
const lineItems: ReceiptLineItem[] = [
|
||||
{
|
||||
description: "Widget",
|
||||
quantity: 2,
|
||||
unitPrice: { amount: 1500n, currency: "USD" },
|
||||
total: { amount: 3000n, currency: "USD" },
|
||||
},
|
||||
];
|
||||
const receipt = generator.generate({
|
||||
intent: captured,
|
||||
lineItems,
|
||||
subtotal: { amount: 3000n, currency: "USD" },
|
||||
total: { amount: 3000n, currency: "USD" },
|
||||
});
|
||||
console.log(generator.toPlainText(receipt));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ReceiptGenerator,
|
||||
resetReceiptCounter,
|
||||
} from "@suaveplan/payment/receipt/receipt";
|
||||
import type { PaymentIntent } from "@suaveplan/payment/types/payment";
|
||||
|
||||
declare const intent: PaymentIntent;
|
||||
resetReceiptCounter();
|
||||
let seq = 0;
|
||||
const generator = new ReceiptGenerator({
|
||||
merchantName: "Acme Store",
|
||||
merchantAddress: "123 Main St",
|
||||
merchantEmail: "billing@acme.com",
|
||||
footerText: "Thank you for your purchase!",
|
||||
taxLabel: "VAT",
|
||||
now: () => 1_700_000_000_000,
|
||||
nextId: () => `rcpt_${(++seq).toString().padStart(6, "0")}`,
|
||||
});
|
||||
const receipt = generator.generate({
|
||||
intent,
|
||||
lineItems: [
|
||||
{
|
||||
description: "Widget",
|
||||
quantity: 1,
|
||||
unitPrice: { amount: 1000n, currency: "EUR" },
|
||||
total: { amount: 1000n, currency: "EUR" },
|
||||
},
|
||||
],
|
||||
subtotal: { amount: 1000n, currency: "EUR" },
|
||||
tax: { amount: 200n, currency: "EUR" },
|
||||
total: { amount: 1200n, currency: "EUR" },
|
||||
customerName: "Jane Doe",
|
||||
});
|
||||
const text = generator.toPlainText(receipt);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ReceiptLineItem` — `{ description, quantity, unitPrice, total }`.
|
||||
- `ReceiptConfig` — merchant identity + presentation overrides +
|
||||
`now`/`nextId` injectables.
|
||||
- `Receipt` — structured receipt record returned by `generate`.
|
||||
- `GenerateReceiptInput` — `{ intent, lineItems, subtotal, tax?, total,
|
||||
customerName?, customerEmail? }`.
|
||||
- `new ReceiptGenerator(config)` → instance.
|
||||
- `generator.generate(input)` → `Receipt`.
|
||||
- `generator.toPlainText(receipt)` → multi-line string.
|
||||
- `resetReceiptCounter()` — module-level reset of the default sequence.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default id generator (`defaultGenerateReceiptId`) emits
|
||||
`rcpt_<timestamp36>_<seq36>` — collision-resistant within a single process,
|
||||
not cryptographically random. Callers needing UUIDs pass `nextId`.
|
||||
- The plain-text renderer concatenates with `"\n"` (not `os.EOL`) to stay
|
||||
universal — consumers can normalise per-platform as needed.
|
||||
- `amountRefunded.amount > 0n` gates the "Refunded:" line so unrefunded
|
||||
receipts read cleanly without a trailing "Refunded: 0" line.
|
||||
- `taxLabel` falls back to `"Tax"` only when `receipt.tax` is present and
|
||||
`taxLabel` is undefined — both at the receipt level and the config level
|
||||
resolve here.
|
||||
- `paymentMethod` rendering prefers `brand` over `type` when both are
|
||||
present and `last4` is known; otherwise it falls back to the bare type.
|
||||
- The injected `now` source is invoked once per `generate` call; receipts
|
||||
generated in a tight loop with a frozen clock all share the same
|
||||
`createdAt` ISO string, which is the desired behaviour for deterministic
|
||||
golden-file tests.
|
||||
145
docs/money/payment/payment/src/reconciliation/reconciliation.md
Normal file
145
docs/money/payment/payment/src/reconciliation/reconciliation.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# reconciliation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Reconciles a batch of internal payment records against gateway transaction
|
||||
records, matches them by id, surfaces amount / currency / status
|
||||
discrepancies, and emits a structured report that downstream tooling
|
||||
(dashboards, alerts, finance exports) can act on. Pure function over the two
|
||||
input arrays — no state is retained between calls — so reconciliation runs
|
||||
safely from any worker.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentReconciliation.reconcile(internal, gateway)` →
|
||||
`ReconciliationReport`.
|
||||
- Matches internal records to gateway records via `gatewayChargeId` ↔
|
||||
`chargeId`.
|
||||
- Three entry statuses: `"matched"`, `"unmatched"` (either side), and
|
||||
`"discrepancy"` (matched but fields disagree).
|
||||
- Discrepancy detection covers amount, currency, and normalised status
|
||||
(lower-case + dashes/underscores stripped — so `"PARTIALLY_REFUNDED"` and
|
||||
`"partially-refunded"` are treated as equal).
|
||||
- Counts every category in the report header so callers can branch on the
|
||||
summary without re-walking the entries.
|
||||
- Injectable `now()` clock so report timestamps are deterministic.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentReconciliation } from "@suaveplan/payment/reconciliation/reconciliation";
|
||||
|
||||
const reconciliation = new PaymentReconciliation();
|
||||
const report = reconciliation.reconcile(
|
||||
[
|
||||
{
|
||||
id: "pi_001",
|
||||
gatewayChargeId: "ch_001",
|
||||
amount: { amount: 1000n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
chargeId: "ch_001",
|
||||
amount: { amount: 1000n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
},
|
||||
]
|
||||
);
|
||||
console.log(report.matchedCount, report.discrepancyCount);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PaymentReconciliation } from "@suaveplan/payment/reconciliation/reconciliation";
|
||||
|
||||
const reconciliation = new PaymentReconciliation({
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
const report = reconciliation.reconcile(
|
||||
[
|
||||
{
|
||||
id: "pi_001",
|
||||
amount: { amount: 100n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
}, // no gateway id → unmatched
|
||||
{
|
||||
id: "pi_002",
|
||||
gatewayChargeId: "ch_002",
|
||||
amount: { amount: 200n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
}, // discrepancy: gateway says 199
|
||||
{
|
||||
id: "pi_003",
|
||||
gatewayChargeId: "ch_999",
|
||||
amount: { amount: 300n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
}, // unmatched: gateway has no ch_999
|
||||
],
|
||||
[
|
||||
{
|
||||
chargeId: "ch_002",
|
||||
amount: { amount: 199n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
},
|
||||
{
|
||||
chargeId: "ch_orphan",
|
||||
amount: { amount: 400n, currency: "USD" },
|
||||
status: "captured",
|
||||
createdAt: "2026-05-17T00:00:00Z",
|
||||
}, // orphan: no internal record
|
||||
]
|
||||
);
|
||||
console.log(
|
||||
report.matchedCount,
|
||||
report.unmatchedInternalCount,
|
||||
report.unmatchedGatewayCount,
|
||||
report.discrepancyCount
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ReconciliationStatus` — `"matched" | "unmatched" | "discrepancy"`.
|
||||
- `InternalRecord` — `{ id, gatewayChargeId?, amount, status, createdAt }`.
|
||||
- `GatewayRecord` — `{ chargeId, amount, status, createdAt }`.
|
||||
- `ReconciliationEntry` — `{ status, internalRecord?, gatewayRecord?,
|
||||
discrepancies }`.
|
||||
- `ReconciliationReport` — `{ matchedCount, unmatchedInternalCount,
|
||||
unmatchedGatewayCount, discrepancyCount, entries, reconciledAt }`.
|
||||
- `PaymentReconciliationConfig` — `{ now? }`.
|
||||
- `new PaymentReconciliation(config?)` → instance.
|
||||
- `reconcile(internal, gateway)` → `ReconciliationReport`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The orphan-gateway pass uses a `Set<string>` of matched-on-input charge
|
||||
ids to skip already-paired records — O(n+m) instead of O(n×m).
|
||||
- Status normalisation is intentionally aggressive — finance teams rarely
|
||||
care whether a gateway uses `"partially_refunded"` versus
|
||||
`"partially-refunded"`; the reconciliation should not flag that as a
|
||||
discrepancy.
|
||||
- The matcher pairs by charge id only — if two internal records share a
|
||||
`gatewayChargeId` (which should be a data integrity violation),
|
||||
both will be flagged as discrepancies (or matched, if the gateway
|
||||
record agrees) and the latter call to `matchedGatewayIds.add(id)` is a
|
||||
no-op.
|
||||
- An internal record without `gatewayChargeId` produces an "unmatched"
|
||||
entry with a synthetic discrepancy string so reports stay self-
|
||||
describing.
|
||||
- Currency is part of the discrepancy check — a USD ↔ EUR mismatch with
|
||||
the same amount value is flagged separately from the amount mismatch
|
||||
so finance can route the alert correctly.
|
||||
- `reconciledAt` is taken once per call from the injected clock.
|
||||
120
docs/money/payment/payment/src/refund/refund.md
Normal file
120
docs/money/payment/payment/src/refund/refund.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# refund
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Tracks per-intent refund history with balance validation, partial-refund
|
||||
support, and reason categorisation. The `RefundManager` is engine-agnostic
|
||||
— pair it with the `PaymentEngine` (which already enforces refund balance
|
||||
against provider-reported totals) when you need an in-process ledger for
|
||||
analytics, dispute prep, or summary reporting.
|
||||
|
||||
## Features
|
||||
|
||||
- `registerCapture` seeds the in-memory record for an intent.
|
||||
- `createRefund` raises `PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)` when
|
||||
the amount would exceed the remaining balance, and
|
||||
`PaymentError(PAYMENT.REFUND_INTENT_NOT_REGISTERED)` when the intent was
|
||||
never seeded.
|
||||
- `getSummary` returns a `RefundSummary` with `capturedAmount`,
|
||||
`totalRefunded`, `remainingBalance`, `refundCount`, the refund list, and a
|
||||
`fullyRefunded` flag.
|
||||
- `getRefundsByReason` filters by `RefundReason`.
|
||||
- `canRefund` predicate — true when the proposed amount fits remaining.
|
||||
- Injectable `now` clock and `nextId` generator.
|
||||
- `resetRefundCounter()` resets the module-level sequence for deterministic
|
||||
tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RefundManager } from "@suaveplan/payment/refund/refund";
|
||||
|
||||
const manager = new RefundManager();
|
||||
manager.registerCapture({
|
||||
intentId: "pi_001",
|
||||
capturedAmount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
const refund = manager.createRefund({
|
||||
intentId: "pi_001",
|
||||
amount: { amount: 2000n, currency: "USD" },
|
||||
reason: "requested_by_customer",
|
||||
});
|
||||
console.log(refund.id, manager.getSummary("pi_001"));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
RefundManager,
|
||||
resetRefundCounter,
|
||||
} from "@suaveplan/payment/refund/refund";
|
||||
import {
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
|
||||
resetRefundCounter();
|
||||
let seq = 0;
|
||||
const manager = new RefundManager({
|
||||
now: () => 1_700_000_000_000,
|
||||
nextId: () => `ref_${(++seq).toString().padStart(6, "0")}`,
|
||||
});
|
||||
manager.registerCapture({
|
||||
intentId: "pi_002",
|
||||
capturedAmount: { amount: 10_000n, currency: "EUR" },
|
||||
});
|
||||
manager.createRefund({
|
||||
intentId: "pi_002",
|
||||
amount: { amount: 4_000n, currency: "EUR" },
|
||||
reason: "duplicate",
|
||||
});
|
||||
try {
|
||||
manager.createRefund({
|
||||
intentId: "pi_002",
|
||||
amount: { amount: 7_000n, currency: "EUR" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof PaymentError &&
|
||||
err.code === PAYMENT_CODES.REFUND_EXCEEDS_CAPTURED
|
||||
) {
|
||||
console.warn("would exceed remaining balance");
|
||||
}
|
||||
}
|
||||
console.log(manager.canRefund("pi_002", { amount: 6_000n, currency: "EUR" }));
|
||||
console.log(manager.getRefundsByReason("pi_002", "duplicate").length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RefundSummary` — `{ intentId, capturedAmount, totalRefunded,
|
||||
remainingBalance, refundCount, refunds, fullyRefunded }`.
|
||||
- `CreateRefundInput` — `{ intentId, amount, reason?, metadata? }`.
|
||||
- `RegisterCaptureInput` — `{ intentId, capturedAmount }`.
|
||||
- `RefundManagerConfig` — `{ now?, nextId? }`.
|
||||
- `new RefundManager(config?)` → instance.
|
||||
- `registerCapture(input)` → `void`.
|
||||
- `createRefund(input)` → `Refund` (or throws typed `PaymentError`).
|
||||
- `getSummary(intentId)` → `RefundSummary | undefined`.
|
||||
- `getRefundsByReason(intentId, reason)` → `readonly Refund[]`.
|
||||
- `canRefund(intentId, amount)` → `boolean`.
|
||||
- `resetRefundCounter()` — module-level sequence reset.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The internal `Map` is keyed by intent id — multiple calls to
|
||||
`registerCapture` for the same intent replace the previous record.
|
||||
- `getSummary` returns a defensive copy of the refund list so callers
|
||||
cannot mutate the manager's internal state.
|
||||
- `getRefundsByReason` returns an empty array (not `undefined`) for unknown
|
||||
intents so callers can use the result with `.length` / `.filter` without
|
||||
null checks.
|
||||
- `canRefund` uses `<=` so a refund that exactly drains the remaining
|
||||
balance is accepted; the resulting record becomes `fullyRefunded`.
|
||||
- The injected `now` is invoked once per `createRefund` call so all refund
|
||||
timestamps stay consistent under a frozen clock.
|
||||
99
docs/money/payment/payment/src/retry/retry.md
Normal file
99
docs/money/payment/payment/src/retry/retry.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# retry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Generic retry helper for payment operations. Wraps any async function with
|
||||
exponential backoff + jitter, honours a non-retryable error-code blocklist
|
||||
matched against thrown-error message text or extracted-from-result codes,
|
||||
and reports an envelope (`RetryResult<T>`) with `success`, `attempts`, the
|
||||
collected `errors`, and total wall time. The RNG and sleep functions are
|
||||
injectable so retry tests are deterministic.
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential backoff (`baseDelayMs * 2^(attempt - 1)`) capped at
|
||||
`maxDelayMs` with `[-jitter, +jitter]` * capped jitter range.
|
||||
- Non-retryable code blocklist (default 5 Stripe-style codes —
|
||||
`card_declined`, `expired_card`, `invalid_number`, `fraudulent`,
|
||||
`authentication_required`) — both for thrown error messages and
|
||||
extracted-from-result codes via the caller-supplied `extractCode` hook.
|
||||
- `isSuccess` predicate decoupled from the underlying operation so callers
|
||||
can plug in any "good enough" test (e.g.
|
||||
`result => result.success === true`).
|
||||
- Injectable RNG (`random`) so backoff jitter is reproducible.
|
||||
- Injectable sleep (`sleep`) so tests advance the clock without real timers.
|
||||
- `calculateDelay(attempt)` exposed for callers that want to log expected
|
||||
delays or wire their own scheduler.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PaymentRetrier } from "@suaveplan/payment/retry/retry";
|
||||
|
||||
const retrier = new PaymentRetrier({ maxAttempts: 3 });
|
||||
const result = await retrier.execute(
|
||||
() => fetch("/charge", { method: "POST" }).then((r) => r.json()),
|
||||
(res: { success: boolean }) => res.success === true
|
||||
);
|
||||
console.log(result.success, result.attempts);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PaymentRetrier } from "@suaveplan/payment/retry/retry";
|
||||
|
||||
const retrier = new PaymentRetrier({
|
||||
maxAttempts: 4,
|
||||
baseDelayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
jitter: 0.5,
|
||||
nonRetryableCodes: ["card_declined", "do_not_honor"],
|
||||
random: () => 0.5, // deterministic jitter
|
||||
sleep: async () => {}, // no-op delays under test
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const result = await retrier.execute(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
return { success: false, errorCode: "transient" } as const;
|
||||
}
|
||||
return { success: true, errorCode: undefined } as const;
|
||||
},
|
||||
(r) => r.success,
|
||||
(r) => r.errorCode
|
||||
);
|
||||
console.log(result.success, result.attempts, retrier.calculateDelay(1));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentRetryConfig` — `{ maxAttempts?, baseDelayMs?, maxDelayMs?,
|
||||
jitter?, nonRetryableCodes?, random?, sleep? }`.
|
||||
- `RetryResult<T>` — `{ result?, success, attempts, errors, durationMs }`.
|
||||
- `new PaymentRetrier(config?)` → instance.
|
||||
- `retrier.execute(operation, isSuccess, extractCode?)` → `Promise<RetryResult<T>>`.
|
||||
- `retrier.calculateDelay(attempt)` → number.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The non-retryable blocklist is matched by `message.toLowerCase().includes(code)`
|
||||
for thrown `Error`s, and by exact equality for codes returned by
|
||||
`extractCode`. Both paths short-circuit further retries.
|
||||
- `errors` accumulates every failure across attempts — successful results
|
||||
return `errors: []` only if no prior attempt failed.
|
||||
- The delay calculation uses `random() * 2 - 1` to centre the jitter at the
|
||||
capped delay; with the default `0.25` jitter, actual delays span ~75% to
|
||||
~125% of the capped value.
|
||||
- `Date.now()` provides wall-clock measurement for `durationMs`; under a
|
||||
no-op `sleep` injection the duration reflects only the operation work
|
||||
itself, which is the desired behaviour for tests.
|
||||
- The retrier deliberately makes no assumption about thread-safety — it is
|
||||
fine to share an instance across requests as long as the underlying
|
||||
operation and predicate are themselves reentrant.
|
||||
125
docs/money/payment/payment/src/tax/tax.md
Normal file
125
docs/money/payment/payment/src/tax/tax.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# tax
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
In-memory tax-rate registry and line-item calculator. Supports tax-inclusive
|
||||
(price-includes-tax) and tax-exclusive (price-excludes-tax) pricing, falls
|
||||
back through region → country → default when looking up rates, and produces a
|
||||
typed `TaxCalculationResult` that downstream renderers (receipts, checkout
|
||||
summaries) consume directly. Universal and instance-scoped — no module
|
||||
state.
|
||||
|
||||
## Features
|
||||
|
||||
- `addRate` / `removeRate` / `getRate` over a `(country, region?)` key.
|
||||
- `calculate(items, jurisdiction)` produces both per-line and aggregate
|
||||
results carrying subtotal, tax amount, total, and the resolved tax-rate
|
||||
metadata.
|
||||
- Tax-inclusive mode subtracts the embedded tax portion from the line
|
||||
total via `total * (rate / (1 + rate))`.
|
||||
- Tax-exclusive mode adds `Math.round(amount * rate)` on top.
|
||||
- Tax-exempt items are surfaced with a synthetic `"Exempt"` tax name and
|
||||
zero tax amount.
|
||||
- Default rate fallback (configurable on construction) when no
|
||||
jurisdiction is registered.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TaxCalculator } from "@suaveplan/payment/tax/tax";
|
||||
|
||||
const calc = new TaxCalculator();
|
||||
calc.addRate({ country: "DE", rate: 0.19, name: "VAT" });
|
||||
const result = calc.calculate(
|
||||
[
|
||||
{
|
||||
description: "Widget",
|
||||
amount: { amount: 1000n, currency: "EUR" },
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
{ country: "DE" }
|
||||
);
|
||||
console.log(result.totalTax, result.total);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TaxCalculator } from "@suaveplan/payment/tax/tax";
|
||||
|
||||
const calc = new TaxCalculator({
|
||||
country: "DEFAULT",
|
||||
rate: 0,
|
||||
name: "No Tax",
|
||||
});
|
||||
calc.addRate({ country: "US", rate: 0, name: "No Federal Tax" });
|
||||
calc.addRate({
|
||||
country: "US",
|
||||
region: "CA",
|
||||
rate: 0.0725,
|
||||
name: "CA Sales Tax",
|
||||
});
|
||||
calc.addRate({
|
||||
country: "FR",
|
||||
rate: 0.2,
|
||||
name: "VAT-Inclusive",
|
||||
inclusive: true,
|
||||
});
|
||||
|
||||
const taxableFR = calc.calculate(
|
||||
[
|
||||
{
|
||||
description: "Service",
|
||||
amount: { amount: 12_000n, currency: "EUR" },
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
description: "Free voucher",
|
||||
amount: { amount: 0n, currency: "EUR" },
|
||||
quantity: 1,
|
||||
taxExempt: true,
|
||||
},
|
||||
],
|
||||
{ country: "FR" }
|
||||
);
|
||||
console.log(taxableFR.lineItems[0]?.taxAmount, taxableFR.inclusive);
|
||||
console.log(calc.removeRate("US", "CA"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TaxRate` — `{ country, region?, rate, name, inclusive? }`.
|
||||
- `TaxLineItem` — `{ description, amount, quantity, taxExempt? }`.
|
||||
- `TaxLineResult` — `{ description, subtotal, taxAmount, taxRate, taxName,
|
||||
total }`.
|
||||
- `TaxCalculationResult` — `{ lineItems, subtotal, totalTax, total, taxRate,
|
||||
inclusive }`.
|
||||
- `TaxJurisdiction` — `{ country, region? }`.
|
||||
- `new TaxCalculator(defaultRate?)` → instance with default rate
|
||||
`{ country: "DEFAULT", rate: 0, name: "No Tax" }` unless overridden.
|
||||
- `calc.addRate(rate)` — register.
|
||||
- `calc.removeRate(country, region?)` → `boolean`.
|
||||
- `calc.getRate(jurisdiction)` → resolved `TaxRate`.
|
||||
- `calc.calculate(items, jurisdiction)` → `TaxCalculationResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Jurisdiction keys are flat strings (`"<country>"` or `"<country>:<region>"`)
|
||||
to avoid nested `Map<string, Map<string, ...>>` indirection.
|
||||
- The calculator currency defaults to `"USD"` when invoked with an empty
|
||||
line-item list. Callers passing a single empty list get a result of
|
||||
`{ lineItems: [], subtotal: 0n USD, totalTax: 0n USD, total: 0n USD }`.
|
||||
- `BigInt(Math.round(...))` rounding mirrors the upstream behaviour — for
|
||||
exact half-even rounding callers can pre-multiply or wrap the calculator.
|
||||
- `inclusive` defaults to `false` on a registered rate unless explicitly
|
||||
set; the default-rate fallback also defaults to exclusive.
|
||||
- Per-line `taxRate` is the resolved numeric rate (`0.19`), per-line
|
||||
`taxName` is the resolved rate's name (or `"Exempt"`).
|
||||
- Region lookups fall back to country lookups only when the region key
|
||||
misses; this matches the conventional "state overrides country" mental
|
||||
model.
|
||||
103
docs/money/payment/payment/src/tokenization/tokenization.md
Normal file
103
docs/money/payment/payment/src/tokenization/tokenization.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# tokenization
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
PCI-conscious card tokeniser. Validates the PAN with the Luhn check, rejects
|
||||
malformed expiry data and expired cards as typed `PaymentError(INVALID_CARD)`,
|
||||
and hashes the cleaned PAN with HMAC-SHA-256 (via `SubtleCrypto`) so the
|
||||
original card number is never recoverable from the stored token. Only the
|
||||
last four digits, detected brand, and expiry survive in plain text.
|
||||
|
||||
## Features
|
||||
|
||||
- `tokenize(card)` — validate + hash + project to a `TokenizedCard` record.
|
||||
- `validateLuhn(input)` — strips non-digits, applies the Luhn algorithm,
|
||||
rejects PANs shorter than 13 or longer than 19 digits.
|
||||
- `detectBrand(input)` — frozen pattern table covering Visa, Mastercard,
|
||||
Amex, Discover, Diners, JCB, and UnionPay; unknown PANs map to
|
||||
`"unknown"`.
|
||||
- Configurable token prefix (`"tok_"` by default).
|
||||
- Injectable `now` clock and `SubtleCrypto` so the tokeniser is fully
|
||||
deterministic in tests without touching globals.
|
||||
- Invalid cards (Luhn fail, bad month, expired) raise typed
|
||||
`PaymentError(PAYMENT.INVALID_CARD)` with a descriptive reason.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CardTokenizer } from "@suaveplan/payment/tokenization/tokenization";
|
||||
|
||||
const tokeniser = new CardTokenizer({ secretKey: "hmac-secret" });
|
||||
const token = await tokeniser.tokenize({
|
||||
number: "4242 4242 4242 4242",
|
||||
expiryMonth: 12,
|
||||
expiryYear: 2030,
|
||||
});
|
||||
console.log(token.token, token.brand, token.last4);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CardTokenizer } from "@suaveplan/payment/tokenization/tokenization";
|
||||
import {
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
|
||||
const tokeniser = new CardTokenizer({
|
||||
secretKey: "hmac-secret",
|
||||
prefix: "tok_v2_",
|
||||
now: () => 1_700_000_000_000,
|
||||
subtle: globalThis.crypto.subtle,
|
||||
});
|
||||
|
||||
try {
|
||||
await tokeniser.tokenize({
|
||||
number: "4242 4242 4242 4241", // Luhn fail
|
||||
expiryMonth: 12,
|
||||
expiryYear: 2030,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentError && err.code === PAYMENT_CODES.INVALID_CARD) {
|
||||
console.warn("invalid card:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(tokeniser.detectBrand("371449635398431")); // "amex"
|
||||
console.log(tokeniser.validateLuhn("4242424242424242")); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CardInput` — `{ number, expiryMonth, expiryYear, holderName? }`.
|
||||
- `TokenizedCard` — `{ token, last4, brand, expiryMonth, expiryYear,
|
||||
createdAt }`.
|
||||
- `CardTokenizerConfig` — `{ secretKey, prefix?, now?, subtle? }`.
|
||||
- `new CardTokenizer(config)` → instance.
|
||||
- `tokeniser.tokenize(card)` → `Promise<TokenizedCard>`; rejects with
|
||||
`PaymentError(INVALID_CARD)` on Luhn / month / expiry failures.
|
||||
- `tokeniser.validateLuhn(input)` → boolean.
|
||||
- `tokeniser.detectBrand(input)` → `string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- HMAC-SHA-256 is routed through `SubtleCrypto`, which is available in every
|
||||
supported universal runtime (Node 18+, Bun, browsers, Web Workers).
|
||||
- The hex encoder is hand-rolled (no `Buffer`) so no Node-specific API
|
||||
leaks in.
|
||||
- Whitespace and dashes in the input PAN are stripped before validation,
|
||||
hashing, and last-four extraction.
|
||||
- The detector is intentionally narrow — `unknown` is a valid return value
|
||||
rather than a thrown error, so callers can decide whether to refuse
|
||||
unknown brands.
|
||||
- The injected `now` source is invoked once during `tokenize` and once per
|
||||
`validate` (for the expiry comparison); under a frozen clock both reflect
|
||||
the same wall time so tests stay deterministic.
|
||||
- The token format is `"<prefix><hex-sha256>"` — 64 hex chars after the
|
||||
prefix; consumers persisting in a fixed-width column should reserve
|
||||
prefix-length + 64.
|
||||
96
docs/money/payment/payment/src/types/payment.md
Normal file
96
docs/money/payment/payment/src/types/payment.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# types/payment
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type contracts for the payment lifecycle: status union, payment-method
|
||||
descriptors, refund and dispute records, the canonical `PaymentIntent` shape,
|
||||
the `PaymentProvider` adapter interface, the `PaymentEventMap` consumed by the
|
||||
`EventBus`-backed `PaymentEngine`, and the `VALID_TRANSITIONS` map enforcing
|
||||
the state machine. Every other module in the package consumes types from here
|
||||
so the public contract sits in one place and stays stable across follow-up
|
||||
changes.
|
||||
|
||||
## Features
|
||||
|
||||
- `PaymentStatus` — the 9-state lifecycle union (`created` → `pending` →
|
||||
`authorized` → `captured` → `partially_refunded` / `refunded` / `disputed` /
|
||||
`canceled` / `failed`).
|
||||
- `PaymentMethodType` / `CardBrand` / `PaymentMethod` — non-sensitive method
|
||||
descriptors carrying only token / last-four / expiry data.
|
||||
- `CreatePaymentIntentInput` / `RefundInput` — provider-facing input shapes
|
||||
with optional `idempotencyKey` / `metadata` passthrough.
|
||||
- `Refund` / `RefundStatus` / `RefundReason` — refund record + status union.
|
||||
- `Dispute` / `DisputeEvidence` — chargeback record.
|
||||
- `PaymentIntent` — the canonical object every provider returns.
|
||||
- `PaymentProvider` — bring-your-own gateway contract.
|
||||
- `PaymentEventMap` — type-keyed map consumed by the engine's `EventBus`.
|
||||
- `VALID_TRANSITIONS` — frozen state-machine table.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
} from "@suaveplan/payment/types/payment";
|
||||
|
||||
const input: CreatePaymentIntentInput = {
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
};
|
||||
declare function readIntent(): PaymentIntent;
|
||||
const intent = readIntent();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { PaymentProvider } from "@suaveplan/payment/types/payment";
|
||||
|
||||
class MyProvider implements PaymentProvider {
|
||||
readonly name = "my-provider";
|
||||
async createIntent(_input) { /* ... */ throw new Error("stub"); }
|
||||
async confirmIntent(_id) { throw new Error("stub"); }
|
||||
async captureIntent(_id) { throw new Error("stub"); }
|
||||
async cancelIntent(_id) { throw new Error("stub"); }
|
||||
async refund(_input) { throw new Error("stub"); }
|
||||
async getIntent(_id) { throw new Error("stub"); }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PaymentStatus` — string union, exhaustive.
|
||||
- `PaymentMethodType` — `card | bank_transfer | wallet | crypto | manual`.
|
||||
- `CardBrand` — 8 brand identifiers including `unknown`.
|
||||
- `PaymentMethod` — `{ id, type, brand?, last4?, expiryMonth?, expiryYear?, holderName?, metadata? }`.
|
||||
- `CreatePaymentIntentInput` — caller-supplied intent fields; `amount` required.
|
||||
- `RefundInput` — `{ paymentIntentId, amount?, reason?, metadata?, idempotencyKey? }`.
|
||||
- `Refund` — `{ id, paymentIntentId, amount, status, reason?, createdAt, metadata? }`.
|
||||
- `Dispute` — `{ id, paymentIntentId, amount, reason, status, evidence?, createdAt, respondBy? }`.
|
||||
- `PaymentIntent` — full intent record carrying current status, captured /
|
||||
refunded totals, attached method, refund history, optional dispute.
|
||||
- `PaymentProvider` — six async methods covering create / confirm / capture /
|
||||
cancel / refund / get.
|
||||
- `PaymentEventMap` — typed event-name → payload map.
|
||||
- `VALID_TRANSITIONS` — `Readonly<Record<PaymentStatus, readonly PaymentStatus[]>>`,
|
||||
frozen at module load.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Every record is `readonly` — providers and engine helpers return new objects
|
||||
rather than mutating shared state.
|
||||
- `VALID_TRANSITIONS` is `Object.freeze`d at module load so accidental
|
||||
mutation is a structural error, not a silent bug.
|
||||
- All money fields are `MoneyLike` (from `@suaveplan/money`) — never raw
|
||||
numbers — so `bigint` precision flows end-to-end.
|
||||
- The `PaymentEventMap` payloads are single values (not tuples) to match the
|
||||
`EventBus<TMap>` shape from `@suaveplan/events`.
|
||||
- Type-only module: no runtime export besides the frozen `VALID_TRANSITIONS`
|
||||
table. Vitest's `*.types.ts` exclusion does not apply here because we ship
|
||||
one runtime constant; the file is `types/payment.ts` so coverage treats the
|
||||
table as touched by every engine test.
|
||||
116
docs/money/payment/payment/src/webhook/webhook.md
Normal file
116
docs/money/payment/payment/src/webhook/webhook.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# webhook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal webhook signature verifier. Computes an HMAC over a
|
||||
`timestamp.body` envelope using `@suaveplan/crypto-hash` (so no `node:crypto`
|
||||
import leaks in), compares with the constant-time helper from
|
||||
`@suaveplan/crypto-utils`, enforces a configurable tolerance window to defeat
|
||||
replay attacks, and parses the body into a generic-typed `WebhookPayload<T>`.
|
||||
Designed to drop into a request handler — pass the raw body, the
|
||||
provider-supplied signature header, and the provider-supplied timestamp
|
||||
header, and the verifier returns the parsed payload or throws a discriminated
|
||||
`PaymentError`.
|
||||
|
||||
## Features
|
||||
|
||||
- `sign(body, timestamp)` — emit the hex signature consumers can persist or
|
||||
echo back when forwarding webhooks downstream.
|
||||
- `verify<T>(body, signature, timestamp)` — full pipeline: expiry check →
|
||||
constant-time signature compare → JSON parse.
|
||||
- Configurable algorithm (`SHA-256` default, `SHA-384`, `SHA-512`).
|
||||
- Configurable tolerance window (`toleranceMs`, default 5 minutes).
|
||||
- Injectable `now()` clock for deterministic tests.
|
||||
- Errors are typed `PaymentError` with the discriminated `ageMs` /
|
||||
`WEBHOOK_*` codes — never `Error` instances.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebhookVerifier } from "@suaveplan/payment/webhook/webhook";
|
||||
|
||||
const verifier = new WebhookVerifier({ secret: process.env.WEBHOOK_SECRET! });
|
||||
const { payload } = verifier.verify(
|
||||
rawBody,
|
||||
signatureHeader,
|
||||
Number(timestampHeader)
|
||||
);
|
||||
console.log("event:", payload.type, payload.data);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
PAYMENT_CODES,
|
||||
PaymentError,
|
||||
} from "@suaveplan/payment/error-codes/error-codes";
|
||||
import { WebhookVerifier } from "@suaveplan/payment/webhook/webhook";
|
||||
|
||||
// Deterministic clock for replay-window edge cases.
|
||||
let frozenNow = 1_700_000_000_000;
|
||||
const verifier = new WebhookVerifier({
|
||||
secret: "whsec_test",
|
||||
toleranceMs: 60_000,
|
||||
algorithm: "SHA-512",
|
||||
now: () => frozenNow,
|
||||
});
|
||||
|
||||
const ts = frozenNow - 30_000; // 30 seconds old, inside the 60s tolerance
|
||||
const body = JSON.stringify({
|
||||
id: "evt_001",
|
||||
type: "payment.captured",
|
||||
timestamp: ts,
|
||||
data: { intentId: "pi_001" },
|
||||
});
|
||||
const sig = verifier.sign(body, ts);
|
||||
|
||||
interface CaptureEvent {
|
||||
readonly intentId: string;
|
||||
}
|
||||
try {
|
||||
const { payload } = verifier.verify<CaptureEvent>(body, sig, ts);
|
||||
console.log(payload.data.intentId);
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentError && err.code === PAYMENT_CODES.WEBHOOK_EXPIRED) {
|
||||
console.warn("replayed webhook", err.ageMs, "ms");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebhookAlgorithm` — `"SHA-256" | "SHA-384" | "SHA-512"`.
|
||||
- `WebhookVerifierConfig` — `{ secret, toleranceMs?, algorithm?, now? }`.
|
||||
- `WebhookPayload<T>` — `{ id, type, timestamp, data: T }`.
|
||||
- `VerifiedWebhook<T>` — `{ payload: WebhookPayload<T>, receivedAt: number }`.
|
||||
- `new WebhookVerifier(config)` — encode the secret once, normalise defaults.
|
||||
- `verifier.sign(body, timestamp)` → hex string.
|
||||
- `verifier.verify<T>(body, signature, timestamp)` → `VerifiedWebhook<T>`,
|
||||
throws `PaymentError` (`WEBHOOK_EXPIRED`, `WEBHOOK_INVALID_SIGNATURE`, or
|
||||
`WEBHOOK_PARSE_ERROR`) on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- HMAC routes through `@suaveplan/crypto-hash`, whose primitive selector
|
||||
ships pure-TS and native impls — both keep the package universal.
|
||||
- The comparison is delegated to `constantTimeEqual` from
|
||||
`@suaveplan/crypto-utils`; the verifier additionally rejects empty
|
||||
decoded buffers (malformed hex) up front so timing leakage from a
|
||||
truncated provider signature is impossible.
|
||||
- Hex encode/decode is hand-rolled to avoid `Buffer` (which would force a
|
||||
Node dependency). Decode rejects odd-length and non-hex chars by
|
||||
returning an empty `Uint8Array`, which the length-check then rejects.
|
||||
- The age check uses `Math.abs(now - timestamp)`, so both stale (`now > ts`)
|
||||
and clock-skewed future timestamps (`ts > now`) outside the tolerance
|
||||
are rejected with the same `WEBHOOK_EXPIRED` code.
|
||||
- Parse errors thrown by `JSON.parse` are wrapped in `webhookParseError`
|
||||
with the original `Error` preserved on `cause`; non-`Error` rejection
|
||||
cases default to an undefined cause.
|
||||
- The verifier holds the secret as a `Uint8Array` after construction —
|
||||
callers can still pass the literal string at construction time, but the
|
||||
encoded buffer is what's used per request.
|
||||
Reference in New Issue
Block a user