Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# `payment`
Payment intent lifecycle, provider adapters, and fraud detection.
<!-- begin:auto-docs -->
## Packages in `payment`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestrati… |
| [`@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-provide… |
| [`@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 `P… |
| [`@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 wit… |
| [`@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`… |
| [`@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 ad… |
<!-- end:auto-docs -->

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
{
"name": "@suaveplan/payment-provider-adyen",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./adyen/adyen": {
"import": { "types": "./dist/adyen/adyen.d.ts", "default": "./dist/adyen/adyen.js" },
"require": { "types": "./dist/adyen/adyen.d.ts", "default": "./dist/adyen/adyen.js" }
},
"./types/adyen": {
"import": { "types": "./dist/types/adyen.d.ts", "default": "./dist/types/adyen.js" },
"require": { "types": "./dist/types/adyen.d.ts", "default": "./dist/types/adyen.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,435 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
type Harness,
makeFetch,
setupRecording,
} from "../../tests/harness.js";
import { AdyenProvider } from "./adyen.js";
const ADYEN_PAYMENT = {
pspReference: "PSP_123",
resultCode: "Authorised",
amount: { value: 5000, currency: "USD" },
merchantReference: "ref_123",
paymentMethod: { type: "scheme", brand: "visa", lastFour: "4242" },
} as const;
const ADYEN_MODIFICATION = {
pspReference: "MOD_123",
paymentPspReference: "PSP_123",
amount: { value: 5000, currency: "USD" },
status: "received",
} as const;
describe("AdyenProvider", () => {
let h: Harness;
beforeEach(() => {
h = setupRecording(ADYEN_PAYMENT);
});
it("has correct name", { timeout: 5000 }, () => {
expect(h.provider.name).toBe("adyen");
});
describe("createIntent", () => {
it("creates a payment", { timeout: 5000 }, async () => {
const result = await h.provider.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Test",
idempotencyKey: "idem_1",
captureMethod: "manual",
paymentMethodId: "stored_pm_1",
returnUrl: "https://example.com/return",
metadata: { key: "val" },
});
expect(result.id).toBe("PSP_123");
expect(result.status).toBe("authorized");
expect(result.paymentMethod?.brand).toBe("visa");
expect(result.paymentMethod?.last4).toBe("4242");
const headers = h.headersOf(0);
expect(headers["X-API-Key"]).toBe("adyen_key_123");
expect(headers["Idempotency-Key"]).toBe("idem_1");
const body = h.bodyOf(0);
expect(body.merchantAccount).toBe("TestMerchant");
expect(body.reference).toBe("idem_1");
expect(body.captureDelayHours).toBe(0);
expect(body.shopperStatement).toBe("Test");
expect(body.returnUrl).toBe("https://example.com/return");
expect(body.paymentMethod).toEqual({
storedPaymentMethodId: "stored_pm_1",
});
expect(body.metadata).toEqual({ key: "val" });
});
it("uses automatic capture by default", { timeout: 5000 }, async () => {
await h.provider.createIntent({
amount: { amount: 1000n, currency: "USD" },
idempotencyKey: "auto_idem",
});
const body = h.bodyOf(0);
expect(body.merchantAccount).toBe("TestMerchant");
expect(body.captureDelayHours).toBeUndefined();
});
it("uses injected randomId to generate reference when idempotencyKey is absent", {
timeout: 5000,
}, async () => {
let observed: Record<string, unknown> | undefined;
const provider = new AdyenProvider({
apiKey: "adyen_key_123",
merchantAccount: "TestMerchant",
baseUrl: "https://adyen.mock/v71",
fetch: async (_url, init) => {
observed = JSON.parse(String(init?.body)) as Record<
string,
unknown
>;
return new Response(JSON.stringify(ADYEN_PAYMENT), {
status: 200,
});
},
randomId: () => "gen_REF_1",
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
expect(observed?.reference).toBe("gen_REF_1");
});
});
describe("confirmIntent", () => {
it("submits payment details on confirm", {
timeout: 5000,
}, async () => {
const result = await h.provider.confirmIntent("PSP_123");
expect(result.id).toBe("PSP_123");
expect(h.calls[0]?.url).toContain("/details");
expect(h.bodyOf(0).details).toEqual({});
});
});
describe("captureIntent", () => {
it("captures a payment", { timeout: 5000 }, async () => {
const cap = setupRecording(ADYEN_MODIFICATION);
const result = await cap.provider.captureIntent("PSP_123");
expect(result.status).toBe("captured");
expect(result.capturedAmount.amount).toBe(5000n);
expect(cap.bodyOf(0).merchantAccount).toBe("TestMerchant");
expect(cap.calls[0]?.url).toContain("/captures");
});
});
describe("cancelIntent", () => {
it("cancels a payment", { timeout: 5000 }, async () => {
const cancel = setupRecording(ADYEN_MODIFICATION);
const result = await cancel.provider.cancelIntent("PSP_123");
expect(result.status).toBe("canceled");
expect(result.id).toBe("PSP_123");
expect(cancel.bodyOf(0).merchantAccount).toBe("TestMerchant");
expect(cancel.calls[0]?.url).toContain("/cancels");
});
});
describe("refund", () => {
it("creates a refund", { timeout: 5000 }, async () => {
const refund = setupRecording(ADYEN_MODIFICATION);
const result = await refund.provider.refund({
paymentIntentId: "PSP_123",
amount: { amount: 2000n, currency: "USD" },
idempotencyKey: "ref_idem_1",
});
expect(result.id).toBe("MOD_123");
expect(result.status).toBe("pending");
const body = refund.bodyOf(0);
expect(body.merchantAccount).toBe("TestMerchant");
expect(body.amount).toEqual({ value: 2000, currency: "USD" });
expect(refund.headersOf(0)["Idempotency-Key"]).toBe("ref_idem_1");
});
it("refunds without amount", { timeout: 5000 }, async () => {
const refund = setupRecording(ADYEN_MODIFICATION);
const result = await refund.provider.refund({
paymentIntentId: "PSP_123",
});
expect(result.id).toBe("MOD_123");
expect(refund.bodyOf(0).amount).toBeUndefined();
expect(refund.headersOf(0)["Idempotency-Key"]).toBeUndefined();
});
});
describe("getIntent", () => {
it("fetches a payment", { timeout: 5000 }, async () => {
const result = await h.provider.getIntent("PSP_123");
expect(result.id).toBe("PSP_123");
expect(h.calls[0]?.init?.method).toBe("GET");
});
});
describe("status mapping", () => {
const cases: ReadonlyArray<readonly [string, string, string]> = [
[
"Authorised",
"authorized",
"maps result Authorised to authorized",
],
["Refused", "failed", "maps result Refused to failed"],
["Cancelled", "canceled", "maps result Cancelled to canceled"],
["Error", "failed", "maps result Error to failed"],
["Pending", "pending", "maps result Pending to pending"],
["Received", "pending", "maps result Received to pending"],
[
"RedirectShopper",
"pending",
"maps result RedirectShopper to pending",
],
[
"IdentifyShopper",
"pending",
"maps result IdentifyShopper to pending",
],
[
"ChallengeShopper",
"pending",
"maps result ChallengeShopper to pending",
],
[
"PresentToShopper",
"pending",
"maps result PresentToShopper to pending",
],
["UnknownCode", "failed", "maps unknown result to failed"],
];
for (const [adyenCode, expected, name] of cases) {
it(name, { timeout: 5000 }, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
resultCode: adyenCode,
});
const result = await env.provider.getIntent("PSP_test");
expect(result.status).toBe(expected);
});
}
});
it("maps payment without paymentMethod", { timeout: 5000 }, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
paymentMethod: undefined,
});
const result = await env.provider.getIntent("PSP_123");
expect(result.paymentMethod).toBeUndefined();
});
it("maps non-scheme payment type as wallet", {
timeout: 5000,
}, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
paymentMethod: {
type: "applepay",
brand: "applepay",
lastFour: "0000",
},
});
const result = await env.provider.getIntent("PSP_123");
expect(result.paymentMethod?.type).toBe("wallet");
});
describe("default randomId", () => {
it("falls back to globalThis.crypto.randomUUID when randomId not supplied", {
timeout: 5000,
}, async () => {
let observed: Record<string, unknown> | undefined;
const provider = new AdyenProvider({
apiKey: "k",
merchantAccount: "M",
baseUrl: "https://adyen.mock/v71",
fetch: async (_url, init) => {
observed = JSON.parse(String(init?.body)) as Record<
string,
unknown
>;
return new Response(JSON.stringify(ADYEN_PAYMENT), {
status: 200,
});
},
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
expect(typeof observed?.reference).toBe("string");
expect((observed?.reference as string).length).toBeGreaterThan(0);
});
it("throws PaymentProviderError when crypto.randomUUID is unavailable", {
timeout: 5000,
}, async () => {
const cryptoApi = (
globalThis as { crypto?: { randomUUID?: () => string } }
).crypto;
const provider = new AdyenProvider({
apiKey: "k",
merchantAccount: "M",
baseUrl: "https://adyen.mock/v71",
fetch: makeFetch(ADYEN_PAYMENT),
});
const expectThrow = async (): Promise<void> => {
await expect(
provider.createIntent({
amount: { amount: 100n, currency: "USD" },
})
).rejects.toThrow(/AdyenConfig\.randomId is required/);
};
if (cryptoApi?.randomUUID === undefined) {
await expectThrow();
return;
}
const original = cryptoApi.randomUUID;
const descriptor = Object.getOwnPropertyDescriptor(
cryptoApi,
"randomUUID"
);
Object.defineProperty(cryptoApi, "randomUUID", {
configurable: true,
writable: true,
value: undefined,
});
try {
await expectThrow();
} finally {
if (descriptor !== undefined) {
Object.defineProperty(cryptoApi, "randomUUID", descriptor);
} else {
Object.defineProperty(cryptoApi, "randomUUID", {
configurable: true,
writable: true,
value: original,
});
}
}
});
});
describe("config defaults", () => {
it("falls back to default Adyen test baseUrl when not provided", {
timeout: 5000,
}, async () => {
let observedUrl = "";
const provider = new AdyenProvider({
apiKey: "k",
merchantAccount: "M",
fetch: async (url) => {
observedUrl = url;
return new Response(JSON.stringify(ADYEN_PAYMENT), {
status: 200,
});
},
randomId: () => "ref_default",
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
expect(observedUrl).toBe(
"https://checkout-test.adyen.com/v71/payments"
);
});
});
describe("payment-method edge cases", () => {
it("maps payment-method with lastFour but no brand", {
timeout: 5000,
}, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
paymentMethod: { type: "applepay", lastFour: "9999" },
});
const result = await env.provider.getIntent("PSP_x");
expect(result.paymentMethod?.type).toBe("wallet");
expect(result.paymentMethod?.last4).toBe("9999");
expect(result.paymentMethod?.brand).toBeUndefined();
});
it("maps payment-method with brand but no lastFour", {
timeout: 5000,
}, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
paymentMethod: { type: "scheme", brand: "AMEX" },
});
const result = await env.provider.getIntent("PSP_x");
expect(result.paymentMethod?.brand).toBe("amex");
expect(result.paymentMethod?.last4).toBeUndefined();
});
it("maps payment-method with neither brand nor lastFour", {
timeout: 5000,
}, async () => {
const env = setupRecording({
...ADYEN_PAYMENT,
paymentMethod: { type: "scheme" },
});
const result = await env.provider.getIntent("PSP_x");
expect(result.paymentMethod?.brand).toBeUndefined();
expect(result.paymentMethod?.last4).toBeUndefined();
});
it("maps payment without merchantReference but with paymentMethod", {
timeout: 5000,
}, async () => {
const env = setupRecording({
pspReference: "PSP_x",
resultCode: "Authorised",
amount: { value: 100, currency: "USD" },
paymentMethod: {
type: "scheme",
brand: "visa",
lastFour: "4242",
},
});
const result = await env.provider.getIntent("PSP_x");
expect(result.description).toBeUndefined();
expect(result.paymentMethod?.brand).toBe("visa");
});
it("maps payment with neither merchantReference nor paymentMethod", {
timeout: 5000,
}, async () => {
const env = setupRecording({
pspReference: "PSP_y",
resultCode: "Authorised",
amount: { value: 200, currency: "EUR" },
});
const result = await env.provider.getIntent("PSP_y");
expect(result.description).toBeUndefined();
expect(result.paymentMethod).toBeUndefined();
});
});
describe("refund status mapping", () => {
it("maps refund received to pending", { timeout: 5000 }, async () => {
const env = setupRecording({
...ADYEN_MODIFICATION,
status: "received",
});
const result = await env.provider.refund({
paymentIntentId: "PSP_123",
});
expect(result.status).toBe("pending");
});
it("maps other refund statuses to failed", {
timeout: 5000,
}, async () => {
const env = setupRecording({
...ADYEN_MODIFICATION,
status: "error",
});
const result = await env.provider.refund({
paymentIntentId: "PSP_123",
});
expect(result.status).toBe("failed");
});
});
});

View File

@@ -0,0 +1,222 @@
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import {
httpRequest,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
type RandomIdFn,
} from "@suaveplan/payment-providers";
import type { AdyenConfig } from "../types/adyen.js";
import {
type AdyenModification,
type AdyenPayment,
mapPayment,
mapRefundStatus,
} from "./mapping.js";
const DEFAULT_BASE_URL = "https://checkout-test.adyen.com/v71";
const DEFAULT_TIMEOUT_MS = 30_000;
const PROVIDER_NAME = "adyen";
function defaultRandomId(): string {
const cryptoApi = (globalThis as { crypto?: { randomUUID?: () => string } })
.crypto;
if (cryptoApi?.randomUUID !== undefined) {
return cryptoApi.randomUUID();
}
throw new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PARSE_ERROR,
"AdyenConfig.randomId is required when globalThis.crypto.randomUUID is unavailable",
{ provider: PROVIDER_NAME }
);
}
/**
* Adyen Checkout API v71 adapter implementing `PaymentProvider`.
* Authenticates via `X-API-Key`, injects the configured
* `merchantAccount` into every modification body, and generates
* default `reference` values via an injectable `RandomIdFn` so the
* package stays universal.
*/
export class AdyenProvider implements PaymentProvider {
public readonly name = PROVIDER_NAME;
private readonly apiKey: string;
private readonly merchantAccount: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly fetchImpl: AdyenConfig["fetch"];
private readonly signal: AbortSignal | undefined;
private readonly randomId: RandomIdFn;
public constructor(config: AdyenConfig) {
this.apiKey = config.apiKey;
this.merchantAccount = config.merchantAccount;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchImpl = config.fetch;
this.signal = config.signal;
this.randomId = config.randomId ?? defaultRandomId;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const captureMethod = input.captureMethod ?? "automatic";
const reference = input.idempotencyKey ?? this.randomId();
const body: Record<string, unknown> = {
amount: {
value: Number(input.amount.amount),
currency: input.amount.currency,
},
merchantAccount: this.merchantAccount,
reference,
};
if (captureMethod === "manual") {
body["captureDelayHours"] = 0;
}
if (input.description !== undefined) {
body["shopperStatement"] = input.description;
}
if (input.returnUrl !== undefined) {
body["returnUrl"] = input.returnUrl;
}
if (input.paymentMethodId !== undefined) {
body["paymentMethod"] = {
storedPaymentMethodId: input.paymentMethodId,
};
}
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
const { data } = await this.request<AdyenPayment>({
method: "POST",
url: `${this.baseUrl}/payments`,
headers: this.headers(input.idempotencyKey),
body,
});
return mapPayment(data, captureMethod, new Date().toISOString());
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.request<AdyenPayment>({
method: "POST",
url: `${this.baseUrl}/payments/${intentId}/details`,
headers: this.headers(),
body: { details: {} },
});
return mapPayment(data, "manual", new Date().toISOString());
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.request<AdyenModification>({
method: "POST",
url: `${this.baseUrl}/payments/${intentId}/captures`,
headers: this.headers(),
body: { merchantAccount: this.merchantAccount, amount: {} },
});
const nowIso = new Date().toISOString();
const currency = data.amount.currency;
const amount = BigInt(data.amount.value);
return {
id: intentId,
status: "captured",
amount: { amount, currency },
capturedAmount: { amount, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: "manual",
refunds: [],
createdAt: nowIso,
updatedAt: nowIso,
};
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.request<AdyenModification>({
method: "POST",
url: `${this.baseUrl}/payments/${intentId}/cancels`,
headers: this.headers(),
body: { merchantAccount: this.merchantAccount },
});
const nowIso = new Date().toISOString();
const currency = data.amount.currency;
return {
id: intentId,
status: "canceled",
amount: { amount: 0n, currency },
capturedAmount: { amount: 0n, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: "manual",
refunds: [],
createdAt: nowIso,
updatedAt: nowIso,
};
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {
merchantAccount: this.merchantAccount,
};
if (input.amount !== undefined) {
body["amount"] = {
value: Number(input.amount.amount),
currency: input.amount.currency,
};
}
const { data } = await this.request<AdyenModification>({
method: "POST",
url: `${this.baseUrl}/payments/${input.paymentIntentId}/refunds`,
headers: this.headers(input.idempotencyKey),
body,
});
return {
id: data.pspReference,
paymentIntentId: input.paymentIntentId,
amount: {
amount: BigInt(data.amount.value),
currency: data.amount.currency,
},
status: mapRefundStatus(data.status),
createdAt: new Date().toISOString(),
};
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.request<AdyenPayment>({
method: "GET",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
});
return mapPayment(data, "automatic", new Date().toISOString());
}
private headers(idempotencyKey?: string): Record<string, string> {
const h: Record<string, string> = { "X-API-Key": this.apiKey };
if (idempotencyKey !== undefined) {
h["Idempotency-Key"] = idempotencyKey;
}
return h;
}
private request<T>(opts: {
method: "GET" | "POST";
url: string;
headers: Record<string, string>;
body?: unknown;
}): Promise<{ status: number; data: T }> {
return httpRequest<T>({
method: opts.method,
url: opts.url,
headers: opts.headers,
body: opts.body,
fetch: this.fetchImpl,
signal: this.signal,
timeoutMs: this.timeoutMs,
provider: PROVIDER_NAME,
});
}
}

View File

@@ -0,0 +1,11 @@
export { AdyenProvider } from "./adyen.js";
export {
type AdyenAmount,
type AdyenModification,
type AdyenPayment,
type AdyenPaymentMethod,
mapPayment,
mapPaymentMethod,
mapRefundStatus,
mapStatus,
} from "./mapping.js";

View File

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

View File

@@ -0,0 +1,137 @@
import type {
PaymentIntent,
PaymentMethod,
PaymentStatus,
RefundStatus,
} from "@suaveplan/payment";
/** Adyen Checkout API common amount shape. */
export interface AdyenAmount {
readonly value: number;
readonly currency: string;
}
/** Adyen Checkout API payment-method excerpt used for mapping. */
export interface AdyenPaymentMethod {
readonly type: string;
readonly brand?: string;
readonly lastFour?: string;
}
/** Adyen `/payments` and `/payments/{id}` response excerpt. */
export interface AdyenPayment {
readonly pspReference: string;
readonly resultCode: string;
readonly amount: AdyenAmount;
readonly merchantReference?: string;
readonly paymentMethod?: AdyenPaymentMethod;
}
/** Adyen `/captures`, `/cancels`, `/refunds` response excerpt. */
export interface AdyenModification {
readonly pspReference: string;
readonly paymentPspReference: string;
readonly amount: AdyenAmount;
readonly status: string;
readonly merchantReference?: string;
}
const RESULT_STATUS: Readonly<Record<string, PaymentStatus>> = Object.freeze({
Authorised: "authorized",
Refused: "failed",
Cancelled: "canceled",
Error: "failed",
Pending: "pending",
Received: "pending",
RedirectShopper: "pending",
IdentifyShopper: "pending",
ChallengeShopper: "pending",
PresentToShopper: "pending",
});
/** Map an Adyen `resultCode` to the canonical `PaymentStatus`. */
export function mapStatus(resultCode: string): PaymentStatus {
return RESULT_STATUS[resultCode] ?? "failed";
}
/** Map an Adyen modification `status` to the canonical `RefundStatus`. */
export function mapRefundStatus(status: string): RefundStatus {
return status === "received" ? "pending" : "failed";
}
/** Map an Adyen `paymentMethod` excerpt to the canonical `PaymentMethod`. */
export function mapPaymentMethod(
pspReference: string,
method: AdyenPaymentMethod | undefined
): PaymentMethod | undefined {
if (method === undefined) {
return undefined;
}
const type = method.type === "scheme" ? "card" : "wallet";
const result: PaymentMethod = {
id: `adyen_${pspReference}`,
type,
};
const brand = method.brand?.toLowerCase();
if (brand !== undefined) {
return method.lastFour !== undefined
? {
...result,
brand: brand as NonNullable<PaymentMethod["brand"]>,
last4: method.lastFour,
}
: {
...result,
brand: brand as NonNullable<PaymentMethod["brand"]>,
};
}
if (method.lastFour !== undefined) {
return { ...result, last4: method.lastFour };
}
return result;
}
/**
* Lift an Adyen `/payments` response into the canonical
* `PaymentIntent` shape used by `@suaveplan/payment`.
*/
export function mapPayment(
p: AdyenPayment,
captureMethod: "automatic" | "manual",
nowIso: string
): PaymentIntent {
const currency = p.amount.currency;
const amount = BigInt(p.amount.value);
const status = mapStatus(p.resultCode);
// Adyen `/payments` and `/payments/{id}` never return a "captured"
// resultCode — capture is a separate `/captures` call modelled by
// `AdyenProvider.captureIntent`. The mapped intent therefore always
// starts with `capturedAmount = 0n`; `captureIntent` constructs its
// own response with the captured value rather than re-mapping.
const base: PaymentIntent = {
id: p.pspReference,
status,
amount: { amount, currency },
capturedAmount: { amount: 0n, currency },
refundedAmount: { amount: 0n, currency },
captureMethod,
refunds: [],
createdAt: nowIso,
updatedAt: nowIso,
};
const method = mapPaymentMethod(p.pspReference, p.paymentMethod);
if (p.merchantReference !== undefined && method !== undefined) {
return {
...base,
description: p.merchantReference,
paymentMethod: method,
};
}
if (p.merchantReference !== undefined) {
return { ...base, description: p.merchantReference };
}
if (method !== undefined) {
return { ...base, paymentMethod: method };
}
return base;
}

View File

@@ -0,0 +1,12 @@
export { AdyenProvider } from "./adyen/adyen.js";
export {
type AdyenAmount,
type AdyenModification,
type AdyenPayment,
type AdyenPaymentMethod,
mapPayment,
mapPaymentMethod,
mapRefundStatus,
mapStatus,
} from "./adyen/mapping.js";
export type { AdyenConfig } from "./types/adyen.js";

View File

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

View File

@@ -0,0 +1,23 @@
import type { MerchantKeyConfig } from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { AdyenConfig } from "./adyen.js";
describe("AdyenConfig", () => {
it("accepts a MerchantKeyConfig value as AdyenConfig", {
timeout: 5000,
}, () => {
const merchantConfig: MerchantKeyConfig = {
apiKey: "AQE_test",
merchantAccount: "TestMerchant",
baseUrl: "https://adyen.mock/v71",
timeoutMs: 5_000,
randomId: () => "abc",
};
const adyenConfig: AdyenConfig = merchantConfig;
expect(adyenConfig.apiKey).toBe("AQE_test");
expect(adyenConfig.merchantAccount).toBe("TestMerchant");
expect(adyenConfig.baseUrl).toBe("https://adyen.mock/v71");
expect(adyenConfig.timeoutMs).toBe(5_000);
expect(adyenConfig.randomId?.()).toBe("abc");
});
});

View File

@@ -0,0 +1,35 @@
import type { MerchantKeyConfig } from "@suaveplan/payment-providers";
/**
* Constructor configuration for {@link AdyenProvider}. Extends the
* slim-core {@link MerchantKeyConfig} verbatim — both names are
* structurally identical and may be used interchangeably. The local
* alias exists so consumers importing only from
* `@suaveplan/payment-provider-adyen` do not need a second import
* from the foundation package, and so future Adyen-specific fields
* (e.g. live-vs-test endpoint hints, region pinning) can be added here
* without disturbing the foundation contract.
*
* Fields:
*
* - `apiKey` — Adyen Checkout API key (`AQE...`); sent as `X-API-Key` on
* every request.
* - `merchantAccount` — Adyen merchant-account code; injected into the
* request body for `/payments`, `/payments/{id}/captures`,
* `/payments/{id}/cancels`, and `/payments/{id}/refunds`.
* - `baseUrl` — overrides the default test endpoint
* `https://checkout-test.adyen.com/v71`.
* - `webhookHmacKey` — reserved for webhook signature verification;
* plumbed through but not consumed by this package (verification
* logic lives in `@suaveplan/payment`'s `WebhookVerifier`).
* - `randomId` — injectable RNG used to generate the default
* `reference` field when the caller does not supply an
* `idempotencyKey`. Defaults to `globalThis.crypto?.randomUUID()` at
* call time when available; if neither is supplied the provider
* throws on the first auto-generation attempt.
* - `fetch` — injectable `FetchLike` for tests.
* - `timeoutMs` — per-call timeout; defaults to `30_000`.
* - `signal` — external `AbortSignal` composed with the internal
* timeout signal by the slim-core `httpRequest` helper.
*/
export type AdyenConfig = MerchantKeyConfig;

View File

@@ -0,0 +1 @@
export type { AdyenConfig } from "./adyen.js";

View File

@@ -0,0 +1,56 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const SRC_ROOT = join(PKG_ROOT, "src");
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
timeout: 5000,
}, () => {
const banned =
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
if (banned.test(text)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
it("package.json exposes exactly the feature subpaths", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const featurePaths = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "." && k !== "./package.json"
);
expect(featurePaths.sort()).toEqual(
["./adyen/adyen", "./types/adyen"].sort()
);
});
});

View File

@@ -0,0 +1,76 @@
import type { FetchLike } from "@suaveplan/payment-providers";
import { AdyenProvider } from "../src/adyen/adyen.js";
import type { AdyenConfig } from "../src/types/adyen.js";
/** Single recorded call on the test `FetchLike`. */
export interface RecordedCall {
readonly url: string;
readonly init?: RequestInit;
}
/** Test harness bundling a provider with helpers for inspecting requests. */
export interface Harness {
readonly provider: AdyenProvider;
readonly calls: RecordedCall[];
readonly bodyOf: (index: number) => Record<string, unknown>;
readonly headersOf: (index: number) => Record<string, string>;
}
/** Convenience: build a `FetchLike` that responds with the given JSON payload. */
export function makeFetch(payload: unknown, status = 200): FetchLike {
return async () =>
new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
function recording(payload: unknown): {
calls: RecordedCall[];
fetch: FetchLike;
} {
const calls: RecordedCall[] = [];
const fetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
return { calls, fetch };
}
/**
* Build a harness around an `AdyenProvider` whose `fetch` records every
* request and replies with the supplied JSON payload. Optional `extra`
* config overrides merge over the defaults (`apiKey`, `merchantAccount`,
* `baseUrl`).
*/
export function setupRecording(
payload: unknown,
extra: Partial<AdyenConfig> = {}
): Harness {
const { calls, fetch } = recording(payload);
const provider = new AdyenProvider({
apiKey: "adyen_key_123",
merchantAccount: "TestMerchant",
baseUrl: "https://adyen.mock/v71",
fetch,
...extra,
});
const bodyOf = (index: number): Record<string, unknown> => {
const raw = calls[index]?.init?.body;
if (typeof raw !== "string") {
throw new Error("expected string body");
}
return JSON.parse(raw) as Record<string, unknown>;
};
const headersOf = (index: number): Record<string, string> => {
const raw = calls[index]?.init?.headers;
if (raw === undefined) {
throw new Error("expected headers");
}
return raw as Record<string, string>;
};
return { provider, calls, bodyOf, headersOf };
}

View File

@@ -0,0 +1,6 @@
// Polyfills for the payment-provider-adyen test environment — none required.
// Every test injects a recording `FetchLike` closure via the provider's
// constructor `fetch` option, so the suite never depends on a platform
// `fetch` global. This file exists to mirror the sibling-package layout
// and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-adyen",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,67 @@
{
"name": "@suaveplan/payment-provider-mollie",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./mollie/mollie": {
"import": {
"types": "./dist/mollie/mollie.d.ts",
"default": "./dist/mollie/mollie.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,9 @@
export {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie/mollie.js";

View File

@@ -0,0 +1,9 @@
export {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie.js";

View File

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

View File

@@ -0,0 +1,538 @@
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie.js";
interface Recorded {
readonly url: string;
readonly init: RequestInit | undefined;
}
interface RecorderShape {
readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
readonly calls: Recorded[];
}
function recorder(
handler: (url: string, init: RequestInit | undefined) => Response
): RecorderShape {
const calls: Recorded[] = [];
const fetchImpl = async (
url: string,
init?: RequestInit
): Promise<Response> => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const MOLLIE_PAYMENT = {
id: "tr_123",
status: "paid",
amount: { value: "50.00", currency: "EUR" },
description: "Test payment",
metadata: { orderId: "42" },
method: "creditcard",
details: { cardNumber: "************4242", cardLabel: "Visa" },
createdAt: "2026-01-01T00:00:00+00:00",
};
const MOLLIE_REFUND = {
id: "re_123",
paymentId: "tr_123",
amount: { value: "20.00", currency: "EUR" },
status: "refunded",
createdAt: "2026-01-02T00:00:00+00:00",
};
function makeProvider(rec: RecorderShape): MollieProvider {
return new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: rec.fetch,
});
}
function headersFromInit(
init: RequestInit | undefined
): Record<string, string> {
return (init?.headers as Record<string, string>) ?? {};
}
function bodyFromInit(init: RequestInit | undefined): Record<string, unknown> {
const body = init?.body;
if (typeof body !== "string") {
return {};
}
return JSON.parse(body) as Record<string, unknown>;
}
describe("MollieProvider — identity", () => {
it("exposes the literal provider name", { timeout: 5000 }, () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
expect(provider.name).toBe("mollie");
expect(MOLLIE_PROVIDER_NAME).toBe("mollie");
});
it("exposes the default Mollie base URL constant", {
timeout: 5000,
}, () => {
expect(MOLLIE_DEFAULT_BASE_URL).toBe("https://api.mollie.com/v2");
});
it("falls back to the default base URL when none provided", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = new MollieProvider({
apiKey: "test_xyz",
fetch: rec.fetch,
});
await provider.getIntent("tr_123");
expect(rec.calls[0]?.url).toBe(
"https://api.mollie.com/v2/payments/tr_123"
);
});
it("sends Authorization: Bearer header on every request", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
await provider.getIntent("tr_123");
await provider.cancelIntent("tr_123");
for (const call of rec.calls) {
expect(headersFromInit(call.init).Authorization).toBe(
"Bearer test_xyz"
);
}
expect(rec.calls).toHaveLength(2);
});
});
describe("MollieProvider — createIntent", () => {
it("marshals amount to two-decimal string on createIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
const result = await provider.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Test",
metadata: { orderId: "42" },
returnUrl: "https://example.com/return",
});
const body = bodyFromInit(rec.calls[0]?.init);
const amount = body.amount as { currency: string; value: string };
expect(amount.value).toBe("50.00");
expect(amount.currency).toBe("EUR");
expect(body.description).toBe("Test");
expect(body.redirectUrl).toBe("https://example.com/return");
expect(result.id).toBe("tr_123");
expect(result.status).toBe("created");
});
it("applies default description and redirectUrl when omitted", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
});
const body = bodyFromInit(rec.calls[0]?.init);
expect(body.description).toBe("Payment");
expect(body.redirectUrl).toBe("https://localhost/return");
expect(body.metadata).toBeUndefined();
});
it("forwards metadata on createIntent", { timeout: 5000 }, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
metadata: { orderId: "99" },
});
const body = bodyFromInit(rec.calls[0]?.init);
expect(body.metadata).toEqual({ orderId: "99" });
});
});
describe("MollieProvider — read-throughs", () => {
it("confirmIntent reads through to getIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.confirmIntent("tr_123");
expect(result.id).toBe("tr_123");
expect(rec.calls[0]?.init?.method).toBe("GET");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123"
);
});
it("captureIntent reads through to getIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.captureIntent("tr_123");
expect(result.status).toBe("captured");
expect(result.capturedAmount.amount).toBe(5000n);
});
});
describe("MollieProvider — cancelIntent", () => {
it("cancelIntent issues DELETE and maps canceled status", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "canceled" })
);
const provider = makeProvider(rec);
const result = await provider.cancelIntent("tr_123");
expect(rec.calls[0]?.init?.method).toBe("DELETE");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123"
);
expect(result.status).toBe("canceled");
});
});
describe("MollieProvider — refund", () => {
it("refund posts with explicit amount and round-trips through parseMollieAmount", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_REFUND));
const provider = makeProvider(rec);
const result = await provider.refund({
paymentIntentId: "tr_123",
amount: { amount: 2000n, currency: "EUR" },
});
const body = bodyFromInit(rec.calls[0]?.init);
const amount = body.amount as { currency: string; value: string };
expect(amount.value).toBe("20.00");
expect(amount.currency).toBe("EUR");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123/refunds"
);
expect(result.id).toBe("re_123");
expect(result.status).toBe("succeeded");
expect(result.amount.amount).toBe(2000n);
});
it("refund without explicit amount posts empty body", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_REFUND));
const provider = makeProvider(rec);
const result = await provider.refund({ paymentIntentId: "tr_123" });
const body = bodyFromInit(rec.calls[0]?.init);
expect(body).toEqual({});
expect(result.id).toBe("re_123");
});
});
describe("MollieProvider — getIntent + payment-method mapping", () => {
it("getIntent maps a creditcard payment to card with brand and last4", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.id).toBe("tr_123");
expect(result.paymentMethod?.type).toBe("card");
expect(result.paymentMethod?.brand).toBe("visa");
expect(result.paymentMethod?.last4).toBe("4242");
});
it("getIntent normalises an unknown card label to 'unknown' brand", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
details: {
cardNumber: "************0000",
cardLabel: "Bancomat",
},
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod?.brand).toBe("unknown");
});
it("getIntent maps a non-card method to wallet", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
method: "ideal",
details: undefined,
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod?.type).toBe("wallet");
expect(result.paymentMethod?.last4).toBeUndefined();
expect(result.paymentMethod?.brand).toBeUndefined();
});
it("getIntent yields undefined paymentMethod when method is missing", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, method: undefined })
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod).toBeUndefined();
});
it("getIntent omits description and metadata when Mollie omits them", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
description: undefined,
metadata: undefined,
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.description).toBeUndefined();
expect(result.metadata).toBeUndefined();
});
it("getIntent reports zero captured for non-paid statuses", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.capturedAmount.amount).toBe(0n);
});
});
describe("mapMollieStatus", () => {
it("mapMollieStatus covers every defined Mollie status", {
timeout: 5000,
}, () => {
expect(mapMollieStatus("open")).toBe("created");
expect(mapMollieStatus("pending")).toBe("pending");
expect(mapMollieStatus("authorized")).toBe("authorized");
expect(mapMollieStatus("paid")).toBe("captured");
expect(mapMollieStatus("canceled")).toBe("canceled");
expect(mapMollieStatus("expired")).toBe("canceled");
expect(mapMollieStatus("failed")).toBe("failed");
});
it("mapMollieStatus falls back to failed on unknown status", {
timeout: 5000,
}, () => {
expect(mapMollieStatus("unknown")).toBe("failed");
expect(mapMollieStatus("")).toBe("failed");
});
});
describe("mapMollieRefundStatus", () => {
it("mapMollieRefundStatus covers every defined refund status", {
timeout: 5000,
}, () => {
expect(mapMollieRefundStatus("refunded")).toBe("succeeded");
expect(mapMollieRefundStatus("pending")).toBe("pending");
expect(mapMollieRefundStatus("queued")).toBe("pending");
expect(mapMollieRefundStatus("processing")).toBe("pending");
expect(mapMollieRefundStatus("failed")).toBe("failed");
});
it("mapMollieRefundStatus falls back to failed on unknown status", {
timeout: 5000,
}, () => {
expect(mapMollieRefundStatus("weird")).toBe("failed");
});
});
describe("formatMollieAmount", () => {
it("formatMollieAmount produces 0.00 for zero", { timeout: 5000 }, () => {
expect(formatMollieAmount(0n)).toBe("0.00");
});
it("formatMollieAmount pads single cent to 0.01", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(1n)).toBe("0.01");
expect(formatMollieAmount(9n)).toBe("0.09");
});
it("formatMollieAmount produces two trailing zeros for exact major unit", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(100n)).toBe("1.00");
expect(formatMollieAmount(1000n)).toBe("10.00");
});
it("formatMollieAmount handles large bigint values without loss", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(12345678901234n)).toBe("123456789012.34");
expect(parseMollieAmount(formatMollieAmount(12345678901234n))).toBe(
12345678901234n
);
});
it("formatMollieAmount formats negative amounts with leading minus", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(-50n)).toBe("-0.50");
expect(formatMollieAmount(-12345n)).toBe("-123.45");
});
});
describe("parseMollieAmount", () => {
it("parseMollieAmount accepts no-decimal, single-decimal, and two-decimal strings", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("10")).toBe(1000n);
expect(parseMollieAmount("10.5")).toBe(1050n);
expect(parseMollieAmount("10.50")).toBe(1050n);
expect(parseMollieAmount("0.01")).toBe(1n);
expect(parseMollieAmount("0.00")).toBe(0n);
});
it("parseMollieAmount truncates over-precision without rounding", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("10.123")).toBe(1012n);
expect(parseMollieAmount("10.999")).toBe(1099n);
});
it("parseMollieAmount parses negative wire values", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("-10.50")).toBe(-1050n);
});
it("formatMollieAmount and parseMollieAmount round-trip for every minted bigint", {
timeout: 5000,
}, () => {
const samples: bigint[] = [
0n,
1n,
9n,
10n,
99n,
100n,
999n,
1_000n,
123_456n,
999_999_999n,
12_345_678_901_234n,
];
for (const n of samples) {
expect(parseMollieAmount(formatMollieAmount(n))).toBe(n);
}
});
});
describe("MollieProvider — error surfaces", () => {
it("surfaces a PaymentProviderError when fetch rejects", {
timeout: 5000,
}, async () => {
const failingFetch = async (): Promise<Response> => {
throw new TypeError("network down");
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: failingFetch,
});
await expect(provider.getIntent("tr_123")).rejects.toBeInstanceOf(
PaymentProviderError
);
try {
await provider.getIntent("tr_123");
} catch (err) {
const e = err as PaymentProviderError;
expect(e.code).toBe(PAYMENT_PROVIDER_CODES.HTTP_ERROR);
expect(e.provider).toBe("mollie");
}
});
it("surfaces HTTP_ERROR when Mollie responds with 422", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ message: "validation failed" }, 422)
);
const provider = makeProvider(rec);
try {
await provider.createIntent({
amount: { amount: 5000n, currency: "EUR" },
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(PaymentProviderError);
const e = err as PaymentProviderError;
expect(e.code).toBe(PAYMENT_PROVIDER_CODES.HTTP_ERROR);
expect(e.httpStatus).toBe(422);
expect(e.provider).toBe("mollie");
}
});
});
describe("MollieProvider — signal forwarding", () => {
it("propagates the constructor AbortSignal into the FetchLike init", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const seen: (AbortSignal | undefined)[] = [];
const observingFetch = async (
_url: string,
init?: RequestInit
): Promise<Response> => {
seen.push(init?.signal ?? undefined);
return jsonResponse(MOLLIE_PAYMENT);
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: observingFetch,
signal: controller.signal,
});
await provider.getIntent("tr_123");
expect(seen[0]).toBeDefined();
expect(seen[0]?.aborted).toBe(false);
});
});

View File

@@ -0,0 +1,332 @@
import type {
CardBrand,
CreatePaymentIntentInput,
PaymentIntent,
PaymentMethod,
PaymentProvider,
PaymentStatus,
Refund,
RefundInput,
RefundStatus,
} from "@suaveplan/payment";
import {
type ApiKeyConfig,
type FetchLike,
httpRequest,
} from "@suaveplan/payment-providers";
/**
* Default Mollie Payments REST v2 base URL. Override via
* `ApiKeyConfig.baseUrl` for testing or for routing through a
* regional gateway. The value is the canonical production endpoint
* Mollie publishes in their v2 docs.
*/
export const MOLLIE_DEFAULT_BASE_URL = "https://api.mollie.com/v2";
/** Stable provider identifier surfaced through `PaymentProvider.name`. */
export const MOLLIE_PROVIDER_NAME = "mollie";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_DESCRIPTION = "Payment";
const DEFAULT_REDIRECT_URL = "https://localhost/return";
const MINOR_UNIT_DIGITS = 2;
const HUNDRED = 100n;
interface MollieAmountWire {
readonly currency: string;
readonly value: string;
}
interface MolliePayment {
readonly id: string;
readonly status: string;
readonly amount: MollieAmountWire;
readonly description?: string;
readonly metadata?: Record<string, string>;
readonly method?: string;
readonly details?: {
readonly cardNumber?: string;
readonly cardLabel?: string;
};
readonly createdAt: string;
}
interface MollieRefund {
readonly id: string;
readonly paymentId: string;
readonly amount: MollieAmountWire;
readonly status: string;
readonly description?: string;
readonly createdAt: string;
}
/**
* Format a minor-units `bigint` into a Mollie-compatible two-decimal
* string. The output is always exactly two decimal places, padded
* left for sub-major values (`1n` → `"0.01"`) and trailing zeros
* preserved for whole-major values (`100n` → `"1.00"`).
*/
export function formatMollieAmount(amount: bigint): string {
const sign = amount < 0n ? "-" : "";
const abs = amount < 0n ? -amount : amount;
const major = abs / HUNDRED;
const minor = abs % HUNDRED;
return `${sign}${major.toString()}.${minor.toString().padStart(MINOR_UNIT_DIGITS, "0")}`;
}
/**
* Parse a Mollie wire-format decimal string into a minor-units `bigint`.
* Accepts strings with no decimal (`"10"` → `1000n`), single-decimal
* (`"10.5"` → `1050n`), exact two-decimal (`"10.50"` → `1050n`), or
* over-precision (`"10.123"` → `1012n` — excess minor digits are
* truncated, never rounded).
*/
export function parseMollieAmount(value: string): bigint {
const trimmed = value.trim();
const negative = trimmed.startsWith("-");
const body = negative ? trimmed.slice(1) : trimmed;
const dot = body.indexOf(".");
const majorRaw = dot === -1 ? body : body.slice(0, dot);
const minorRaw = dot === -1 ? "" : body.slice(dot + 1);
const minorPadded = minorRaw
.padEnd(MINOR_UNIT_DIGITS, "0")
.slice(0, MINOR_UNIT_DIGITS);
const majorBig = BigInt(majorRaw);
const minorBig = BigInt(minorPadded);
const total = majorBig * HUNDRED + minorBig;
return negative ? -total : total;
}
/**
* Map a Mollie payment status to the canonical `PaymentStatus` union
* from `@suaveplan/payment`. Unknown / future statuses fall back to
* `"failed"` — a forward-defensive choice that surfaces unrecognised
* states as failures rather than silently treating them as success.
*/
export function mapMollieStatus(status: string): PaymentStatus {
switch (status) {
case "open":
return "created";
case "pending":
return "pending";
case "authorized":
return "authorized";
case "paid":
return "captured";
case "canceled":
case "expired":
return "canceled";
default:
return "failed";
}
}
/**
* Map a Mollie refund status to the canonical `RefundStatus` union.
* Mollie publishes `refunded`, `pending`, `queued`, `processing`, and
* `failed`; unknown values fall back to `"failed"`.
*/
export function mapMollieRefundStatus(status: string): RefundStatus {
switch (status) {
case "refunded":
return "succeeded";
case "pending":
case "queued":
case "processing":
return "pending";
default:
return "failed";
}
}
const KNOWN_CARD_BRANDS: readonly CardBrand[] = [
"visa",
"mastercard",
"amex",
"discover",
"diners",
"jcb",
"unionpay",
"unknown",
];
function normaliseCardBrand(label: string | undefined): CardBrand | undefined {
if (label === undefined) {
return undefined;
}
const lower = label.toLowerCase();
return (KNOWN_CARD_BRANDS as readonly string[]).includes(lower)
? (lower as CardBrand)
: "unknown";
}
function buildPaymentMethod(payment: MolliePayment): PaymentMethod | undefined {
if (payment.method === undefined) {
return undefined;
}
const type = payment.method === "creditcard" ? "card" : "wallet";
const last4 = payment.details?.cardNumber?.slice(-4);
const brand = normaliseCardBrand(payment.details?.cardLabel);
const method: PaymentMethod = {
id: `mollie_${payment.id}`,
type,
...(brand !== undefined ? { brand } : {}),
...(last4 !== undefined ? { last4 } : {}),
};
return method;
}
function mapPayment(payment: MolliePayment): PaymentIntent {
const currency = payment.amount.currency;
const total = parseMollieAmount(payment.amount.value);
const captured = payment.status === "paid" ? total : 0n;
const paymentMethod = buildPaymentMethod(payment);
const intent: PaymentIntent = {
id: payment.id,
status: mapMollieStatus(payment.status),
amount: { amount: total, currency },
capturedAmount: { amount: captured, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: "automatic",
...(payment.description !== undefined
? { description: payment.description }
: {}),
...(paymentMethod !== undefined ? { paymentMethod } : {}),
refunds: [],
...(payment.metadata !== undefined
? { metadata: payment.metadata }
: {}),
createdAt: payment.createdAt,
updatedAt: payment.createdAt,
};
return intent;
}
function mapRefund(refund: MollieRefund): Refund {
return {
id: refund.id,
paymentIntentId: refund.paymentId,
amount: {
amount: parseMollieAmount(refund.amount.value),
currency: refund.amount.currency,
},
status: mapMollieRefundStatus(refund.status),
createdAt: refund.createdAt,
};
}
/**
* Mollie Payments API v2 adapter. Implements the `PaymentProvider`
* contract from `@suaveplan/payment` on top of the slim
* `@suaveplan/payment-providers` core. Marshals genesis Money
* (`bigint` minor-units) to / from Mollie's `{ currency, value: "X.YY" }`
* wire shape, sets `Authorization: Bearer ${apiKey}` on every call,
* and raises only `PaymentProviderError`.
*/
export class MollieProvider implements PaymentProvider {
public readonly name = MOLLIE_PROVIDER_NAME;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly fetchImpl: FetchLike | undefined;
private readonly signal: AbortSignal | undefined;
public constructor(config: ApiKeyConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl ?? MOLLIE_DEFAULT_BASE_URL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchImpl = config.fetch;
this.signal = config.signal;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const body: Record<string, unknown> = {
amount: {
currency: input.amount.currency,
value: formatMollieAmount(input.amount.amount),
},
description: input.description ?? DEFAULT_DESCRIPTION,
redirectUrl: input.returnUrl ?? DEFAULT_REDIRECT_URL,
};
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
const { data } = await httpRequest<MolliePayment>({
method: "POST",
url: `${this.baseUrl}/payments`,
headers: this.headers(),
body,
...this.commonOptions(),
});
return mapPayment(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
return this.getIntent(intentId);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
return this.getIntent(intentId);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<MolliePayment>({
method: "DELETE",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapPayment(data);
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {};
if (input.amount !== undefined) {
body["amount"] = {
currency: input.amount.currency,
value: formatMollieAmount(input.amount.amount),
};
}
const { data } = await httpRequest<MollieRefund>({
method: "POST",
url: `${this.baseUrl}/payments/${input.paymentIntentId}/refunds`,
headers: this.headers(),
body,
...this.commonOptions(),
});
return mapRefund(data);
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<MolliePayment>({
method: "GET",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapPayment(data);
}
private headers(): Record<string, string> {
return { Authorization: `Bearer ${this.apiKey}` };
}
private commonOptions(): {
readonly timeoutMs: number;
readonly provider: string;
readonly fetch: FetchLike | undefined;
readonly signal: AbortSignal | undefined;
} {
return {
timeoutMs: this.timeoutMs,
provider: MOLLIE_PROVIDER_NAME,
fetch: this.fetchImpl,
signal: this.signal,
};
}
}

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `Response`, `BigInt`, `Number.parseInt`, the WHATWG `fetch`
// surface, and an injected `FetchLike` closure — all available in every
// supported runtime (Node 18+, Bun, browsers, Web Workers). This file exists
// so vitest has a stable setup file path mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-mollie",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,62 @@
{
"name": "@suaveplan/payment-provider-paypal",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./paypal/paypal": {
"import": { "types": "./dist/paypal/paypal.d.ts", "default": "./dist/paypal/paypal.js" },
"require": { "types": "./dist/paypal/paypal.d.ts", "default": "./dist/paypal/paypal.js" }
},
"./paypal/mapping": {
"import": { "types": "./dist/paypal/mapping.d.ts", "default": "./dist/paypal/mapping.js" },
"require": { "types": "./dist/paypal/mapping.d.ts", "default": "./dist/paypal/mapping.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,20 @@
export {
formatAmount,
mapOrder,
mapRefundStatus,
mapStatus,
parseAmount,
} from "./paypal/mapping.js";
export { PayPalProvider } from "./paypal/paypal.js";
export type {
CachedToken,
PayPalAmount,
PayPalCapture,
PayPalConfig,
PayPalEmbeddedRefund,
PayPalOrder,
PayPalPayments,
PayPalPurchaseUnit,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal/paypal.types.js";

View File

@@ -0,0 +1,20 @@
export {
formatAmount,
mapOrder,
mapRefundStatus,
mapStatus,
parseAmount,
} from "./mapping.js";
export { PayPalProvider } from "./paypal.js";
export type {
CachedToken,
PayPalAmount,
PayPalCapture,
PayPalConfig,
PayPalEmbeddedRefund,
PayPalOrder,
PayPalPayments,
PayPalPurchaseUnit,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal.types.js";

View File

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

View File

@@ -0,0 +1,93 @@
import type {
PaymentIntent,
PaymentStatus,
RefundStatus,
} from "@suaveplan/payment";
import type { PayPalOrder } from "./paypal.types.js";
/** Maps a PayPal Orders v2 status to the genesis `PaymentStatus` union. */
export function mapStatus(ppStatus: string): PaymentStatus {
switch (ppStatus) {
case "CREATED":
case "SAVED":
return "created";
case "APPROVED":
return "authorized";
case "VOIDED":
return "canceled";
case "COMPLETED":
return "captured";
case "PAYER_ACTION_REQUIRED":
return "pending";
default:
return "failed";
}
}
/** Maps a PayPal refund status to the genesis `RefundStatus` union. */
export function mapRefundStatus(status: string): RefundStatus {
if (status === "COMPLETED") {
return "succeeded";
}
if (status === "PENDING") {
return "pending";
}
return "failed";
}
/**
* Parses a PayPal major-unit decimal-string amount into a `bigint` count
* of minor units. PayPal omits the decimal separator for zero-decimal
* currencies (JPY, KRW, …), in which case `minor` is `undefined` and we
* fall back to "00".
*/
export function parseAmount(value: string): bigint {
// `String.prototype.split` always returns a non-empty array — even for
// the empty string it returns `[""]` — so destructuring `[major, minor]`
// is total.
const [major, minor] = value.split(".");
const minorPadded = (minor ?? "00").padEnd(2, "0").slice(0, 2);
return BigInt(major as string) * 100n + BigInt(minorPadded);
}
/**
* Formats a non-negative `bigint` count of minor units into a PayPal
* major-unit decimal string with two fractional digits. Genesis
* `MoneyLike.amount` values for payment intents and refunds are
* non-negative by contract.
*/
export function formatAmount(minorUnits: bigint): string {
const major = minorUnits / 100n;
const minor = minorUnits % 100n;
const minorStr = minor.toString().padStart(2, "0");
return `${major.toString()}.${minorStr}`;
}
/**
* Maps a PayPal Orders v2 order to the genesis `PaymentIntent` shape.
* Sums every `purchase_units[].payments.captures[].amount.value` into
* `capturedAmount`, defaults the currency to USD when no purchase unit is
* present, and derives `captureMethod` from the order `intent`
* (`AUTHORIZE` → manual, anything else → automatic).
*/
export function mapOrder(order: PayPalOrder): PaymentIntent {
const unit = order.purchase_units[0];
const currency = unit?.amount.currency_code ?? "USD";
const totalAmount = unit ? parseAmount(unit.amount.value) : 0n;
const captures = unit?.payments?.captures ?? [];
const capturedAmount = captures.reduce(
(sum, capture) => sum + parseAmount(capture.amount.value),
0n
);
return {
id: order.id,
status: mapStatus(order.status),
amount: { amount: totalAmount, currency },
capturedAmount: { amount: capturedAmount, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: order.intent === "AUTHORIZE" ? "manual" : "automatic",
refunds: [],
createdAt: order.create_time,
updatedAt: order.update_time,
};
}

View File

@@ -0,0 +1,189 @@
import { PAYMENT_PROVIDER_CODES } from "@suaveplan/payment-providers";
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
baseOrder,
jsonResponse,
make,
PROD,
recorder,
TOKEN_RESPONSE,
TOKEN_RESPONSE_REFRESH,
} from "../../tests/fixtures/paypal-recorder.js";
describe("PayPalProvider — OAuth token cache", () => {
it("reuses a cached OAuth token across method calls", {
timeout: 5000,
}, async () => {
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch });
await provider.createIntent({
amount: { amount: 1000n, currency: "USD" },
});
await provider.createIntent({
amount: { amount: 2000n, currency: "USD" },
});
const tokenCalls = rec.calls.filter((c) =>
c.url.endsWith("/v1/oauth2/token")
);
const orderCalls = rec.calls.filter((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(tokenCalls).toHaveLength(1);
expect(orderCalls).toHaveLength(2);
});
it("refreshes the token after the cached expiry passes", {
timeout: 5000,
}, async () => {
const clock = new FakeClock({ startTime: 1_000_000 });
let tokenIdx = 0;
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
tokenIdx += 1;
return jsonResponse(
tokenIdx === 1 ? TOKEN_RESPONSE : TOKEN_RESPONSE_REFRESH
);
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch, now: () => clock.now() });
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
// expires_in 3600s = 3,600,000ms, minus 60s skew = 3,540,000ms
clock.advance(3_540_001);
await provider.createIntent({
amount: { amount: 200n, currency: "USD" },
});
const tokenCalls = rec.calls.filter((c) =>
c.url.endsWith("/v1/oauth2/token")
);
expect(tokenCalls).toHaveLength(2);
const orderCalls = rec.calls.filter(
(c) => c.url.endsWith("/v2/checkout/orders") && c.method === "POST"
);
expect(orderCalls[1]?.headers.Authorization).toBe("Bearer tok_xyz");
});
it("derives the Basic header via the injected base64 encoder", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({
fetch: rec.fetch,
base64Encode: (input) => `X-${input}`,
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
const tokenCall = rec.calls.find((c) =>
c.url.endsWith("/v1/oauth2/token")
);
expect(tokenCall?.headers.Authorization).toBe(
"Basic X-client_x:secret_y"
);
expect(tokenCall?.body).toBe("grant_type=client_credentials");
expect(tokenCall?.headers["Content-Type"]).toBe(
"application/x-www-form-urlencoded"
);
});
});
describe("PayPalProvider — OAuth failure modes", () => {
it("raises OAUTH_TOKEN_FAILURE when the token endpoint returns a non-2xx", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse({ error: "invalid_client" }, 401)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
await expect(
provider.createIntent({
amount: { amount: 100n, currency: "USD" },
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
provider: "paypal",
url: `${PROD}/v1/oauth2/token`,
});
const orderCalls = rec.calls.filter((c) =>
c.url.includes("/v2/checkout/orders")
);
expect(orderCalls).toEqual([]);
});
it("raises OAUTH_TOKEN_FAILURE when the token endpoint throws", {
timeout: 5000,
}, async () => {
const transport = new Error("ECONNRESET");
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
throw transport;
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch });
await expect(
provider.createIntent({
amount: { amount: 100n, currency: "USD" },
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
provider: "paypal",
});
});
});
describe("PayPalProvider — endpoint resolution", () => {
it("defaults to the sandbox endpoint when sandbox flag is true", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.includes("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch, sandbox: true });
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
for (const call of rec.calls) {
expect(
call.url.startsWith("https://api-m.sandbox.paypal.com")
).toBe(true);
}
});
it("explicit baseUrl overrides the sandbox flag", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.includes("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({
fetch: rec.fetch,
sandbox: true,
baseUrl: "https://override.example",
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
for (const call of rec.calls) {
expect(call.url.startsWith("https://override.example")).toBe(true);
}
});
});

View File

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

View File

@@ -0,0 +1,458 @@
import type { PaymentProvider } from "@suaveplan/payment";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
baseOrder,
baseRefund,
jsonResponse,
make,
PROD,
recorder,
TOKEN_RESPONSE,
} from "../../tests/fixtures/paypal-recorder.js";
import { PayPalProvider } from "./paypal.js";
describe("PayPalProvider — lifecycle methods", () => {
it("has literal name 'paypal'", { timeout: 5000 }, () => {
const provider = make({
fetch: recorder(() => jsonResponse({})).fetch,
});
expect(provider.name).toBe("paypal");
const literal: "paypal" = provider.name;
expect(literal).toBe("paypal");
});
it("satisfies the PaymentProvider structural contract", {
timeout: 5000,
}, () => {
const provider = make({
fetch: recorder(() => jsonResponse({})).fetch,
});
const asContract: PaymentProvider = provider;
expect(typeof asContract.createIntent).toBe("function");
expect(typeof asContract.confirmIntent).toBe("function");
expect(typeof asContract.captureIntent).toBe("function");
expect(typeof asContract.cancelIntent).toBe("function");
expect(typeof asContract.refund).toBe("function");
expect(typeof asContract.getIntent).toBe("function");
});
it("createIntent with manual captureMethod posts an AUTHORIZE order", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Manual cap",
captureMethod: "manual",
idempotencyKey: "idem-1",
});
expect(intent.id).toBe("ORDER_123");
expect(intent.status).toBe("created");
const orderCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(orderCall?.method).toBe("POST");
expect(orderCall?.headers["PayPal-Request-Id"]).toBe("idem-1");
expect(orderCall?.headers.Authorization).toBe("Bearer tok_abc");
const body = JSON.parse(orderCall?.body ?? "{}") as {
intent: string;
purchase_units: Array<{
amount: { value: string; currency_code: string };
description?: string;
}>;
};
expect(body.intent).toBe("AUTHORIZE");
expect(body.purchase_units[0]?.amount.value).toBe("50.00");
expect(body.purchase_units[0]?.amount.currency_code).toBe("USD");
expect(body.purchase_units[0]?.description).toBe("Manual cap");
});
it("createIntent with automatic captureMethod posts a CAPTURE order without an idempotency header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
captureMethod: "automatic",
});
const orderCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(orderCall?.headers["PayPal-Request-Id"]).toBeUndefined();
const body = JSON.parse(orderCall?.body ?? "{}") as {
intent: string;
purchase_units: Array<{
amount: { value: string; currency_code: string };
description?: string;
}>;
};
expect(body.intent).toBe("CAPTURE");
expect(body.purchase_units[0]?.amount.value).toBe("10.00");
expect(body.purchase_units[0]?.amount.currency_code).toBe("EUR");
expect(body.purchase_units[0]?.description).toBeUndefined();
});
it("confirmIntent posts authorize and maps APPROVED to authorized", {
timeout: 5000,
}, async () => {
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return jsonResponse({ ...baseOrder(), status: "APPROVED" });
});
const provider = make({ fetch: rec.fetch });
const intent = await provider.confirmIntent("ORDER_X");
expect(intent.status).toBe("authorized");
const authCall = rec.calls.find((c) => c.url.includes("/authorize"));
expect(authCall?.method).toBe("POST");
expect(authCall?.url).toBe(
`${PROD}/v2/checkout/orders/ORDER_X/authorize`
);
});
it("captureIntent sums every purchase_unit capture into capturedAmount", {
timeout: 5000,
}, async () => {
const captured = {
...baseOrder(),
status: "COMPLETED",
purchase_units: [
{
amount: { currency_code: "USD", value: "50.00" },
payments: {
captures: [
{
id: "CAP1",
amount: {
value: "25.00",
currency_code: "USD",
},
status: "COMPLETED",
},
{
id: "CAP2",
amount: {
value: "25.00",
currency_code: "USD",
},
status: "COMPLETED",
},
],
},
},
],
};
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(captured)
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.captureIntent("ORDER_X");
expect(intent.status).toBe("captured");
expect(intent.capturedAmount.amount).toBe(5000n);
const captureCall = rec.calls.find((c) => c.url.includes("/capture"));
expect(captureCall?.method).toBe("POST");
});
it("cancelIntent voids the order and returns a canceled intent with injected-now timestamps", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: new Response(null, { status: 204 })
);
const fixed = 1_700_000_000_000;
const provider = make({ fetch: rec.fetch, now: () => fixed });
const intent = await provider.cancelIntent("ORDER_X");
expect(intent.id).toBe("ORDER_X");
expect(intent.status).toBe("canceled");
expect(intent.amount.amount).toBe(0n);
expect(intent.capturedAmount.amount).toBe(0n);
expect(intent.refundedAmount.amount).toBe(0n);
expect(intent.captureMethod).toBe("automatic");
const stamp = new Date(fixed).toISOString();
expect(intent.createdAt).toBe(stamp);
expect(intent.updatedAt).toBe(stamp);
const voidCall = rec.calls.find((c) => c.url.includes("/void"));
expect(voidCall?.method).toBe("POST");
expect(voidCall?.url).toBe(`${PROD}/v2/checkout/orders/ORDER_X/void`);
});
it("refund with explicit amount and idempotency posts amount body and PayPal-Request-Id header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseRefund())
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({
paymentIntentId: "CAP_X",
amount: { amount: 2000n, currency: "USD" },
idempotencyKey: "rfn-1",
});
expect(refund.id).toBe("REFUND_123");
expect(refund.paymentIntentId).toBe("CAP_X");
expect(refund.amount.amount).toBe(2000n);
expect(refund.amount.currency).toBe("USD");
expect(refund.status).toBe("succeeded");
const refundCall = rec.calls.find((c) => c.url.includes("/refund"));
expect(refundCall?.url).toBe(
`${PROD}/v2/payments/captures/CAP_X/refund`
);
expect(refundCall?.headers["PayPal-Request-Id"]).toBe("rfn-1");
const body = JSON.parse(refundCall?.body ?? "{}") as {
amount?: { currency_code: string; value: string };
};
expect(body.amount).toEqual({
currency_code: "USD",
value: "20.00",
});
});
it("refund without amount or idempotency posts an empty body and no idempotency header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseRefund())
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({ paymentIntentId: "CAP_X" });
expect(refund.id).toBe("REFUND_123");
const refundCall = rec.calls.find((c) => c.url.includes("/refund"));
expect(refundCall?.headers["PayPal-Request-Id"]).toBeUndefined();
expect(refundCall?.body).toBe(JSON.stringify({}));
});
it("getIntent fetches the order and maps PayPal status to PaymentStatus", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
...baseOrder(),
id: "ORDER_Y",
status: "COMPLETED",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_Y");
expect(intent.id).toBe("ORDER_Y");
expect(intent.status).toBe("captured");
expect(intent.capturedAmount.amount).toBe(0n);
const getCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders/ORDER_Y")
);
expect(getCall?.method).toBe("GET");
});
});
describe("PayPalProvider — status mapping", () => {
it("maps every documented PayPal order status to PaymentStatus", {
timeout: 5000,
}, async () => {
const cases: ReadonlyArray<readonly [string, string, string]> = [
["CREATED", "created", "CAPTURE"],
["SAVED", "created", "CAPTURE"],
["APPROVED", "authorized", "CAPTURE"],
["VOIDED", "canceled", "CAPTURE"],
["COMPLETED", "captured", "CAPTURE"],
["PAYER_ACTION_REQUIRED", "pending", "CAPTURE"],
["UNKNOWN_STATUS", "failed", "CAPTURE"],
// intent AUTHORIZE → captureMethod "manual"
["CREATED", "created", "AUTHORIZE"],
];
for (const [ppStatus, expected, intent] of cases) {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ ...baseOrder(), status: ppStatus, intent })
);
const provider = make({ fetch: rec.fetch });
const result = await provider.getIntent("ORDER_X");
expect(result.status).toBe(expected);
expect(result.captureMethod).toBe(
intent === "AUTHORIZE" ? "manual" : "automatic"
);
}
});
it("maps every documented PayPal refund status to RefundStatus", {
timeout: 5000,
}, async () => {
const cases: ReadonlyArray<readonly [string, string]> = [
["COMPLETED", "succeeded"],
["PENDING", "pending"],
["CANCELLED", "failed"],
];
for (const [ppStatus, expected] of cases) {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ ...baseRefund(), status: ppStatus })
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({ paymentIntentId: "CAP_X" });
expect(refund.status).toBe(expected);
}
});
it("mapOrder handles a missing purchase_units array", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
id: "ORDER_EMPTY",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_EMPTY");
expect(intent.amount.amount).toBe(0n);
expect(intent.amount.currency).toBe("USD");
expect(intent.capturedAmount.amount).toBe(0n);
});
it("mapOrder handles a purchase_unit without a payments block", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
id: "ORDER_NOPAY",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "GBP", value: "12.34" },
},
],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_NOPAY");
expect(intent.amount.amount).toBe(1234n);
expect(intent.amount.currency).toBe("GBP");
expect(intent.capturedAmount.amount).toBe(0n);
});
it("parseAmount handles values without a decimal separator", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
...baseOrder(),
purchase_units: [
{
amount: { currency_code: "JPY", value: "500" },
},
],
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_NODEC");
expect(intent.amount.amount).toBe(50000n);
expect(intent.amount.currency).toBe("JPY");
});
});
describe("PayPalProvider — error narrowing", () => {
it("every failure path narrows to PaymentProviderError with provider 'paypal'", {
timeout: 5000,
}, async () => {
// OAUTH_TOKEN_FAILURE path.
const rec1 = recorder(() => jsonResponse({ err: "no" }, 500));
const provider1 = make({ fetch: rec1.fetch });
const err1 = await provider1.getIntent("X").catch((e: unknown) => e);
expect(err1).toBeInstanceOf(PaymentProviderError);
expect((err1 as PaymentProviderError).provider).toBe("paypal");
expect((err1 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE
);
// HTTP_ERROR path (token OK, order call non-2xx).
const rec2 = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ message: "Order not found" }, 404)
);
const provider2 = make({ fetch: rec2.fetch });
const err2 = await provider2
.getIntent("MISSING")
.catch((e: unknown) => e);
expect(err2).toBeInstanceOf(PaymentProviderError);
expect((err2 as PaymentProviderError).provider).toBe("paypal");
expect((err2 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.HTTP_ERROR
);
// PARSE_ERROR path (token OK, order call returns malformed JSON).
const rec3 = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: new Response("{not-json", { status: 200 })
);
const provider3 = make({ fetch: rec3.fetch });
const err3 = await provider3.getIntent("BAD").catch((e: unknown) => e);
expect(err3).toBeInstanceOf(PaymentProviderError);
expect((err3 as PaymentProviderError).provider).toBe("paypal");
expect((err3 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.PARSE_ERROR
);
// TIMEOUT path.
const provider4 = new PayPalProvider({
clientId: "c",
clientSecret: "s",
fetch: async (url, init) => {
if (url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
});
},
timeoutMs: 25,
});
const err4 = await provider4.getIntent("HANG").catch((e: unknown) => e);
expect(err4).toBeInstanceOf(PaymentProviderError);
expect((err4 as PaymentProviderError).provider).toBe("paypal");
expect((err4 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.TIMEOUT
);
});
});

View File

@@ -0,0 +1,252 @@
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import {
type Base64EncodeFn,
type FetchLike,
httpRequest,
type NowFn,
oauthTokenFailure,
} from "@suaveplan/payment-providers";
import {
formatAmount,
mapOrder,
mapRefundStatus,
parseAmount,
} from "./mapping.js";
import type {
CachedToken,
PayPalConfig,
PayPalOrder,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal.types.js";
const DEFAULT_PRODUCTION_BASE_URL = "https://api-m.paypal.com";
const DEFAULT_SANDBOX_BASE_URL = "https://api-m.sandbox.paypal.com";
const DEFAULT_TIMEOUT_MS = 30_000;
const TOKEN_SKEW_MS = 60_000;
const PROVIDER_NAME = "paypal";
function defaultBase64(): Base64EncodeFn {
return (input) => globalThis.btoa(input);
}
function defaultNow(): NowFn {
return () => Date.now();
}
function resolveBaseUrl(config: PayPalConfig): string {
if (config.baseUrl !== undefined) {
return config.baseUrl;
}
return config.sandbox === true
? DEFAULT_SANDBOX_BASE_URL
: DEFAULT_PRODUCTION_BASE_URL;
}
/**
* PayPal payment provider implementing the slim `PaymentProvider` contract
* from `@suaveplan/payment` against the PayPal Orders v2 + Payments
* Captures Refund APIs. Uses an in-memory OAuth2 client-credentials token
* cache and the slim core's `httpRequest` helper for every HTTP call.
*
* Universal: zero `node:*` / `bun:*` / DOM / React imports. The OAuth
* Basic header derives via the injected `base64Encode` seam (defaulting to
* `globalThis.btoa`), and the token-cache TTL derives via the injected
* `now` seam (defaulting to `Date.now`), so the package runs unchanged on
* Node, Bun, browsers, and Web Workers.
*/
export class PayPalProvider implements PaymentProvider {
public readonly name = "paypal" as const;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly fetchImpl: FetchLike | undefined;
private readonly externalSignal: AbortSignal | undefined;
private readonly now: NowFn;
private readonly base64Encode: Base64EncodeFn;
private cachedToken: CachedToken | null = null;
public constructor(config: PayPalConfig) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.baseUrl = resolveBaseUrl(config);
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchImpl = config.fetch;
this.externalSignal = config.signal;
this.now = config.now ?? defaultNow();
this.base64Encode = config.base64Encode ?? defaultBase64();
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const token = await this.acquireToken();
const body = {
intent: input.captureMethod === "manual" ? "AUTHORIZE" : "CAPTURE",
purchase_units: [
{
amount: {
currency_code: input.amount.currency,
value: formatAmount(input.amount.amount),
},
...(input.description !== undefined
? { description: input.description }
: {}),
},
],
};
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: "/v2/checkout/orders",
token,
body,
idempotencyKey: input.idempotencyKey,
});
return mapOrder(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: `/v2/checkout/orders/${intentId}/authorize`,
token,
});
return mapOrder(data);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: `/v2/checkout/orders/${intentId}/capture`,
token,
});
return mapOrder(data);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
await this.callApi({
method: "POST",
path: `/v2/checkout/orders/${intentId}/void`,
token,
});
const stamp = new Date(this.now()).toISOString();
return {
id: intentId,
status: "canceled",
amount: { amount: 0n, currency: "USD" },
capturedAmount: { amount: 0n, currency: "USD" },
refundedAmount: { amount: 0n, currency: "USD" },
captureMethod: "automatic",
refunds: [],
createdAt: stamp,
updatedAt: stamp,
};
}
public async refund(input: RefundInput): Promise<Refund> {
const token = await this.acquireToken();
const body: Record<string, unknown> = {};
if (input.amount !== undefined) {
body["amount"] = {
currency_code: input.amount.currency,
value: formatAmount(input.amount.amount),
};
}
const { data } = await this.callApi<PayPalRefund>({
method: "POST",
path: `/v2/payments/captures/${input.paymentIntentId}/refund`,
token,
body,
idempotencyKey: input.idempotencyKey,
});
return {
id: data.id,
paymentIntentId: input.paymentIntentId,
amount: {
amount: parseAmount(data.amount.value),
currency: data.amount.currency_code,
},
status: mapRefundStatus(data.status),
createdAt: data.create_time,
};
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "GET",
path: `/v2/checkout/orders/${intentId}`,
token,
});
return mapOrder(data);
}
private async callApi<T = unknown>(args: {
readonly method: "GET" | "POST" | "DELETE" | "PATCH";
readonly path: string;
readonly token: string;
readonly body?: unknown;
readonly idempotencyKey?: string | undefined;
}): Promise<{ readonly status: number; readonly data: T }> {
const headers: Record<string, string> = {
Authorization: `Bearer ${args.token}`,
};
if (args.idempotencyKey !== undefined) {
headers["PayPal-Request-Id"] = args.idempotencyKey;
}
return httpRequest<T>({
method: args.method,
url: `${this.baseUrl}${args.path}`,
headers,
...(args.body !== undefined ? { body: args.body } : {}),
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: PROVIDER_NAME,
});
}
private async acquireToken(): Promise<string> {
const current = this.cachedToken;
if (current !== null && this.now() < current.expiresAt) {
return current.accessToken;
}
const tokenUrl = `${this.baseUrl}/v1/oauth2/token`;
const basic = this.base64Encode(
`${this.clientId}:${this.clientSecret}`
);
try {
const { data } = await httpRequest<PayPalTokenResponse>({
method: "POST",
url: tokenUrl,
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: PROVIDER_NAME,
});
const acquiredAt = this.now();
this.cachedToken = {
accessToken: data.access_token,
expiresAt: acquiredAt + data.expires_in * 1000 - TOKEN_SKEW_MS,
};
return data.access_token;
} catch (cause) {
throw oauthTokenFailure(PROVIDER_NAME, tokenUrl, cause);
}
}
}

View File

@@ -0,0 +1,79 @@
import type { OAuthConfig } from "@suaveplan/payment-providers";
/**
* Constructor configuration for {@link PayPalProvider}. Extends the slim
* core's {@link OAuthConfig} with a single optional `sandbox` shortcut:
* when `true` and `baseUrl` is unset, the provider routes traffic to
* `https://api-m.sandbox.paypal.com`; otherwise it routes to
* `https://api-m.paypal.com`. An explicit `baseUrl` always wins.
*/
export interface PayPalConfig extends OAuthConfig {
/**
* When `true` and `baseUrl` is unset, the provider routes to the
* PayPal sandbox endpoint. Default `false` (production).
*/
readonly sandbox?: boolean;
}
/** PayPal Orders v2 `purchase_unit` amount object. */
export interface PayPalAmount {
readonly currency_code: string;
readonly value: string;
}
/** PayPal Orders v2 capture entry. */
export interface PayPalCapture {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
}
/** PayPal Orders v2 refund entry (nested under `payments.refunds`). */
export interface PayPalEmbeddedRefund {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
}
/** PayPal Orders v2 `purchase_unit.payments` block. */
export interface PayPalPayments {
readonly captures?: ReadonlyArray<PayPalCapture>;
readonly refunds?: ReadonlyArray<PayPalEmbeddedRefund>;
}
/** PayPal Orders v2 `purchase_unit`. */
export interface PayPalPurchaseUnit {
readonly amount: PayPalAmount;
readonly description?: string;
readonly payments?: PayPalPayments;
}
/** PayPal Orders v2 order wire shape. */
export interface PayPalOrder {
readonly id: string;
readonly status: string;
readonly intent: string;
readonly purchase_units: ReadonlyArray<PayPalPurchaseUnit>;
readonly create_time: string;
readonly update_time: string;
}
/** PayPal Payments-Captures-Refund response wire shape. */
export interface PayPalRefund {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
readonly create_time: string;
}
/** PayPal OAuth2 token endpoint response wire shape. */
export interface PayPalTokenResponse {
readonly access_token: string;
readonly expires_in: number;
}
/** Internal token-cache entry — `null` when no token is held. */
export interface CachedToken {
readonly accessToken: string;
readonly expiresAt: number;
}

View File

@@ -0,0 +1,80 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const SRC_ROOT = join(PKG_ROOT, "src");
interface PackageJson {
readonly dependencies?: Record<string, string>;
readonly devDependencies?: Record<string, string>;
readonly peerDependencies?: Record<string, string>;
}
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("declares only universal dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as PackageJson;
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(
[
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
].sort()
);
});
it("source imports only @suaveplan/* siblings, tslib, and node-free intrinsics", {
timeout: 5000,
}, () => {
// Match any "from 'X'" or "from \"X\"" import specifier.
const importPattern = /from\s+['"]([^'"]+)['"]/g;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
for (const match of text.matchAll(importPattern)) {
const spec = match[1] ?? "";
if (spec.startsWith("node:") || spec.startsWith("bun:")) {
offenders.push(`${file}: ${spec}`);
continue;
}
if (
spec === "fs" ||
spec === "path" ||
spec === "crypto" ||
spec === "stream" ||
spec === "net" ||
spec === "http" ||
spec === "https" ||
spec === "react" ||
spec === "react-dom"
) {
offenders.push(`${file}: ${spec}`);
}
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,111 @@
import type { FetchLike } from "@suaveplan/payment-providers";
import { PayPalProvider } from "../../src/paypal/paypal.js";
export interface Recorded {
readonly method: string;
readonly url: string;
readonly headers: Record<string, string>;
readonly body: string | undefined;
}
export interface Recorder {
readonly fetch: FetchLike;
readonly calls: Recorded[];
}
export function recorder(
handler: (recorded: Recorded) => Response | Promise<Response>
): Recorder {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
const headers: Record<string, string> = {};
const raw = init?.headers as Record<string, string> | undefined;
if (raw !== undefined) {
for (const [k, v] of Object.entries(raw)) {
headers[k] = v;
}
}
const body = init?.body;
const bodyStr =
typeof body === "string"
? body
: body === undefined
? undefined
: String(body);
const recorded: Recorded = {
method: init?.method ?? "GET",
url,
headers,
body: bodyStr,
};
calls.push(recorded);
return handler(recorded);
};
return { fetch: fetchImpl, calls };
}
export function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
}
export const TOKEN_RESPONSE = {
access_token: "tok_abc",
expires_in: 3600,
};
export const TOKEN_RESPONSE_REFRESH = {
access_token: "tok_xyz",
expires_in: 3600,
};
export function baseOrder(): Record<string, unknown> {
return {
id: "ORDER_123",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: "50.00" },
description: "Test order",
payments: { captures: [], refunds: [] },
},
],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
};
}
export function baseRefund(): Record<string, unknown> {
return {
id: "REFUND_123",
amount: { value: "20.00", currency_code: "USD" },
status: "COMPLETED",
create_time: "2026-01-02T00:00:00Z",
};
}
export interface MakeArgs {
readonly fetch: FetchLike;
readonly now?: () => number;
readonly base64Encode?: (input: string) => string;
readonly baseUrl?: string;
readonly sandbox?: boolean;
}
export function make(args: MakeArgs): PayPalProvider {
return new PayPalProvider({
clientId: "client_x",
clientSecret: "secret_y",
fetch: args.fetch,
...(args.now !== undefined ? { now: args.now } : {}),
...(args.base64Encode !== undefined
? { base64Encode: args.base64Encode }
: {}),
...(args.baseUrl !== undefined ? { baseUrl: args.baseUrl } : {}),
...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}),
});
}
export const PROD = "https://api-m.paypal.com";

View File

@@ -0,0 +1,6 @@
// Polyfills for the payment-provider-paypal test environment — none required.
// The package consumes the slim core's `FetchLike` seam and the `Base64EncodeFn`
// injectable; every test passes an explicit recorder closure, so no platform
// `fetch` or `btoa` dependency is needed. This file exists to mirror the
// sibling-package layout and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-paypal",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,69 @@
{
"name": "@suaveplan/payment-provider-square",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./square/square": {
"import": { "types": "./dist/square/square.d.ts", "default": "./dist/square/square.js" },
"require": { "types": "./dist/square/square.d.ts", "default": "./dist/square/square.js" }
},
"./types/config": {
"import": { "types": "./dist/types/config.d.ts", "default": "./dist/types/config.js" },
"require": { "types": "./dist/types/config.d.ts", "default": "./dist/types/config.js" }
},
"./mapping/payment": {
"import": { "types": "./dist/mapping/payment.d.ts", "default": "./dist/mapping/payment.js" },
"require": { "types": "./dist/mapping/payment.d.ts", "default": "./dist/mapping/payment.js" }
},
"./mapping/refund": {
"import": { "types": "./dist/mapping/refund.d.ts", "default": "./dist/mapping/refund.js" },
"require": { "types": "./dist/mapping/refund.d.ts", "default": "./dist/mapping/refund.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,19 @@
export {
mapPayment,
mapPaymentStatus,
type SquareCard,
type SquareMoney,
type SquarePaymentDto,
} from "./mapping/payment.js";
export {
mapRefund,
mapRefundStatus,
type SquareRefundDto,
} from "./mapping/refund.js";
export { SquareProvider } from "./square/square.js";
export {
SQUARE_DEFAULT_API_VERSION,
SQUARE_DEFAULT_BASE_URL,
SQUARE_SANDBOX_BASE_URL,
type SquareConfig,
} from "./types/config.js";

View File

@@ -0,0 +1,12 @@
export {
mapPayment,
mapPaymentStatus,
type SquareCard,
type SquareMoney,
type SquarePaymentDto,
} from "./payment.js";
export {
mapRefund,
mapRefundStatus,
type SquareRefundDto,
} from "./refund.js";

View File

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

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
mapPayment,
mapPaymentStatus,
type SquarePaymentDto,
} from "./payment.js";
function basePayment(
overrides: Partial<SquarePaymentDto> = {}
): SquarePaymentDto {
return {
id: "sq_pay_123",
status: "COMPLETED",
amount_money: { amount: 5000, currency: "USD" },
total_money: { amount: 5000, currency: "USD" },
customer_id: "cust_1",
note: "Test payment",
card_details: { card: { card_brand: "VISA", last_4: "1234" } },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("mapPaymentStatus", () => {
it("maps every known Square status to the documented PaymentStatus", {
timeout: 5000,
}, () => {
expect(mapPaymentStatus("APPROVED")).toBe("authorized");
expect(mapPaymentStatus("COMPLETED")).toBe("captured");
expect(mapPaymentStatus("CANCELED")).toBe("canceled");
expect(mapPaymentStatus("FAILED")).toBe("failed");
expect(mapPaymentStatus("PENDING")).toBe("pending");
});
it("falls back to failed for unknown Square status", {
timeout: 5000,
}, () => {
expect(mapPaymentStatus("SOMETHING_NEW")).toBe("failed");
expect(mapPaymentStatus("")).toBe("failed");
});
});
describe("mapPayment", () => {
it("maps a COMPLETED payment with card details", { timeout: 5000 }, () => {
const result = mapPayment(basePayment());
expect(result.id).toBe("sq_pay_123");
expect(result.status).toBe("captured");
expect(result.amount).toEqual({
amount: 5000n,
currency: "USD",
});
expect(result.capturedAmount).toEqual({
amount: 5000n,
currency: "USD",
});
expect(result.refundedAmount).toEqual({
amount: 0n,
currency: "USD",
});
expect(result.captureMethod).toBe("automatic");
expect(result.customerId).toBe("cust_1");
expect(result.description).toBe("Test payment");
expect(result.paymentMethod).toEqual({
id: "sq_card_sq_pay_123",
type: "card",
brand: "visa",
last4: "1234",
});
expect(result.refunds).toEqual([]);
});
it("flips captureMethod to manual when approved_money is present", {
timeout: 5000,
}, () => {
const result = mapPayment(
basePayment({
status: "APPROVED",
approved_money: { amount: 5000, currency: "USD" },
})
);
expect(result.captureMethod).toBe("manual");
expect(result.status).toBe("authorized");
expect(result.capturedAmount.amount).toBe(0n);
});
it("sets paymentMethod to undefined when card_details is absent", {
timeout: 5000,
}, () => {
const result = mapPayment(basePayment({ card_details: undefined }));
expect(result.paymentMethod).toBeUndefined();
});
it("lower-cases the card brand and falls back to unknown for non-canonical brands", {
timeout: 5000,
}, () => {
const mc = mapPayment(
basePayment({
card_details: {
card: { card_brand: "MASTERCARD", last_4: "4444" },
},
})
);
expect(mc.paymentMethod?.brand).toBe("mastercard");
const obscure = mapPayment(
basePayment({
card_details: {
card: { card_brand: "SQUARE_GIFT", last_4: "0000" },
},
})
);
expect(obscure.paymentMethod?.brand).toBe("unknown");
});
it("leaves capturedAmount at zero when status is not COMPLETED", {
timeout: 5000,
}, () => {
const result = mapPayment(basePayment({ status: "PENDING" }));
expect(result.status).toBe("pending");
expect(result.capturedAmount.amount).toBe(0n);
});
it("omits optional fields when Square envelope omits them", {
timeout: 5000,
}, () => {
const result = mapPayment(
basePayment({
customer_id: undefined,
note: undefined,
card_details: undefined,
})
);
expect(result.customerId).toBeUndefined();
expect(result.description).toBeUndefined();
expect(result.paymentMethod).toBeUndefined();
});
});

View File

@@ -0,0 +1,123 @@
import type {
CardBrand,
PaymentIntent,
PaymentStatus,
} from "@suaveplan/payment";
/**
* Subset of Square's `Money` envelope. Square serialises every
* money-typed field as `{ amount: number, currency: string }` where
* `amount` is the smallest-unit integer (cents for USD, etc.).
*/
export interface SquareMoney {
readonly amount: number;
readonly currency: string;
}
/**
* Subset of Square's `Card` envelope captured on `card_details.card`.
* Square reports `card_brand` in upper case; the mapping function
* lower-cases it to match `@suaveplan/payment`'s `CardBrand` union.
*/
export interface SquareCard {
readonly card_brand: string;
readonly last_4: string;
}
/**
* Subset of Square's `Payment` envelope this package consumes. Square
* documents additional fields (location_id, processing_fee, etc.)
* that are not part of the genesis `PaymentIntent` shape.
*/
export interface SquarePaymentDto {
readonly id: string;
readonly status: string;
readonly amount_money: SquareMoney;
readonly total_money: SquareMoney;
readonly approved_money?: SquareMoney;
readonly customer_id?: string;
readonly note?: string;
readonly source_type?: string;
readonly card_details?: { readonly card: SquareCard };
readonly created_at: string;
readonly updated_at: string;
}
const STATUS_MAP: Readonly<Record<string, PaymentStatus>> = Object.freeze({
APPROVED: "authorized",
COMPLETED: "captured",
CANCELED: "canceled",
FAILED: "failed",
PENDING: "pending",
});
/**
* Map Square's payment status to the genesis {@link PaymentStatus}
* union. Unknown statuses fall back to `"failed"` so a forward-rolled
* API version cannot widen the union silently.
*/
export function mapPaymentStatus(squareStatus: string): PaymentStatus {
return STATUS_MAP[squareStatus] ?? "failed";
}
function brandFrom(card: SquareCard): CardBrand {
const brand = card.card_brand.toLowerCase();
const known: readonly CardBrand[] = [
"visa",
"mastercard",
"amex",
"discover",
"diners",
"jcb",
"unionpay",
];
return (known as readonly string[]).includes(brand)
? (brand as CardBrand)
: "unknown";
}
/**
* Shape a Square `Payment` envelope into a genesis
* {@link PaymentIntent}. Captures the `COMPLETED` → captured-amount
* branch, the `approved_money` → manual-capture branch, and the
* `card_details` → `PaymentMethod` branch.
*/
export function mapPayment(p: SquarePaymentDto): PaymentIntent {
const currency = p.amount_money.currency;
const capturedRaw =
p.status === "COMPLETED" ? BigInt(p.total_money.amount) : 0n;
const base = {
id: p.id,
status: mapPaymentStatus(p.status),
amount: { amount: BigInt(p.amount_money.amount), currency },
capturedAmount: { amount: capturedRaw, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: (p.approved_money ? "manual" : "automatic") as
| "manual"
| "automatic",
refunds: [] as const,
createdAt: p.created_at,
updatedAt: p.updated_at,
};
const withCustomer =
p.customer_id !== undefined
? { ...base, customerId: p.customer_id }
: base;
const withDescription =
p.note !== undefined
? { ...withCustomer, description: p.note }
: withCustomer;
const intent: PaymentIntent = p.card_details
? {
...withDescription,
paymentMethod: {
id: `sq_card_${p.id}`,
type: "card",
brand: brandFrom(p.card_details.card),
last4: p.card_details.card.last_4,
},
}
: withDescription;
return intent;
}

View File

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

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { mapRefund, mapRefundStatus, type SquareRefundDto } from "./refund.js";
function baseRefund(overrides: Partial<SquareRefundDto> = {}): SquareRefundDto {
return {
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 request",
...overrides,
};
}
describe("mapRefundStatus", () => {
it("maps every known Square refund status", { timeout: 5000 }, () => {
expect(mapRefundStatus("COMPLETED")).toBe("succeeded");
expect(mapRefundStatus("PENDING")).toBe("pending");
expect(mapRefundStatus("REJECTED")).toBe("failed");
expect(mapRefundStatus("FAILED")).toBe("failed");
expect(mapRefundStatus("")).toBe("failed");
});
});
describe("mapRefund", () => {
it("maps a refund with a reason to requested_by_customer", {
timeout: 5000,
}, () => {
const r = mapRefund(baseRefund());
expect(r.id).toBe("sq_ref_123");
expect(r.paymentIntentId).toBe("sq_pay_123");
expect(r.amount).toEqual({ amount: 2000n, currency: "USD" });
expect(r.status).toBe("succeeded");
expect(r.reason).toBe("requested_by_customer");
expect(r.createdAt).toBe("2026-01-02T00:00:00Z");
});
it("falls back to other when reason is absent", { timeout: 5000 }, () => {
const undef = mapRefund(baseRefund({ reason: undefined }));
expect(undef.reason).toBe("other");
const empty = mapRefund(baseRefund({ reason: "" }));
expect(empty.reason).toBe("other");
});
it("carries the mapped status from mapRefundStatus", {
timeout: 5000,
}, () => {
expect(mapRefund(baseRefund({ status: "PENDING" })).status).toBe(
"pending"
);
expect(mapRefund(baseRefund({ status: "REJECTED" })).status).toBe(
"failed"
);
});
});

View File

@@ -0,0 +1,50 @@
import type { Refund, RefundStatus } from "@suaveplan/payment";
import type { SquareMoney } from "./payment.js";
/**
* Subset of Square's `Refund` envelope this package consumes. Square
* documents additional fields (processing_fee, etc.) the genesis
* {@link Refund} shape does not surface.
*/
export interface SquareRefundDto {
readonly id: string;
readonly payment_id: string;
readonly amount_money: SquareMoney;
readonly status: string;
readonly created_at: string;
readonly reason?: string;
}
/**
* Map Square's refund status to the genesis {@link RefundStatus}
* union. `COMPLETED` → `"succeeded"`, `PENDING` → `"pending"`,
* everything else (including `REJECTED` and `FAILED`) → `"failed"`.
*/
export function mapRefundStatus(squareRefundStatus: string): RefundStatus {
if (squareRefundStatus === "COMPLETED") return "succeeded";
if (squareRefundStatus === "PENDING") return "pending";
return "failed";
}
/**
* Shape a Square `Refund` envelope into a genesis {@link Refund}.
* Square's refund payload carries `reason` only when the merchant
* supplied one; the mapping defaults to `"other"` otherwise so the
* downstream `RefundReason` union is always populated.
*/
export function mapRefund(r: SquareRefundDto): Refund {
return {
id: r.id,
paymentIntentId: r.payment_id,
amount: {
amount: BigInt(r.amount_money.amount),
currency: r.amount_money.currency,
},
status: mapRefundStatus(r.status),
reason:
typeof r.reason === "string" && r.reason.length > 0
? "requested_by_customer"
: "other",
createdAt: r.created_at,
};
}

View File

@@ -0,0 +1 @@
export { SquareProvider } from "./square.js";

View File

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

View File

@@ -0,0 +1,435 @@
import type { PaymentProvider } from "@suaveplan/payment";
import {
type FetchLike,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { SquareConfig } from "../types/config.js";
import { SquareProvider } from "./square.js";
interface Recorded {
readonly url: string;
readonly init: RequestInit | undefined;
}
interface Recorder {
readonly fetch: FetchLike;
readonly calls: Recorded[];
}
function recorder(
handler: (
url: string,
init: RequestInit | undefined
) => Response | Promise<Response>
): Recorder {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const SQUARE_PAYMENT = {
id: "sq_pay_123",
status: "COMPLETED" as const,
amount_money: { amount: 5000, currency: "USD" },
total_money: { amount: 5000, currency: "USD" },
customer_id: "cust_1",
note: "Test payment",
card_details: { card: { card_brand: "VISA", last_4: "1234" } },
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const SQUARE_REFUND = {
id: "sq_ref_123",
payment_id: "sq_pay_123",
amount_money: { amount: 2000, currency: "USD" },
status: "COMPLETED" as const,
created_at: "2026-01-02T00:00:00Z",
reason: "Customer request",
};
function counterRandomId(): {
next: () => string;
fn: () => string;
} {
let i = 0;
const fn = (): string => {
i += 1;
return `idem-${i}`;
};
return { next: fn, fn };
}
function build(
overrides: Partial<SquareConfig> & { readonly fetch: FetchLike }
): SquareProvider {
return new SquareProvider({
apiKey: "sq_key_123",
locationId: "L1",
baseUrl: "https://square.mock/v2",
randomId: () => "idem-default",
...overrides,
});
}
describe("SquareProvider", () => {
it('name is the string literal "square"', { timeout: 5000 }, () => {
const p = build({ fetch: recorder(() => jsonResponse({})).fetch });
expect(p.name).toBe("square");
expectTypeOf(p.name).toEqualTypeOf<"square">();
});
it("is assignable to the PaymentProvider interface", {
timeout: 5000,
}, () => {
const p = build({ fetch: recorder(() => jsonResponse({})).fetch });
const asProvider: PaymentProvider = p;
expect(typeof asProvider.createIntent).toBe("function");
expect(typeof asProvider.confirmIntent).toBe("function");
expect(typeof asProvider.captureIntent).toBe("function");
expect(typeof asProvider.cancelIntent).toBe("function");
expect(typeof asProvider.refund).toBe("function");
expect(typeof asProvider.getIntent).toBe("function");
});
describe("base URL resolution", () => {
it("sandbox flag flips the default base URL", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = new SquareProvider({
apiKey: "k",
locationId: "L1",
sandbox: true,
fetch: rec.fetch,
});
await p.getIntent("sq_pay_123");
expect(rec.calls[0]?.url).toBe(
"https://connect.squareupsandbox.com/v2/payments/sq_pay_123"
);
});
it("uses the production base URL by default", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = new SquareProvider({
apiKey: "k",
locationId: "L1",
fetch: rec.fetch,
});
await p.getIntent("sq_pay_123");
expect(rec.calls[0]?.url).toBe(
"https://connect.squareup.com/v2/payments/sq_pay_123"
);
});
it("explicit baseUrl wins over sandbox flag", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = new SquareProvider({
apiKey: "k",
locationId: "L1",
sandbox: true,
baseUrl: "https://square.mock/v2",
fetch: rec.fetch,
});
await p.getIntent("sq_pay_123");
expect(rec.calls[0]?.url).toBe(
"https://square.mock/v2/payments/sq_pay_123"
);
});
});
describe("headers", () => {
it("sends Authorization: Bearer <apiKey> on every request", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
await p.getIntent("sq_pay_123");
const headers = rec.calls[0]?.init?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer sq_key_123");
});
it("defaults Square-Version to 2024-01-18", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
await p.getIntent("sq_pay_123");
const headers = rec.calls[0]?.init?.headers as Record<
string,
string
>;
expect(headers["Square-Version"]).toBe("2024-01-18");
});
it("pins the Square-Version header to the configured apiVersion", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({
fetch: rec.fetch,
apiVersion: "2024-09-19",
});
await p.getIntent("sq_pay_123");
const headers = rec.calls[0]?.init?.headers as Record<
string,
string
>;
expect(headers["Square-Version"]).toBe("2024-09-19");
});
});
describe("createIntent", () => {
it("createIntent posts the canonical body for an automatic-capture intent", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
const result = await p.createIntent({
amount: { amount: 5000n, currency: "USD" },
customerId: "c1",
description: "test",
paymentMethodId: "src_1",
idempotencyKey: "idem_1",
});
expect(result.id).toBe("sq_pay_123");
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(body).toEqual({
amount_money: { amount: 5000, currency: "USD" },
idempotency_key: "idem_1",
autocomplete: true,
location_id: "L1",
customer_id: "c1",
note: "test",
source_id: "src_1",
});
expect(rec.calls[0]?.url).toBe("https://square.mock/v2/payments");
expect(rec.calls[0]?.init?.method).toBe("POST");
});
it("createIntent flips autocomplete to false for manual capture", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
payment: {
...SQUARE_PAYMENT,
status: "APPROVED",
approved_money: { amount: 5000, currency: "USD" },
},
})
);
const p = build({ fetch: rec.fetch });
const result = await p.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "manual",
});
expect(result.status).toBe("authorized");
expect(result.captureMethod).toBe("manual");
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(body.autocomplete).toBe(false);
});
it("createIntent fills a missing idempotencyKey from the injected randomId", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const counter = counterRandomId();
const p = build({ fetch: rec.fetch, randomId: counter.fn });
await p.createIntent({
amount: { amount: 5000n, currency: "USD" },
});
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(body.idempotency_key).toBe("idem-1");
});
});
describe("confirmIntent", () => {
it("confirmIntent delegates to getIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
await p.confirmIntent("sq_pay_123");
expect(rec.calls).toHaveLength(1);
expect(rec.calls[0]?.init?.method).toBe("GET");
expect(rec.calls[0]?.url).toBe(
"https://square.mock/v2/payments/sq_pay_123"
);
});
});
describe("captureIntent", () => {
it("captureIntent posts to /payments/{id}/complete", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
const result = await p.captureIntent("sq_pay_123");
expect(result.status).toBe("captured");
expect(rec.calls[0]?.url).toBe(
"https://square.mock/v2/payments/sq_pay_123/complete"
);
expect(rec.calls[0]?.init?.method).toBe("POST");
});
});
describe("cancelIntent", () => {
it("cancelIntent posts to /payments/{id}/cancel", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
payment: { ...SQUARE_PAYMENT, status: "CANCELED" },
})
);
const p = build({ fetch: rec.fetch });
const result = await p.cancelIntent("sq_pay_123");
expect(result.status).toBe("canceled");
expect(rec.calls[0]?.url).toBe(
"https://square.mock/v2/payments/sq_pay_123/cancel"
);
expect(rec.calls[0]?.init?.method).toBe("POST");
});
});
describe("refund", () => {
it("refund posts the full body with amount and reason", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse({ refund: SQUARE_REFUND }));
const p = build({ fetch: rec.fetch });
const result = await p.refund({
paymentIntentId: "sq_pay_123",
amount: { amount: 2000n, currency: "USD" },
reason: "requested_by_customer",
idempotencyKey: "ref_idem_1",
});
expect(result.id).toBe("sq_ref_123");
expect(result.status).toBe("succeeded");
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(body).toEqual({
payment_id: "sq_pay_123",
idempotency_key: "ref_idem_1",
amount_money: { amount: 2000, currency: "USD" },
reason: "requested_by_customer",
});
expect(rec.calls[0]?.url).toBe("https://square.mock/v2/refunds");
});
it("refund fills a missing idempotencyKey from the injected randomId", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse({ refund: SQUARE_REFUND }));
const counter = counterRandomId();
const p = build({ fetch: rec.fetch, randomId: counter.fn });
await p.refund({ paymentIntentId: "sq_pay_123" });
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(body).toEqual({
payment_id: "sq_pay_123",
idempotency_key: "idem-1",
});
});
});
describe("getIntent", () => {
it("getIntent fetches /payments/{id}", { timeout: 5000 }, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = build({ fetch: rec.fetch });
const result = await p.getIntent("sq_pay_123");
expect(result.id).toBe("sq_pay_123");
expect(rec.calls).toHaveLength(1);
expect(rec.calls[0]?.init?.method).toBe("GET");
expect(rec.calls[0]?.url).toBe(
"https://square.mock/v2/payments/sq_pay_123"
);
});
});
describe("error surfacing", () => {
it("non-ok HTTP response surfaces a typed PaymentProviderError", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ message: "Not Found" }, 404)
);
const p = build({ fetch: rec.fetch });
await expect(p.getIntent("sq_pay_missing")).rejects.toSatisfy(
(err: unknown) => {
if (!(err instanceof PaymentProviderError)) return false;
return (
err.code === PAYMENT_PROVIDER_CODES.HTTP_ERROR &&
err.httpStatus === 404 &&
err.provider === "square"
);
}
);
});
});
describe("default randomId", () => {
it("falls back to crypto.randomUUID() when no randomId is injected", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ payment: SQUARE_PAYMENT })
);
const p = new SquareProvider({
apiKey: "k",
locationId: "L1",
baseUrl: "https://square.mock/v2",
fetch: rec.fetch,
});
await p.createIntent({
amount: { amount: 100n, currency: "USD" },
});
const body = JSON.parse(rec.calls[0]?.init?.body as string);
expect(typeof body.idempotency_key).toBe("string");
// crypto.randomUUID() returns a canonical UUID v4 string.
expect(body.idempotency_key).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});
});
});

View File

@@ -0,0 +1,176 @@
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import {
type FetchLike,
httpRequest,
type RandomIdFn,
} from "@suaveplan/payment-providers";
import { mapPayment, type SquarePaymentDto } from "../mapping/payment.js";
import { mapRefund, type SquareRefundDto } from "../mapping/refund.js";
import {
SQUARE_DEFAULT_API_VERSION,
SQUARE_DEFAULT_BASE_URL,
SQUARE_SANDBOX_BASE_URL,
type SquareConfig,
} from "../types/config.js";
const PROVIDER_NAME = "square" as const;
function defaultRandomId(): string {
return crypto.randomUUID();
}
function resolveBaseUrl(config: SquareConfig): string {
if (config.baseUrl !== undefined) {
return config.baseUrl;
}
return config.sandbox === true
? SQUARE_SANDBOX_BASE_URL
: SQUARE_DEFAULT_BASE_URL;
}
interface PaymentEnvelope {
readonly payment: SquarePaymentDto;
}
interface RefundEnvelope {
readonly refund: SquareRefundDto;
}
/**
* Square Payments API adapter implementing the
* {@link PaymentProvider} interface. 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.
*/
export class SquareProvider implements PaymentProvider {
public readonly name: "square" = PROVIDER_NAME;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly apiVersion: string;
private readonly randomId: RandomIdFn;
private readonly fetchImpl: FetchLike | undefined;
private readonly timeoutMs: number | undefined;
private readonly externalSignal: AbortSignal | undefined;
private readonly locationId: string;
public constructor(config: SquareConfig) {
this.apiKey = config.apiKey;
this.baseUrl = resolveBaseUrl(config);
this.apiVersion = config.apiVersion ?? SQUARE_DEFAULT_API_VERSION;
this.randomId = config.randomId ?? defaultRandomId;
this.fetchImpl = config.fetch;
this.timeoutMs = config.timeoutMs;
this.externalSignal = config.signal;
this.locationId = config.locationId;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const body: Record<string, unknown> = {
amount_money: {
amount: Number(input.amount.amount),
currency: input.amount.currency,
},
idempotency_key: input.idempotencyKey ?? this.randomId(),
autocomplete: input.captureMethod !== "manual",
location_id: this.locationId,
};
if (input.customerId !== undefined) {
body["customer_id"] = input.customerId;
}
if (input.description !== undefined) {
body["note"] = input.description;
}
if (input.paymentMethodId !== undefined) {
body["source_id"] = input.paymentMethodId;
}
const { data } = await this.post<PaymentEnvelope>("/payments", body);
return mapPayment(data.payment);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
return this.getIntent(intentId);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.post<PaymentEnvelope>(
`/payments/${intentId}/complete`,
undefined
);
return mapPayment(data.payment);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await this.post<PaymentEnvelope>(
`/payments/${intentId}/cancel`,
undefined
);
return mapPayment(data.payment);
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {
payment_id: input.paymentIntentId,
idempotency_key: input.idempotencyKey ?? this.randomId(),
};
if (input.amount !== undefined) {
body["amount_money"] = {
amount: Number(input.amount.amount),
currency: input.amount.currency,
};
}
if (input.reason !== undefined) {
body["reason"] = input.reason;
}
const { data } = await this.post<RefundEnvelope>("/refunds", body);
return mapRefund(data.refund);
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<PaymentEnvelope>({
method: "GET",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: this.name,
});
return mapPayment(data.payment);
}
private async post<T>(
path: string,
body: Record<string, unknown> | undefined
): Promise<{ readonly data: T }> {
return httpRequest<T>({
method: "POST",
url: `${this.baseUrl}${path}`,
headers: this.headers(),
body,
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: this.name,
});
}
private headers(): Record<string, string> {
return {
Authorization: `Bearer ${this.apiKey}`,
"Square-Version": this.apiVersion,
};
}
}

View File

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

View File

@@ -0,0 +1,51 @@
import type { ApiKeyConfig } from "@suaveplan/payment-providers";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
SQUARE_DEFAULT_API_VERSION,
SQUARE_DEFAULT_BASE_URL,
SQUARE_SANDBOX_BASE_URL,
type SquareConfig,
} from "./config.js";
describe("SquareConfig", () => {
it("SquareConfig extends ApiKeyConfig", { timeout: 5000 }, () => {
expectTypeOf<SquareConfig>().toMatchTypeOf<ApiKeyConfig>();
});
it("locationId is a required field at the type level", {
timeout: 5000,
}, () => {
type WithoutLocation = Omit<SquareConfig, "locationId">;
expectTypeOf<WithoutLocation>().not.toMatchTypeOf<SquareConfig>();
expectTypeOf<SquareConfig>()
.toHaveProperty("locationId")
.toEqualTypeOf<string>();
});
it("exposes sandbox / apiVersion / randomId as optional", {
timeout: 5000,
}, () => {
const minimal: SquareConfig = {
apiKey: "sq_test",
locationId: "L1",
};
expect(minimal.sandbox).toBeUndefined();
expect(minimal.apiVersion).toBeUndefined();
expect(minimal.randomId).toBeUndefined();
});
});
describe("Square default constants", () => {
it("exports the documented default base URLs", { timeout: 5000 }, () => {
expect(SQUARE_DEFAULT_BASE_URL).toBe("https://connect.squareup.com/v2");
expect(SQUARE_SANDBOX_BASE_URL).toBe(
"https://connect.squareupsandbox.com/v2"
);
});
it("pins the default Square-Version to 2024-01-18", {
timeout: 5000,
}, () => {
expect(SQUARE_DEFAULT_API_VERSION).toBe("2024-01-18");
});
});

View File

@@ -0,0 +1,58 @@
import type { ApiKeyConfig, RandomIdFn } from "@suaveplan/payment-providers";
/**
* Default production base URL for the Square Payments API. Used when
* neither `baseUrl` nor `sandbox: true` is supplied on the
* `SquareConfig`.
*/
export const SQUARE_DEFAULT_BASE_URL = "https://connect.squareup.com/v2";
/**
* Default Square sandbox base URL. Used when `sandbox: true` is set on
* the `SquareConfig` and no explicit `baseUrl` overrides it.
*/
export const SQUARE_SANDBOX_BASE_URL = "https://connect.squareupsandbox.com/v2";
/**
* Default `Square-Version` header value. Square requires this header
* on every request. Pin a newer value via `SquareConfig.apiVersion` to
* opt into a more recent API surface.
*/
export const SQUARE_DEFAULT_API_VERSION = "2024-01-18";
/**
* Configuration object for {@link SquareProvider}. Extends the shared
* `ApiKeyConfig` from `@suaveplan/payment-providers` with the
* Square-specific fields the Payments API requires (the
* `location_id` carried on every `CreatePayment` body) and convenience
* toggles (`sandbox` for the sandbox base URL flip, `apiVersion` for
* pinning the `Square-Version` header, `randomId` for deterministic
* idempotency-key generation in tests).
*/
export interface SquareConfig extends ApiKeyConfig {
/**
* Square `location_id` carried on every `CreatePayment` body.
* Required because Square's `CreatePayment` endpoint rejects
* single-location assumptions for multi-location merchants.
*/
readonly locationId: string;
/**
* When `true`, requests target {@link SQUARE_SANDBOX_BASE_URL}
* instead of {@link SQUARE_DEFAULT_BASE_URL}. An explicit `baseUrl`
* on the parent `ApiKeyConfig` always wins over this flag.
*/
readonly sandbox?: boolean | undefined;
/**
* Value of the `Square-Version` header sent on every request.
* Defaults to {@link SQUARE_DEFAULT_API_VERSION}.
*/
readonly apiVersion?: string | undefined;
/**
* Injectable id generator used to fill missing
* `CreatePayment.idempotency_key` / `RefundPayment.idempotency_key`
* fields. Defaults to `() => crypto.randomUUID()` at call time so
* runtimes without a global `crypto` can inject their own. Tests
* pass a deterministic counter.
*/
readonly randomId?: RandomIdFn | undefined;
}

View File

@@ -0,0 +1,6 @@
export {
SQUARE_DEFAULT_API_VERSION,
SQUARE_DEFAULT_BASE_URL,
SQUARE_SANDBOX_BASE_URL,
type SquareConfig,
} from "./config.js";

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(__dirname, "..", "src");
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
timeout: 5000,
}, () => {
const banned =
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
if (banned.test(text)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,5 @@
// Polyfills for the payment-provider-square test environment — none required.
// Every test injects its own FetchLike + randomId; no platform globals are
// touched. This file exists to mirror the sibling-package layout and to give
// vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-square",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,57 @@
{
"name": "@suaveplan/payment-provider-stripe",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./stripe/stripe": {
"import": { "types": "./dist/stripe/stripe.d.ts", "default": "./dist/stripe/stripe.js" },
"require": { "types": "./dist/stripe/stripe.d.ts", "default": "./dist/stripe/stripe.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,6 @@
export { StripeProvider } from "./stripe/stripe.js";
export type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe/stripe.types.js";

View File

@@ -0,0 +1,6 @@
export { StripeProvider } from "./stripe.js";
export type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe.types.js";

View File

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

View File

@@ -0,0 +1,621 @@
import type { FetchLike } from "@suaveplan/payment-providers";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StripeProvider } from "./stripe.js";
import type { StripeIntent, StripeRefund } from "./stripe.types.js";
interface Recorded {
readonly url: string;
readonly init?: RequestInit;
}
function recorder(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; calls: Recorded[] } {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const BASE_INTENT: StripeIntent = {
id: "pi_123",
status: "requires_confirmation",
amount: 5000,
amount_received: 0,
amount_capturable: 5000,
currency: "usd",
capture_method: "manual",
customer: "cust_1",
description: "Test",
payment_method: {
id: "pm_1",
type: "card",
card: { brand: "visa", last4: "4242" },
},
metadata: { key: "val" },
created: 1_700_000_000,
};
const BASE_REFUND: StripeRefund = {
id: "ref_123",
payment_intent: "pi_123",
amount: 2000,
currency: "usd",
status: "succeeded",
reason: "requested_by_customer",
created: 1_700_000_100,
metadata: {},
};
function makeProvider(fetchImpl: FetchLike, apiKey = "sk_test_123") {
return new StripeProvider({
apiKey,
baseUrl: "https://stripe.mock/v1",
fetch: fetchImpl,
});
}
function headersOf(init: RequestInit | undefined): Record<string, string> {
return (init?.headers ?? {}) as Record<string, string>;
}
describe("StripeProvider — identity + base URL", () => {
it("exposes literal name 'stripe'", { timeout: 5000 }, () => {
const { fetch } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
expect(provider.name).toBe("stripe");
});
it("defaults baseUrl to api.stripe.com/v1 when omitted", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = new StripeProvider({
apiKey: "sk_test_x",
fetch,
});
await provider.getIntent("pi_123");
expect(calls[0]?.url.startsWith("https://api.stripe.com/v1")).toBe(
true
);
});
});
describe("StripeProvider — URL + verb routing", () => {
it("createIntent POSTs to /payment_intents with Stripe-cased body fields", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 5000n, currency: "USD" },
customerId: "cust_1",
description: "Test",
metadata: { k: "v" },
idempotencyKey: "idem_1",
captureMethod: "manual",
paymentMethodId: "pm_1",
});
const recorded = calls[0];
expect(recorded?.init?.method).toBe("POST");
expect(recorded?.url.endsWith("/payment_intents")).toBe(true);
const body = JSON.parse(String(recorded?.init?.body));
expect(body).toEqual({
amount: 5000,
currency: "usd",
capture_method: "manual",
customer: "cust_1",
description: "Test",
metadata: { k: "v" },
payment_method: "pm_1",
});
});
it("createIntent defaults capture_method to 'automatic' and omits optional fields when absent", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 100n, currency: "EUR" },
});
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({
amount: 100,
currency: "eur",
capture_method: "automatic",
});
});
it("confirmIntent POSTs to /payment_intents/:id/confirm", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "requires_capture" })
);
const provider = makeProvider(fetch);
const result = await provider.confirmIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/confirm")).toBe(
true
);
expect(result.status).toBe("authorized");
});
it("captureIntent POSTs to /payment_intents/:id/capture", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({
...BASE_INTENT,
status: "succeeded",
amount_received: 5000,
})
);
const provider = makeProvider(fetch);
const result = await provider.captureIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/capture")).toBe(
true
);
expect(result.status).toBe("captured");
expect(result.capturedAmount.amount).toBe(5000n);
});
it("cancelIntent POSTs to /payment_intents/:id/cancel", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "canceled" })
);
const provider = makeProvider(fetch);
const result = await provider.cancelIntent("pi_123");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123/cancel")).toBe(
true
);
expect(result.status).toBe("canceled");
});
it("refund POSTs to /refunds with snake_case body", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
const result = await provider.refund({
paymentIntentId: "pi_123",
amount: { amount: 2000n, currency: "USD" },
reason: "requested_by_customer",
metadata: { tag: "x" },
});
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.url.endsWith("/refunds")).toBe(true);
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({
payment_intent: "pi_123",
amount: 2000,
reason: "requested_by_customer",
metadata: { tag: "x" },
});
expect(result.id).toBe("ref_123");
expect(result.status).toBe("succeeded");
expect(result.amount.amount).toBe(2000n);
});
it("refund omits optional fields when caller does not supply them", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({ paymentIntentId: "pi_only" });
const body = JSON.parse(String(calls[0]?.init?.body));
expect(body).toEqual({ payment_intent: "pi_only" });
});
it("getIntent GETs /payment_intents/:id", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(calls[0]?.init?.method).toBe("GET");
expect(calls[0]?.url.endsWith("/payment_intents/pi_123")).toBe(true);
expect(result.id).toBe("pi_123");
});
it("surfaces non-ok responses as PaymentProviderError with provider 'stripe'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ message: "Server down" }, 500)
);
const provider = makeProvider(fetch);
await expect(provider.getIntent("pi_x")).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
provider: "stripe",
httpStatus: 500,
});
});
});
describe("StripeProvider — auth + idempotency headers", () => {
it("includes Bearer Authorization header on every method", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder((url) => {
if (url.endsWith("/refunds")) {
return jsonResponse(BASE_REFUND);
}
return jsonResponse(BASE_INTENT);
});
const provider = makeProvider(fetch, "sk_live_42");
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
});
await provider.confirmIntent("pi_123");
await provider.captureIntent("pi_123");
await provider.cancelIntent("pi_123");
await provider.getIntent("pi_123");
await provider.refund({ paymentIntentId: "pi_123" });
for (const call of calls) {
expect(headersOf(call.init).Authorization).toBe(
"Bearer sk_live_42"
);
}
expect(calls).toHaveLength(6);
});
it("forwards Idempotency-Key header on createIntent when supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
idempotencyKey: "idem_1",
});
expect(headersOf(calls[0]?.init)["Idempotency-Key"]).toBe("idem_1");
});
it("omits Idempotency-Key header on createIntent when not supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
await provider.createIntent({
amount: { amount: 1n, currency: "USD" },
});
const headers = headersOf(calls[0]?.init);
expect("Idempotency-Key" in headers).toBe(false);
});
it("forwards Idempotency-Key header on refund when supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({
paymentIntentId: "pi_123",
idempotencyKey: "ref_idem_1",
});
expect(headersOf(calls[0]?.init)["Idempotency-Key"]).toBe("ref_idem_1");
});
it("omits Idempotency-Key header on refund when not supplied", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse(BASE_REFUND));
const provider = makeProvider(fetch);
await provider.refund({ paymentIntentId: "pi_123" });
const headers = headersOf(calls[0]?.init);
expect("Idempotency-Key" in headers).toBe(false);
});
});
describe("StripeProvider — status mapping", () => {
const cases: ReadonlyArray<readonly [string, string]> = [
["requires_payment_method", "created"],
["requires_confirmation", "created"],
["requires_action", "pending"],
["processing", "pending"],
["requires_capture", "authorized"],
["succeeded", "captured"],
["canceled", "canceled"],
];
for (const [stripeStatus, expected] of cases) {
it(`maps Stripe status '${stripeStatus}' to '${expected}'`, {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: stripeStatus })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_test");
expect(result.status).toBe(expected);
});
}
it("maps unknown Stripe status to 'failed'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, status: "weird_unhandled" })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_test");
expect(result.status).toBe("failed");
});
});
describe("StripeProvider — refund status mapping", () => {
it("maps Stripe refund status 'succeeded' to 'succeeded'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "succeeded" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("succeeded");
});
it("maps Stripe refund status 'pending' to 'pending'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "pending" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("pending");
});
it("maps unknown Stripe refund status to 'failed'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, status: "canceled" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.status).toBe("failed");
});
});
describe("StripeProvider — refund reason mapping", () => {
it("maps Stripe refund reason 'duplicate' to 'duplicate'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "duplicate" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("duplicate");
});
it("maps Stripe refund reason 'fraudulent' to 'fraudulent'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "fraudulent" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("fraudulent");
});
it("maps Stripe refund reason 'requested_by_customer' to 'requested_by_customer'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "requested_by_customer" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("requested_by_customer");
});
it("maps unknown Stripe refund reason to 'other'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_REFUND, reason: "something_else" })
);
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("other");
});
it("maps absent Stripe refund reason to 'other'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const r = { ...BASE_REFUND };
delete (r as { reason?: string }).reason;
return jsonResponse(r);
});
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.reason).toBe("other");
});
});
describe("StripeProvider — payment-method mapping", () => {
it("maps card payment_method with brand and last4", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => jsonResponse(BASE_INTENT));
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toEqual({
id: "pm_1",
type: "card",
brand: "visa",
last4: "4242",
});
});
it("maps non-card payment_method type to 'wallet'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({
...BASE_INTENT,
payment_method: { id: "pm_w", type: "us_bank_account" },
})
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toEqual({ id: "pm_w", type: "wallet" });
});
it("maps absent payment_method to undefined", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const i = { ...BASE_INTENT } as Partial<StripeIntent>;
delete i.payment_method;
return jsonResponse(i);
});
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_123");
expect(result.paymentMethod).toBeUndefined();
});
});
describe("StripeProvider — ProviderCommonOptions forwarding", () => {
it("forwards caller-supplied AbortSignal to httpRequest and propagates rejection", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const provider = new StripeProvider({
apiKey: "sk_test_1",
baseUrl: "https://stripe.mock/v1",
fetch,
signal: external.signal,
});
const pending = provider.getIntent("pi_123");
external.abort();
await expect(pending).rejects.toBeInstanceOf(PaymentProviderError);
});
it("forwards caller-supplied timeoutMs to httpRequest", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const provider = new StripeProvider({
apiKey: "sk_test_1",
baseUrl: "https://stripe.mock/v1",
fetch,
timeoutMs: 20,
});
await expect(provider.getIntent("pi_123")).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.TIMEOUT,
provider: "stripe",
});
});
it("does not forward fetch when caller omits it (default-fetch path)", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const recorded: string[] = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async (url: string) => {
recorded.push(url);
return jsonResponse(BASE_INTENT);
},
});
try {
const provider = new StripeProvider({
apiKey: "sk_default_path",
baseUrl: "https://stripe.mock/v1",
});
const result = await provider.getIntent("pi_def");
expect(recorded).toEqual([
"https://stripe.mock/v1/payment_intents/pi_def",
]);
expect(result.id).toBe("pi_123");
} finally {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: original,
});
}
});
});
describe("StripeProvider — mapping branch coverage", () => {
it("maps Stripe capture_method 'automatic' to PaymentIntent captureMethod 'automatic'", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ ...BASE_INTENT, capture_method: "automatic" })
);
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_auto");
expect(result.captureMethod).toBe("automatic");
});
it("maps Stripe intent with absent customer/description/metadata to undefined fields", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const i = {
...BASE_INTENT,
} as Partial<StripeIntent> & { id: string };
delete i.customer;
delete i.description;
delete i.metadata;
return jsonResponse(i);
});
const provider = makeProvider(fetch);
const result = await provider.getIntent("pi_min");
expect(result.customerId).toBeUndefined();
expect(result.description).toBeUndefined();
expect(result.metadata).toBeUndefined();
});
it("maps Stripe refund with absent metadata to undefined metadata", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const r = {
...BASE_REFUND,
} as Partial<StripeRefund> & { id: string };
delete r.metadata;
return jsonResponse(r);
});
const provider = makeProvider(fetch);
const result = await provider.refund({ paymentIntentId: "pi_123" });
expect(result.metadata).toBeUndefined();
});
});

View File

@@ -0,0 +1,277 @@
import type {
CardBrand,
CreatePaymentIntentInput,
PaymentIntent,
PaymentMethod,
PaymentMethodType,
PaymentProvider,
PaymentStatus,
Refund,
RefundInput,
RefundReason,
RefundStatus,
} from "@suaveplan/payment";
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
import type {
StripeConfig,
StripeIntent,
StripeRefund,
} from "./stripe.types.js";
const PROVIDER_NAME = "stripe" as const;
const DEFAULT_BASE_URL = "https://api.stripe.com/v1";
const STATUS_MAP: Readonly<Record<string, PaymentStatus>> = Object.freeze({
requires_payment_method: "created",
requires_confirmation: "created",
requires_action: "pending",
processing: "pending",
requires_capture: "authorized",
succeeded: "captured",
canceled: "canceled",
});
function mapStatus(stripeStatus: string): PaymentStatus {
return STATUS_MAP[stripeStatus] ?? "failed";
}
function mapRefundStatus(status: string): RefundStatus {
if (status === "succeeded") {
return "succeeded";
}
if (status === "pending") {
return "pending";
}
return "failed";
}
function mapRefundReason(reason: string | undefined): RefundReason {
if (reason === "duplicate") {
return "duplicate";
}
if (reason === "fraudulent") {
return "fraudulent";
}
if (reason === "requested_by_customer") {
return "requested_by_customer";
}
return "other";
}
function mapPaymentMethod(
pm: StripeIntent["payment_method"]
): PaymentMethod | undefined {
if (pm === undefined) {
return undefined;
}
const type: PaymentMethodType = pm.type === "card" ? "card" : "wallet";
if (pm.card !== undefined) {
return {
id: pm.id,
type,
brand: pm.card.brand as CardBrand,
last4: pm.card.last4,
};
}
return { id: pm.id, type };
}
function mapIntent(s: StripeIntent): PaymentIntent {
const currency = s.currency.toUpperCase();
const base = {
id: s.id,
status: mapStatus(s.status),
amount: { amount: BigInt(s.amount), currency },
capturedAmount: { amount: BigInt(s.amount_received), currency },
refundedAmount: { amount: 0n, currency },
captureMethod:
s.capture_method === "manual"
? ("manual" as const)
: ("automatic" as const),
refunds: [] as const,
createdAt: new Date(s.created * 1000).toISOString(),
updatedAt: new Date().toISOString(),
};
const paymentMethod = mapPaymentMethod(s.payment_method);
return {
...base,
...(s.customer !== undefined ? { customerId: s.customer } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
...(paymentMethod !== undefined ? { paymentMethod } : {}),
...(s.metadata !== undefined ? { metadata: s.metadata } : {}),
};
}
function mapRefund(r: StripeRefund): Refund {
const base = {
id: r.id,
paymentIntentId: r.payment_intent,
amount: {
amount: BigInt(r.amount),
currency: r.currency.toUpperCase(),
},
status: mapRefundStatus(r.status),
reason: mapRefundReason(r.reason),
createdAt: new Date(r.created * 1000).toISOString(),
};
return r.metadata !== undefined ? { ...base, metadata: r.metadata } : base;
}
interface CommonOptions {
readonly provider: typeof PROVIDER_NAME;
readonly fetch?: FetchLike;
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
}
/**
* Stripe payment provider implementing the `PaymentProvider` contract
* from `@suaveplan/payment`. Every HTTP call routes through the slim
* `@suaveplan/payment-providers` foundation (`httpRequest` +
* `FetchLike` + composed-`AbortSignal` semantics). Bearer auth is
* supplied on every request; the caller-supplied `Idempotency-Key`
* (when present on `CreatePaymentIntentInput` or `RefundInput`) is
* forwarded as the matching Stripe header on mutating calls.
*
* The provider holds no in-flight state: each method maps its input
* into Stripe's snake-cased wire format, issues a single
* `httpRequest`, and maps the response back to the domain
* `PaymentIntent` / `Refund` shapes. Errors raised by `httpRequest`
* (`HTTP_ERROR` / `TIMEOUT` / `PARSE_ERROR`) carry
* `provider: "stripe"` so consumers can discriminate Stripe failures
* from other registered providers without parsing messages.
*/
export class StripeProvider implements PaymentProvider {
public readonly name = PROVIDER_NAME;
private readonly config: StripeConfig;
private readonly baseUrl: string;
public constructor(config: StripeConfig) {
this.config = config;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const body: Record<string, unknown> = {
amount: Number(input.amount.amount),
currency: input.amount.currency.toLowerCase(),
capture_method: input.captureMethod ?? "automatic",
};
if (input.customerId !== undefined) {
body["customer"] = input.customerId;
}
if (input.description !== undefined) {
body["description"] = input.description;
}
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
if (input.paymentMethodId !== undefined) {
body["payment_method"] = input.paymentMethodId;
}
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents`,
headers: this.headers(input.idempotencyKey),
body,
...this.commonOptions(),
});
return mapIntent(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/confirm`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/capture`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "POST",
url: `${this.baseUrl}/payment_intents/${intentId}/cancel`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {
payment_intent: input.paymentIntentId,
};
if (input.amount !== undefined) {
body["amount"] = Number(input.amount.amount);
}
if (input.reason !== undefined) {
body["reason"] = input.reason;
}
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
const { data } = await httpRequest<StripeRefund>({
method: "POST",
url: `${this.baseUrl}/refunds`,
headers: this.headers(input.idempotencyKey),
body,
...this.commonOptions(),
});
return mapRefund(data);
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<StripeIntent>({
method: "GET",
url: `${this.baseUrl}/payment_intents/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapIntent(data);
}
private headers(idempotencyKey?: string): Record<string, string> {
const h: Record<string, string> = {
Authorization: `Bearer ${this.config.apiKey}`,
};
if (idempotencyKey !== undefined) {
h["Idempotency-Key"] = idempotencyKey;
}
return h;
}
private commonOptions(): CommonOptions {
const opts: {
provider: typeof PROVIDER_NAME;
fetch?: FetchLike;
signal?: AbortSignal;
timeoutMs?: number;
} = { provider: PROVIDER_NAME };
if (this.config.fetch !== undefined) {
opts.fetch = this.config.fetch;
}
if (this.config.signal !== undefined) {
opts.signal = this.config.signal;
}
if (this.config.timeoutMs !== undefined) {
opts.timeoutMs = this.config.timeoutMs;
}
return opts;
}
}

View File

@@ -0,0 +1,52 @@
import type { ApiKeyConfig } from "@suaveplan/payment-providers";
/**
* Construction shape for {@link StripeProvider}. Re-uses the shared
* `ApiKeyConfig` from the slim foundation verbatim — the Stripe
* provider has no Stripe-specific configuration beyond the standard
* API-key + base-URL + webhook-secret + common HTTP seams. Re-exported
* as a named type so consumers can pin construction without reaching
* into `@suaveplan/payment-providers` for `ApiKeyConfig`.
*/
export type StripeConfig = ApiKeyConfig;
/**
* Wire shape of a Stripe payment-intent response, restricted to the
* fields {@link StripeProvider} reads. Stripe returns many more fields
* but the provider only maps these. Kept as an `interface` rather than
* a `type` so consumers extending the wire format for analytics
* purposes can merge declarations downstream.
*/
export interface StripeIntent {
readonly id: string;
readonly status: string;
readonly amount: number;
readonly amount_received: number;
readonly amount_capturable: number;
readonly currency: string;
readonly capture_method: string;
readonly customer?: string;
readonly description?: string;
readonly payment_method?: {
readonly id: string;
readonly type: string;
readonly card?: { readonly brand: string; readonly last4: string };
};
readonly metadata?: Record<string, string>;
readonly created: number;
}
/**
* Wire shape of a Stripe refund response, restricted to the fields
* {@link StripeProvider} reads.
*/
export interface StripeRefund {
readonly id: string;
readonly payment_intent: string;
readonly amount: number;
readonly currency: string;
readonly status: string;
readonly reason?: string;
readonly created: number;
readonly metadata?: Record<string, string>;
}

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(__dirname, "..", "src");
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
timeout: 5000,
}, () => {
const banned =
/from\s+['"](node:|bun:|fs|path|stream|net|http|crypto)['"]/;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
if (banned.test(text)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,5 @@
// Polyfills for the payment-provider-stripe test environment — none required.
// Every test uses an injectable FetchLike from the slim foundation, so the
// suite never reaches into a platform fetch. This file exists to mirror the
// sibling-package layout and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-stripe",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

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

View File

@@ -0,0 +1,69 @@
{
"name": "@suaveplan/payment-providers",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types/provider": {
"import": { "types": "./dist/types/provider.d.ts", "default": "./dist/types/provider.js" },
"require": { "types": "./dist/types/provider.d.ts", "default": "./dist/types/provider.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" },
"require": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./http/http": {
"import": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" },
"require": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" }
},
"./registry/registry": {
"import": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" },
"require": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
httpError,
oauthTokenFailure,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
parseError,
providerAlreadyRegistered,
providerNotRegistered,
timeoutError,
} from "./error-codes.js";
describe("PAYMENT_PROVIDER_CODES registry", () => {
it("every PAYMENT_PROVIDER.* code is reachable from a factory", {
timeout: 5000,
}, () => {
const codes = new Set<string>([
httpError("stripe", 500, "https://x", "boom").code,
timeoutError("paypal", "https://x", 1000).code,
parseError("stripe", "https://x", "not-json", new Error("bad"))
.code,
oauthTokenFailure("paypal", "https://x", new Error("nope")).code,
providerNotRegistered("nope").code,
providerAlreadyRegistered("stripe").code,
]);
expect(codes).toEqual(
new Set<string>([
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
PAYMENT_PROVIDER_CODES.TIMEOUT,
PAYMENT_PROVIDER_CODES.PARSE_ERROR,
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
])
);
});
it("providerNotRegistered carries the name as provider discriminant", {
timeout: 5000,
}, () => {
const err = providerNotRegistered("nope");
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.code).toBe(PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED);
expect(err.provider).toBe("nope");
expect(err.message).toContain("nope");
});
it("providerAlreadyRegistered carries the name as provider discriminant", {
timeout: 5000,
}, () => {
const err = providerAlreadyRegistered("stripe");
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.code).toBe(
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED
);
expect(err.provider).toBe("stripe");
expect(err.message).toContain("stripe");
});
it("httpError carries provider/url/httpStatus/bodyExcerpt discriminants", {
timeout: 5000,
}, () => {
const err = httpError(
"stripe",
404,
"https://api.stripe.com/v1/payment_intents",
"a".repeat(300)
);
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.provider).toBe("stripe");
expect(err.httpStatus).toBe(404);
expect(err.url).toBe("https://api.stripe.com/v1/payment_intents");
expect(err.bodyExcerpt?.endsWith("…")).toBe(true);
expect((err.bodyExcerpt ?? "").length).toBe(257);
});
it("preserves cause through parseError", { timeout: 5000 }, () => {
const cause = new Error("malformed JSON");
const err = parseError("stripe", "https://x", "not-json", cause);
expect(err.cause).toBe(cause);
expect(err.provider).toBe("stripe");
});
it("preserves cause through oauthTokenFailure", { timeout: 5000 }, () => {
const cause = new Error("invalid_client");
const err = oauthTokenFailure("paypal", "https://x/oauth", cause);
expect(err.cause).toBe(cause);
});
it("timeoutError carries provider and url discriminants", {
timeout: 5000,
}, () => {
const err = timeoutError("paypal", "https://x", 50);
expect(err.provider).toBe("paypal");
expect(err.url).toBe("https://x");
expect(err.code).toBe(PAYMENT_PROVIDER_CODES.TIMEOUT);
});
it("httpError respects a custom message override", {
timeout: 5000,
}, () => {
const err = httpError(
"adyen",
500,
"https://x",
"server down",
"explicit message"
);
expect(err.message).toBe("explicit message");
});
it("httpError leaves bodyExcerpt untruncated when short", {
timeout: 5000,
}, () => {
const err = httpError("stripe", 500, "https://x", "short body");
expect(err.bodyExcerpt).toBe("short body");
});
it("PaymentProviderError tolerates zero discriminants", {
timeout: 5000,
}, () => {
const err = new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
"no extras"
);
expect(err.provider).toBeUndefined();
expect(err.httpStatus).toBeUndefined();
expect(err.url).toBeUndefined();
expect(err.bodyExcerpt).toBeUndefined();
});
});

View File

@@ -0,0 +1,162 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/payment-providers`. Every
* production failure path (network error, timeout, JSON parse failure,
* OAuth token failure) raises a `PaymentProviderError` carrying one of
* these namespaced codes.
*/
export const PAYMENT_PROVIDER_CODES = defineCodes("PAYMENT_PROVIDER", [
"HTTP_ERROR",
"TIMEOUT",
"PARSE_ERROR",
"OAUTH_TOKEN_FAILURE",
"PROVIDER_NOT_REGISTERED",
"PROVIDER_ALREADY_REGISTERED",
] as const);
export type PaymentProviderErrorCode =
(typeof PAYMENT_PROVIDER_CODES)[keyof typeof PAYMENT_PROVIDER_CODES];
/** Discriminant context carried on a `PaymentProviderError`. */
export interface PaymentProviderErrorOptions extends SuaveplanErrorOptions {
/** Provider name (`"stripe"`, `"paypal"`, etc.) when known. */
readonly provider?: string;
/** HTTP status code, when the failure originated from a response. */
readonly httpStatus?: number;
/** Request URL, when the failure originated from a fetch call. */
readonly url?: string;
/** Response body excerpt, capped at 256 characters. */
readonly bodyExcerpt?: string;
}
const BODY_EXCERPT_MAX = 256;
/**
* Discriminated error class for `@suaveplan/payment-providers`. Extends
* `SuaveplanError` so `instanceof` narrows `code` to
* `PaymentProviderErrorCode`; carries optional `provider` /
* `httpStatus` / `url` / `bodyExcerpt` diagnostics.
*/
export class PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode> {
public readonly provider?: string;
public readonly httpStatus?: number;
public readonly url?: string;
public readonly bodyExcerpt?: string;
public constructor(
code: PaymentProviderErrorCode,
message: string,
options: PaymentProviderErrorOptions = {}
) {
super(code, message, options);
if (options.provider !== undefined) {
this.provider = options.provider;
}
if (options.httpStatus !== undefined) {
this.httpStatus = options.httpStatus;
}
if (options.url !== undefined) {
this.url = options.url;
}
if (options.bodyExcerpt !== undefined) {
this.bodyExcerpt = options.bodyExcerpt;
}
}
}
function truncate(input: string): string {
if (input.length <= BODY_EXCERPT_MAX) {
return input;
}
return `${input.slice(0, BODY_EXCERPT_MAX)}…`;
}
/** Factory for `PAYMENT_PROVIDER.HTTP_ERROR` — non-ok HTTP response. */
export function httpError(
provider: string,
httpStatus: number,
url: string,
body: string,
message?: string
): PaymentProviderError {
const excerpt = truncate(body);
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
message ?? `HTTP ${httpStatus} from ${provider}`,
{ provider, httpStatus, url, bodyExcerpt: excerpt }
);
}
/** Factory for `PAYMENT_PROVIDER.TIMEOUT` — request aborted by timeout. */
export function timeoutError(
provider: string,
url: string,
timeoutMs: number,
cause?: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.TIMEOUT,
`Request to ${provider} timed out after ${timeoutMs}ms`,
{ provider, url, cause }
);
}
/** Factory for `PAYMENT_PROVIDER.PARSE_ERROR` — JSON parse failure. */
export function parseError(
provider: string,
url: string,
body: string,
cause: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PARSE_ERROR,
`Failed to parse response from ${provider}`,
{ provider, url, bodyExcerpt: truncate(body), cause }
);
}
/** Factory for `PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE` — OAuth call failed. */
export function oauthTokenFailure(
provider: string,
url: string,
cause: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
`Failed to acquire OAuth token from ${provider}`,
{ provider, url, cause }
);
}
/**
* Factory for `PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED` — raised by
* `ProviderRegistry.get` when the requested provider name has not been
* registered. The offending name is carried on the `provider`
* discriminant so consumers handling the error can surface a useful
* diagnostic without re-parsing the message.
*/
export function providerNotRegistered(name: string): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
`Payment provider "${name}" is not registered`,
{ provider: name }
);
}
/**
* Factory for `PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED` — raised
* by `ProviderRegistry.register` when the supplied provider's `name`
* collides with an existing registration. The offending name is
* carried on the `provider` discriminant.
*/
export function providerAlreadyRegistered(name: string): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
`Payment provider "${name}" is already registered`,
{ provider: name }
);
}

View File

@@ -0,0 +1,14 @@
export type {
PaymentProviderErrorCode,
PaymentProviderErrorOptions,
} from "./error-codes.js";
export {
httpError,
oauthTokenFailure,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
parseError,
providerAlreadyRegistered,
providerNotRegistered,
timeoutError,
} from "./error-codes.js";

View File

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

View File

@@ -0,0 +1,385 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "../error-codes/error-codes.js";
import { composeAbortSignal, type FetchLike, httpRequest } from "./http.js";
interface Recorded {
readonly url: string;
readonly init?: RequestInit;
}
function recorder(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; calls: Recorded[] } {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("httpRequest", () => {
it("makes a GET request and parses JSON", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
const result = await httpRequest<{ ok: boolean }>({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "test",
});
expect(result.status).toBe(200);
expect(result.data).toEqual({ ok: true });
expect(calls).toHaveLength(1);
expect(calls[0]?.init?.method).toBe("GET");
});
it("sends JSON body on POST", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ id: "1" }, 201));
const result = await httpRequest({
method: "POST",
url: "https://api.test/v1",
body: { amount: 100 },
fetch,
});
expect(result.data).toEqual({ id: "1" });
expect(calls[0]?.init?.body).toBe(JSON.stringify({ amount: 100 }));
});
it("includes custom headers", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({}));
await httpRequest({
method: "GET",
url: "https://api.test/v1",
headers: { Authorization: "Bearer tok" },
fetch,
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer tok");
expect(headers["Content-Type"]).toBe("application/json");
});
it("throws PaymentProviderError on non-ok response", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ message: "Not Found" }, 404)
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "stripe",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
httpStatus: 404,
provider: "stripe",
message: "Not Found",
});
});
it("throws HTTP_ERROR when no message in body", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => jsonResponse({ error: true }, 500));
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
httpStatus: 500,
});
});
it("handles empty response body", { timeout: 5000 }, async () => {
const { fetch } = recorder(() => new Response(null, { status: 204 }));
const result = await httpRequest({
method: "DELETE",
url: "https://api.test/v1",
fetch,
});
expect(result.status).toBe(204);
expect(result.data).toEqual({});
});
it("throws on non-ok empty response", { timeout: 5000 }, async () => {
const { fetch } = recorder(() => new Response("", { status: 401 }));
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toBeInstanceOf(PaymentProviderError);
});
it("throws TIMEOUT when AbortSignal fires", { timeout: 5000 }, async () => {
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 25,
fetch,
provider: "paypal",
})
).rejects.toMatchObject({ code: PAYMENT_PROVIDER_CODES.TIMEOUT });
});
it("supports DELETE and PATCH methods", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
await httpRequest({
method: "DELETE",
url: "https://api.test/v1/x",
fetch,
});
await httpRequest({
method: "PATCH",
url: "https://api.test/v1/x",
body: { a: 1 },
fetch,
});
expect(calls[0]?.init?.method).toBe("DELETE");
expect(calls[1]?.init?.method).toBe("PATCH");
});
it("passes through a string body without re-serialising as JSON", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
const raw = "grant_type=client_credentials";
await httpRequest({
method: "POST",
url: "https://api.test/v1/oauth",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: raw,
fetch,
});
// String bodies must pass through verbatim — not JSON.stringify'd.
expect(calls[0]?.init?.body).toBe(raw);
});
it("composes caller AbortSignal with internal timeout", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const pending = httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 5000,
signal: external.signal,
fetch,
});
external.abort();
await expect(pending).rejects.toBeDefined();
});
it("surfaces non-AbortError network failures as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
throw new Error("ECONNRESET");
});
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "mollie",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
provider: "mollie",
});
});
it("surfaces non-Error network failures as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const failure: unknown = "string failure";
throw failure;
});
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
});
});
it("rethrows PaymentProviderError raised inside fetchImpl unchanged", {
timeout: 5000,
}, async () => {
const inner = new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
"inner",
{ provider: "stripe" }
);
const { fetch } = recorder(() => {
throw inner;
});
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toBe(inner);
});
it("throws PARSE_ERROR on malformed JSON", { timeout: 5000 }, async () => {
const { fetch } = recorder(
() => new Response("{not-json", { status: 200 })
);
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toMatchObject({ code: PAYMENT_PROVIDER_CODES.PARSE_ERROR });
});
it("defaults to globalThis.fetch when none injected", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: string[] = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async (url: string) => {
calls.push(url);
return jsonResponse({ ok: true });
},
});
try {
const result = await httpRequest({
method: "GET",
url: "https://default.test/v1",
});
expect(result.data).toEqual({ ok: true });
expect(calls).toEqual(["https://default.test/v1"]);
} finally {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: original,
});
}
});
it("internal timeout aborts the composed signal when external signal is present but never fires", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 25,
signal: external.signal,
fetch,
provider: "stripe",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.TIMEOUT,
});
});
it("honours an already-aborted external signal", {
timeout: 5000,
}, async () => {
const external = new AbortController();
external.abort();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
if (init?.signal?.aborted === true) {
reject(new DOMException("aborted", "AbortError"));
return;
}
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
signal: external.signal,
fetch,
})
).rejects.toBeDefined();
});
});
describe("composeAbortSignal", () => {
it("composeAbortSignal returns internal when external is undefined", {
timeout: 5000,
}, () => {
const internal = new AbortController().signal;
const out = composeAbortSignal(internal, undefined);
expect(out).toBe(internal);
});
it("composeAbortSignal propagates internal abort", {
timeout: 5000,
}, async () => {
const internal = new AbortController();
const external = new AbortController();
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(false);
internal.abort("boom-internal");
// Event loop turn — AbortController dispatches synchronously, but
// we await to be defensive across runtimes.
await Promise.resolve();
expect(out.aborted).toBe(true);
});
it("composeAbortSignal propagates external abort", {
timeout: 5000,
}, async () => {
const internal = new AbortController();
const external = new AbortController();
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(false);
external.abort("boom-external");
await Promise.resolve();
expect(out.aborted).toBe(true);
});
it("composeAbortSignal aborts immediately when external is pre-aborted", {
timeout: 5000,
}, () => {
const internal = new AbortController();
const external = new AbortController();
external.abort("pre-aborted");
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,152 @@
import {
httpError,
PaymentProviderError,
parseError,
timeoutError,
} from "../error-codes/error-codes.js";
import type { HttpOptions, ProviderResponse } from "../types/provider.js";
/**
* Subset of the global `fetch` signature the package uses. Every provider
* accepts a `FetchLike` config field so tests inject a recording adapter
* instead of monkey-patching `globalThis.fetch`. Defaults to
* `globalThis.fetch` when omitted.
*/
export type FetchLike = (
input: string,
init?: RequestInit
) => Promise<Response>;
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_PROVIDER = "unknown";
function defaultFetch(): FetchLike {
return globalThis.fetch.bind(globalThis);
}
/**
* Compose two `AbortSignal`s into one. When `external` is `undefined`
* the function returns `internal` directly (reference-equal). Otherwise
* it returns a fresh signal that aborts as soon as either input aborts,
* propagating the firing input's `reason`. If `external` is already
* aborted at call time the composed signal is aborted before the
* function returns. Sibling provider packages reuse this helper to
* compose their out-of-band HTTP calls (OAuth token fetch, webhook
* fetch, etc.) with caller-supplied cancellation tokens without
* re-implementing the wiring.
*/
export function composeAbortSignal(
internal: AbortSignal,
external: AbortSignal | undefined
): AbortSignal {
if (external === undefined) {
return internal;
}
const controller = new AbortController();
const onAbort = (signal: AbortSignal): void => {
// AbortController.abort() is idempotent — subsequent calls are no-ops.
controller.abort(signal.reason);
};
internal.addEventListener("abort", () => onAbort(internal), { once: true });
if (external.aborted) {
controller.abort(external.reason);
} else {
external.addEventListener("abort", () => onAbort(external), {
once: true,
});
}
return controller.signal;
}
function isTimeoutAbort(err: unknown, timeoutSignal: AbortSignal): boolean {
if (!timeoutSignal.aborted) {
return false;
}
return err instanceof Error && err.name === "AbortError";
}
/**
* Universal HTTP helper wrapping an injectable `FetchLike`. Serialises
* JSON bodies, enforces a per-call timeout via `AbortSignal`, composes
* a caller-supplied `signal` with the internal timeout signal, and
* raises typed `PaymentProviderError`s on failure.
*/
export async function httpRequest<T = unknown>(
options: HttpOptions
): Promise<ProviderResponse<T>> {
const {
method,
url,
headers = {},
body,
timeoutMs = DEFAULT_TIMEOUT_MS,
fetch: fetchImpl = defaultFetch(),
signal: external,
provider = DEFAULT_PROVIDER,
} = options;
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
const signal = composeAbortSignal(timeoutController.signal, external);
const init: RequestInit = {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
signal,
};
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
}
try {
let response: Response;
try {
response = await fetchImpl(url, init);
} catch (err) {
if (isTimeoutAbort(err, timeoutController.signal)) {
throw timeoutError(provider, url, timeoutMs, err);
}
if (err instanceof PaymentProviderError) {
throw err;
}
throw httpError(
provider,
0,
url,
err instanceof Error ? err.message : String(err),
err instanceof Error ? err.message : undefined
);
}
const text = await response.text();
let data: T;
if (text === "") {
data = {} as T;
} else {
try {
data = JSON.parse(text) as T;
} catch (parseErr) {
throw parseError(provider, url, text, parseErr);
}
}
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String((data as Record<string, unknown>)["message"])
: `HTTP ${response.status} from ${provider}`;
throw httpError(provider, response.status, url, text, message);
}
return { status: response.status, data };
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,5 @@
export type { FetchLike } from "./http.js";
export {
composeAbortSignal,
httpRequest,
} from "./http.js";

Some files were not shown because too many files have changed in this diff Show More