Initial Commit
This commit is contained in:
19
packages/money/payment/README.md
Normal file
19
packages/money/payment/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `payment`
|
||||
|
||||
Payment intent lifecycle, provider adapters, and fraud detection.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `payment`
|
||||
|
||||
7 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/payment`](./payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry 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 -->
|
||||
1
packages/money/payment/_intro.md
Normal file
1
packages/money/payment/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Payment intent lifecycle, provider adapters, and fraud detection.
|
||||
94
packages/money/payment/payment-provider-adyen/README.md
Normal file
94
packages/money/payment/payment-provider-adyen/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/payment-provider-adyen - Universal Adyen Checkout API v71 adapter for @suaveplan/payment. Implements the PaymentProvider interface via the slim @suaveplan/payment-providers core (httpRequest + composeAbortSignal + MerchantKeyConfig + PaymentProviderError), forwarding X-API-Key auth on every call and injecting the configured merchantAccount into capture / cancel / refund request bodies. Generates default payment references via an injectable RandomIdFn so the package stays universal and deterministic under test.
|
||||
|
||||
Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`.
|
||||
Implements the `PaymentProvider` interface via the slim
|
||||
`@suaveplan/payment-providers` core (`httpRequest`,
|
||||
`composeAbortSignal`, `MerchantKeyConfig`, `PaymentProviderError`),
|
||||
forwarding `X-API-Key` auth on every call and injecting the configured
|
||||
`merchantAccount` into capture / cancel / refund request bodies.
|
||||
Generates default payment references via an injectable `RandomIdFn`
|
||||
so the package stays universal and deterministic under test.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
|
||||
- [adyen](./src/adyen/adyen.md) — Universal adapter that implements the `PaymentProvider` interface from `@suaveplan/payment` against Adyen's Checko…
|
||||
- [mapping](./src/adyen/mapping.md) — Pure mapping helpers that lift Adyen Checkout API v71 response shapes into the canonical `PaymentIntent`, `PaymentMetho…
|
||||
- [types](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
|
||||
- [adyen](./src/types/adyen.md) — Type alias exported by `@suaveplan/payment-provider-adyen` describing the constructor argument accepted by `AdyenProvid…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-adyen @suaveplan/payment @suaveplan/payment-providers
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(adyen);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("adyen") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5_000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Full `PaymentProvider` surface** — `createIntent` /
|
||||
`confirmIntent` / `captureIntent` / `cancelIntent` / `refund` /
|
||||
`getIntent`.
|
||||
- **`X-API-Key` authentication** with `Idempotency-Key` forwarding on
|
||||
`createIntent` and `refund`.
|
||||
- **`merchantAccount` context** injected into every modification body
|
||||
(`/captures`, `/cancels`, `/refunds`).
|
||||
- **Injectable `RandomIdFn`** for default `reference` generation —
|
||||
with `globalThis.crypto.randomUUID()` fallback at
|
||||
call time.
|
||||
- **Adyen status mapping** for all 10 documented v71 result codes
|
||||
plus `failed` fallback.
|
||||
- **Typed errors only** — every failure surfaces as
|
||||
`PaymentProviderError` (HTTP / timeout / parse) from the slim core.
|
||||
- **Universal tier** — zero `node:` / `bun:` / `fs` / `path` /
|
||||
`stream` / `net` / `http` / `crypto` imports; runs on Node, Bun,
|
||||
browsers, and Web Workers.
|
||||
- **Tree-shakeable** — `sideEffects: false` + per-feature subpath
|
||||
exports.
|
||||
- **100% test coverage** (Istanbul) + 100% docs coverage.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/adyen/adyen.md`](./src/adyen/adyen.md) — the `AdyenProvider`
|
||||
class.
|
||||
- [`src/adyen/mapping.md`](./src/adyen/mapping.md) — pure mappers
|
||||
(`mapStatus`, `mapRefundStatus`, `mapPaymentMethod`, `mapPayment`).
|
||||
- [`src/types/adyen.md`](./src/types/adyen.md) — `AdyenConfig` type
|
||||
alias for the slim-core `MerchantKeyConfig`.
|
||||
|
||||
## Related
|
||||
|
||||
- [`@suaveplan/payment-providers`](../payment-providers/README.md) —
|
||||
slim foundation: `httpRequest`, `composeAbortSignal`,
|
||||
`ProviderRegistry`, `PaymentProviderError`, shared config types.
|
||||
- [`@suaveplan/payment`](../payment/README.md) — `PaymentEngine`,
|
||||
`WebhookVerifier`, `PaymentProvider` interface.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
61
packages/money/payment/payment-provider-adyen/package.json
Normal file
61
packages/money/payment/payment-provider-adyen/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
216
packages/money/payment/payment-provider-adyen/src/adyen/adyen.md
Normal file
216
packages/money/payment/payment-provider-adyen/src/adyen/adyen.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# `AdyenProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal adapter that implements the `PaymentProvider` interface
|
||||
from `@suaveplan/payment` against Adyen's Checkout API v71. The class
|
||||
authenticates every request via the `X-API-Key` header (rather than
|
||||
HTTP Basic auth like Stripe/Mollie or OAuth like PayPal), and injects
|
||||
the configured `merchantAccount` string into every request body Adyen
|
||||
requires it on (`/payments`, `/payments/{id}/captures`,
|
||||
`/payments/{id}/cancels`, `/payments/{id}/refunds`).
|
||||
|
||||
The class is the sole runtime export of
|
||||
`@suaveplan/payment-provider-adyen`. It depends only on the slim core
|
||||
`@suaveplan/payment-providers` (for `httpRequest`,
|
||||
`PaymentProviderError`, `RandomIdFn`, and `MerchantKeyConfig`) and on
|
||||
the type-only `@suaveplan/payment` interface — no Node, browser, or
|
||||
React imports, no `node:crypto`, and no module-scope wall-clock or
|
||||
RNG access. Default `reference` generation goes through an injectable
|
||||
`RandomIdFn`, falling back to `globalThis.crypto.randomUUID()` only at
|
||||
call time and only when available; tests inject a deterministic id
|
||||
source instead.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full `PaymentProvider` surface** — `createIntent`, `confirmIntent`,
|
||||
`captureIntent`, `cancelIntent`, `refund`, `getIntent`.
|
||||
- **`X-API-Key` authentication** — set on every request, including
|
||||
`GET`s.
|
||||
- **`Idempotency-Key` header forwarding** — when the caller supplies
|
||||
`idempotencyKey` on `createIntent` or `refund`, the same value is
|
||||
echoed as both the request-level `Idempotency-Key` header and (for
|
||||
`createIntent`) the `reference` field in the body.
|
||||
- **Injectable `randomId`** — default `reference` generation uses
|
||||
caller-supplied `RandomIdFn`; falls back to
|
||||
`globalThis.crypto.randomUUID()` only at call time so the package
|
||||
stays universal and never imports `node:crypto`.
|
||||
- **`merchantAccount` context injection** — every modification body
|
||||
(`/captures`, `/cancels`, `/refunds`) carries the configured value.
|
||||
- **Adyen status mapping** — `Authorised` / `Refused` / `Cancelled` /
|
||||
`Error` / `Pending` / `Received` / `RedirectShopper` /
|
||||
`IdentifyShopper` / `ChallengeShopper` / `PresentToShopper` plus
|
||||
unknown codes mapped to the canonical `PaymentStatus`.
|
||||
- **Refund status mapping** — `received` → `pending`, every other
|
||||
status → `failed`.
|
||||
- **Payment-method mapping** — `scheme` → `card`, everything else →
|
||||
`wallet`; brand lower-cased; `lastFour` preserved as `last4`.
|
||||
- **Typed errors** — every HTTP / timeout / parse failure surfaces as
|
||||
a `PaymentProviderError` (one of `PAYMENT_PROVIDER_CODES`) via the
|
||||
shared `httpRequest` helper; the class never throws plain `Error`.
|
||||
- **Universal purity** — zero `node:`, `bun:`, `fs`, `path`,
|
||||
`stream`, `net`, `http`, or `crypto` imports.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
});
|
||||
|
||||
const intent = await adyen.createIntent({
|
||||
amount: { amount: 4_999n, currency: "USD" },
|
||||
description: "Acme premium subscription",
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider } from "@suaveplan/payment-provider-adyen";
|
||||
import {
|
||||
ProviderRegistry,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
// Test-time injection: recording fetch + deterministic randomId.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
pspReference: "PSP_REC",
|
||||
resultCode: "Authorised",
|
||||
amount: { value: 4999, currency: "USD" },
|
||||
paymentMethod: { type: "scheme", brand: "visa", lastFour: "4242" },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
let refCounter = 0;
|
||||
const adyen = new AdyenProvider({
|
||||
apiKey: "AQE_test",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
baseUrl: "https://checkout-test.adyen.com/v71",
|
||||
fetch: recordingFetch,
|
||||
randomId: () => `ref_${++refCounter}`,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(adyen);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("adyen") });
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 4_999n, currency: "USD" },
|
||||
captureMethod: "manual",
|
||||
});
|
||||
|
||||
// Verify the recording layer saw the right bytes:
|
||||
const body = JSON.parse(String(calls[0]?.init?.body));
|
||||
console.assert(body.merchantAccount === "AcmeMerchantECOM");
|
||||
console.assert(body.reference === "ref_1");
|
||||
console.assert(body.captureDelayHours === 0);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AdyenProvider(config: AdyenConfig)`
|
||||
|
||||
Construct an Adyen adapter. `config` is the `MerchantKeyConfig` alias
|
||||
documented in [`types/adyen.md`](../types/adyen.md). Required:
|
||||
`apiKey`, `merchantAccount`. Optional: `baseUrl`, `timeoutMs`,
|
||||
`signal`, `fetch`, `randomId`, `webhookHmacKey`. When `randomId` is
|
||||
omitted the class falls back to `globalThis.crypto.randomUUID()` at
|
||||
call time; if neither is available, the first auto-generated
|
||||
`reference` raises a `PaymentProviderError` with code
|
||||
`PAYMENT_PROVIDER.PARSE_ERROR`.
|
||||
|
||||
### `readonly name: "adyen"`
|
||||
|
||||
Stable identifier consumed by `ProviderRegistry` and any
|
||||
multi-provider routing layer.
|
||||
|
||||
### `createIntent(input): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments`. Request body always carries
|
||||
`merchantAccount`, `amount`, and `reference` (caller's `idempotencyKey`
|
||||
or `randomId()`). When `captureMethod === "manual"` the body adds
|
||||
`captureDelayHours: 0`. Optional `description` → `shopperStatement`,
|
||||
`returnUrl` → `returnUrl`, `paymentMethodId` →
|
||||
`paymentMethod.storedPaymentMethodId`, `metadata` → `metadata`.
|
||||
|
||||
### `confirmIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/details` with `{ details: {} }`.
|
||||
|
||||
### `captureIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/captures` with
|
||||
`{ merchantAccount, amount: {} }`. Returns a `PaymentIntent` with
|
||||
`status: "captured"` and `capturedAmount` reflecting the response.
|
||||
|
||||
### `cancelIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
POST `${baseUrl}/payments/${intentId}/cancels` with
|
||||
`{ merchantAccount }`. Returns a `PaymentIntent` with
|
||||
`status: "canceled"`.
|
||||
|
||||
### `refund(input): Promise<Refund>`
|
||||
|
||||
POST `${baseUrl}/payments/${input.paymentIntentId}/refunds` with
|
||||
`{ merchantAccount }` plus optional `amount` when supplied. Forwards
|
||||
`Idempotency-Key` when `input.idempotencyKey` is set. Refund `status`
|
||||
is mapped via `mapRefundStatus`.
|
||||
|
||||
### `getIntent(intentId): Promise<PaymentIntent>`
|
||||
|
||||
GET `${baseUrl}/payments/${intentId}`.
|
||||
|
||||
### Errors
|
||||
|
||||
Every method propagates `PaymentProviderError` for HTTP non-2xx
|
||||
responses, network failures, request timeouts, and JSON parse
|
||||
failures — see `@suaveplan/payment-providers`
|
||||
[`error-codes.md`](../../../payment-providers/src/error-codes/error-codes.md).
|
||||
The class never throws plain `Error`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The default `baseUrl` is the **test** endpoint
|
||||
(`https://checkout-test.adyen.com/v71`). Live integrations MUST
|
||||
override `baseUrl` with the merchant-specific
|
||||
`https://<prefix>-checkout-live.adyenpayments.com/checkout/v71`
|
||||
URL. Adyen does not provide a single shared live endpoint.
|
||||
- The cancel response carries no `amount` (Adyen models cancellation
|
||||
as a zero-value state change), so `cancelIntent` returns
|
||||
`amount`/`capturedAmount`/`refundedAmount` all set to `{ amount: 0n,
|
||||
currency: <response.currency> }`. Consumers needing the original
|
||||
authorisation amount should refetch via `getIntent`.
|
||||
- `BigInt(p.amount.value)` is safe because Adyen returns integer minor
|
||||
units across the Checkout API; the conversion would silently round
|
||||
any floating-point input that ever appeared, which is not a
|
||||
documented v71 case.
|
||||
- The class holds no mutable state beyond the constructor seams; every
|
||||
method composes its own `headers` and request body fresh, so
|
||||
concurrent calls are safe.
|
||||
- `mapping.ts` is a sibling file holding the pure status / refund /
|
||||
payment-method / payment mappers; splitting them out keeps `adyen.ts`
|
||||
inside the 300-line hard limit (rule 3) and documents the mapping
|
||||
contract on its own page.
|
||||
- Webhook signature verification is **not** in scope for this package
|
||||
— it lives in `@suaveplan/payment`'s `WebhookVerifier`. The
|
||||
`webhookHmacKey` field on `AdyenConfig` is plumbed through so
|
||||
consumers can pass it to the verifier without re-typing it.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/money/payment/payment-provider-adyen/src/adyen/adyen.ts
Normal file
222
packages/money/payment/payment-provider-adyen/src/adyen/adyen.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/money/payment/payment-provider-adyen/src/index.ts
Normal file
12
packages/money/payment/payment-provider-adyen/src/index.ts
Normal 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";
|
||||
115
packages/money/payment/payment-provider-adyen/src/types/adyen.md
Normal file
115
packages/money/payment/payment-provider-adyen/src/types/adyen.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# `AdyenConfig`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Type alias exported by `@suaveplan/payment-provider-adyen` describing
|
||||
the constructor argument accepted by `AdyenProvider`. It re-exports the
|
||||
slim-core `MerchantKeyConfig` verbatim so consumers importing only this
|
||||
sibling package do not need a second import from
|
||||
`@suaveplan/payment-providers`, and so future Adyen-specific fields can
|
||||
be added here without disturbing the foundation contract.
|
||||
|
||||
The type is structurally identical to `MerchantKeyConfig` — any value
|
||||
typed as `MerchantKeyConfig` is assignable to `AdyenConfig` and vice
|
||||
versa. This is deliberate: the slim-core shape already carries every
|
||||
Adyen-specific seam (`apiKey`, `merchantAccount`, `webhookHmacKey`,
|
||||
`randomId`), and forking the type would create a second source of
|
||||
truth for the same contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports `MerchantKeyConfig` under an Adyen-local name so callers
|
||||
see the right vocabulary.
|
||||
- Carries the injectable `randomId: RandomIdFn` seam used by
|
||||
`AdyenProvider.createIntent` to generate the default `reference`
|
||||
field when no `idempotencyKey` is supplied.
|
||||
- Carries the standard `ProviderCommonOptions` seams (`fetch`,
|
||||
`timeoutMs`, `signal`) so tests can swap in a recording `FetchLike`
|
||||
and exercise abort propagation without monkey-patching globals.
|
||||
- Carries `webhookHmacKey` for future webhook verification wiring
|
||||
(currently consumed by `@suaveplan/payment`'s `WebhookVerifier`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AdyenProvider, type AdyenConfig } from "@suaveplan/payment-provider-adyen";
|
||||
|
||||
const config: AdyenConfig = {
|
||||
apiKey: process.env.ADYEN_API_KEY ?? "",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
};
|
||||
|
||||
const adyen = new AdyenProvider(config);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AdyenProvider,
|
||||
type AdyenConfig,
|
||||
} from "@suaveplan/payment-provider-adyen";
|
||||
import type { FetchLike } from "@suaveplan/payment-providers";
|
||||
|
||||
// Deterministic reference generation + recording fetch for tests.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const recordingFetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
pspReference: "PSP_TEST",
|
||||
resultCode: "Authorised",
|
||||
amount: { value: 5000, currency: "USD" },
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
const config: AdyenConfig = {
|
||||
apiKey: "AQE_test_key",
|
||||
merchantAccount: "AcmeMerchantECOM",
|
||||
baseUrl: "https://checkout-test.adyen.com/v71",
|
||||
fetch: recordingFetch,
|
||||
randomId: () => `gen_ref_${++counter}`,
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
|
||||
const adyen = new AdyenProvider(config);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type AdyenConfig`
|
||||
|
||||
Alias for `MerchantKeyConfig` from `@suaveplan/payment-providers`.
|
||||
Fields (all read-only):
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `apiKey` | `string` | yes | — | Adyen Checkout API key, sent as `X-API-Key`. |
|
||||
| `merchantAccount` | `string` | yes | — | Adyen merchant account code, injected into modification bodies. |
|
||||
| `baseUrl` | `string` | no | `https://checkout-test.adyen.com/v71` | Endpoint override (use `https://<prefix>-checkout-live.adyenpayments.com/checkout/v71` for live). |
|
||||
| `webhookHmacKey` | `string` | no | — | Reserved for webhook signature verification. |
|
||||
| `randomId` | `() => string` | no | `globalThis.crypto?.randomUUID` when available | Default-`reference` generator. |
|
||||
| `fetch` | `FetchLike` | no | `globalThis.fetch` | Injectable HTTP client. |
|
||||
| `timeoutMs` | `number` | no | `30_000` | Per-call timeout in milliseconds. |
|
||||
| `signal` | `AbortSignal` | no | — | External cancellation token. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `AdyenConfig` is intentionally a `type` alias rather than an
|
||||
`interface extends MerchantKeyConfig` — the latter would diverge the
|
||||
two types under `tsc --strict` if `MerchantKeyConfig` ever gains an
|
||||
optional field. Aliasing preserves structural equality forever.
|
||||
- The `randomId` seam is the universal escape hatch for default
|
||||
`reference` generation. Without it, the provider would have to call
|
||||
`crypto.randomUUID()` at request time and bring the entire
|
||||
`node:crypto` polyfill surface into bundlers targeting browsers and
|
||||
Web Workers. With it, callers in those environments can pass a
|
||||
deterministic id source (UUIDv7, NanoID, sequence counter) and the
|
||||
package stays pure.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export type { AdyenConfig } from "./adyen.js";
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-provider-adyen/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-adyen/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
34
packages/money/payment/payment-provider-adyen/vite.config.ts
Normal file
34
packages/money/payment/payment-provider-adyen/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
72
packages/money/payment/payment-provider-mollie/README.md
Normal file
72
packages/money/payment/payment-provider-mollie/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @suaveplan/payment-provider-mollie - Universal Mollie Payments API v2 adapter for @suaveplan/payment. Sibling provider package on the slim @suaveplan/payment-providers core: implements PaymentProvider with Mollie's string-amount marshalling ({ currency, value: "X.YY" }) round-trip via exported formatMollieAmount / parseMollieAmount helpers.
|
||||
|
||||
Universal Mollie Payments API v2 adapter for `@suaveplan/payment`.
|
||||
Sibling provider package on the slim `@suaveplan/payment-providers`
|
||||
core: implements `PaymentProvider` with Mollie's string-amount
|
||||
marshalling (`{ currency, value: "X.YY" }`) round-trip via the
|
||||
exported `formatMollieAmount` / `parseMollieAmount` helpers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
|
||||
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-mollie @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" })
|
||||
);
|
||||
|
||||
const mollie = registry.get("mollie");
|
||||
const intent = await mollie.createIntent({
|
||||
amount: { amount: 5000n, currency: "EUR" },
|
||||
description: "Order #42",
|
||||
returnUrl: "https://shop.example/return",
|
||||
});
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `MollieProvider` — full `PaymentProvider` lifecycle
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) on top of the slim `httpRequest` helper.
|
||||
- Pure, exported amount helpers — `formatMollieAmount(amount: bigint)`
|
||||
and `parseMollieAmount(value: string)` — that round-trip losslessly
|
||||
via `bigint` and cover the 1-cent, exact-major, large-value, and
|
||||
over-precision edges.
|
||||
- Total exported status maps — `mapMollieStatus` and
|
||||
`mapMollieRefundStatus` — each individually unit-testable without
|
||||
invoking the network path.
|
||||
- `Authorization: Bearer <apiKey>` header auto-applied on every call.
|
||||
- Universal — no Node-specific or browser-specific globals; runs
|
||||
on Node 18+, Bun, browsers, and Web Workers.
|
||||
- All failures surface as `PaymentProviderError` from
|
||||
`@suaveplan/payment-providers` carrying a `PAYMENT_PROVIDER_*` code.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/mollie/mollie.md`](./src/mollie/mollie.md) —
|
||||
`MollieProvider` class, helpers, and status maps.
|
||||
|
||||
## Sibling packages
|
||||
|
||||
Part of the `@suaveplan/payment-provider-*` family. See
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) for the
|
||||
shared foundation (`ProviderRegistry`, `httpRequest`,
|
||||
`composeAbortSignal`, `PaymentProviderError`, the `PAYMENT_PROVIDER_*`
|
||||
code registry, and the shared config shapes).
|
||||
|
||||
67
packages/money/payment/payment-provider-mollie/package.json
Normal file
67
packages/money/payment/payment-provider-mollie/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
formatMollieAmount,
|
||||
MOLLIE_DEFAULT_BASE_URL,
|
||||
MOLLIE_PROVIDER_NAME,
|
||||
MollieProvider,
|
||||
mapMollieRefundStatus,
|
||||
mapMollieStatus,
|
||||
parseMollieAmount,
|
||||
} from "./mollie/mollie.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
formatMollieAmount,
|
||||
MOLLIE_DEFAULT_BASE_URL,
|
||||
MOLLIE_PROVIDER_NAME,
|
||||
MollieProvider,
|
||||
mapMollieRefundStatus,
|
||||
mapMollieStatus,
|
||||
parseMollieAmount,
|
||||
} from "./mollie.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 { };
|
||||
12
packages/money/payment/payment-provider-mollie/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-mollie/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
107
packages/money/payment/payment-provider-paypal/README.md
Normal file
107
packages/money/payment/payment-provider-paypal/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @suaveplan/payment-provider-paypal - PayPal payment provider for @suaveplan/payment-providers. Implements the PaymentProvider interface against the PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2 client-credentials token cache, injectable now/base64Encode/FetchLike seams, sandbox-endpoint shortcut, MoneyLike bigint marshalling, and structured PaymentProviderError surfaces. Universal — runs unchanged on Node, Bun, browsers, and Web Workers.
|
||||
|
||||
PayPal sibling adapter for the slim
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) foundation.
|
||||
Ships `PayPalProvider` — a `PaymentProvider` implementation against the
|
||||
PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2
|
||||
client-credentials token cache, injectable `now` / `base64Encode` /
|
||||
`FetchLike` seams, a `sandbox` shortcut, `MoneyLike` bigint marshalling,
|
||||
and structured `PaymentProviderError` surfaces. Universal — runs
|
||||
unchanged on Node, Bun, browsers, and Web Workers.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
|
||||
- [mapping](./src/paypal/mapping.md) — Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders v2 + Payments Captures Refund wire shapes i…
|
||||
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-paypal @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new PayPalProvider({
|
||||
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
|
||||
sandbox: true,
|
||||
})
|
||||
);
|
||||
|
||||
const paypal = registry.get("paypal");
|
||||
const intent = await paypal.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
description: "Order #1234",
|
||||
captureMethod: "automatic",
|
||||
idempotencyKey: "order-1234",
|
||||
});
|
||||
console.log(intent.id, intent.status);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- `class PayPalProvider implements PaymentProvider` with literal-typed
|
||||
`name: "paypal"`
|
||||
- OAuth2 client-credentials token cache with 60 s clock-skew buffer,
|
||||
refreshed transparently on expiry
|
||||
- Injectable `now: NowFn`, `base64Encode: Base64EncodeFn`, and
|
||||
`fetch: FetchLike` for universal determinism and testability
|
||||
- `sandbox` flag → `https://api-m.sandbox.paypal.com`; explicit `baseUrl`
|
||||
always wins
|
||||
- `MoneyLike` bigint minor-unit amounts marshalled to PayPal's major-unit
|
||||
decimal-string format
|
||||
- `PayPal-Request-Id` idempotency-header support on `createIntent` and
|
||||
`refund`
|
||||
- Exhaustive PayPal-status → `PaymentStatus` / `RefundStatus`
|
||||
mapping; unknown statuses map to `failed`
|
||||
- Structured `PaymentProviderError` (from `@suaveplan/payment-providers`)
|
||||
on every failure path — `OAUTH_TOKEN_FAILURE`, `HTTP_ERROR`, `TIMEOUT`,
|
||||
`PARSE_ERROR` — always with `provider: "paypal"`
|
||||
- Universal: zero Node / browser / React imports; `sideEffects: false`
|
||||
- 100% Istanbul coverage; sibling `.md` per implementation file
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/paypal/paypal.md`](./src/paypal/paypal.md) — `PayPalProvider`
|
||||
class, `PayPalConfig` interface, lifecycle methods, OAuth token cache
|
||||
- [`src/paypal/mapping.md`](./src/paypal/mapping.md) — pure helpers:
|
||||
`mapStatus`, `mapRefundStatus`, `parseAmount`, `formatAmount`,
|
||||
`mapOrder`
|
||||
|
||||
## Foundation
|
||||
|
||||
This package depends on
|
||||
[`@suaveplan/payment-providers`](../payment-providers/README.md) for:
|
||||
|
||||
- `httpRequest`, `composeAbortSignal`, `FetchLike`
|
||||
- `OAuthConfig`, `NowFn`, `Base64EncodeFn`
|
||||
- `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `oauthTokenFailure`
|
||||
|
||||
and on [`@suaveplan/payment`](../payment/README.md) for the structural
|
||||
`PaymentProvider`, `PaymentIntent`, `Refund`, `PaymentStatus`,
|
||||
`RefundStatus`, `CreatePaymentIntentInput`, and `RefundInput` types.
|
||||
|
||||
## Related sibling packages
|
||||
|
||||
- `@suaveplan/payment-provider-stripe`
|
||||
- `@suaveplan/payment-provider-square`
|
||||
- `@suaveplan/payment-provider-mollie`
|
||||
- `@suaveplan/payment-provider-adyen`
|
||||
|
||||
All four follow the same pattern: depend on the slim core, expose a
|
||||
single `*Provider` class, register cleanly into `ProviderRegistry`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
62
packages/money/payment/payment-provider-paypal/package.json
Normal file
62
packages/money/payment/payment-provider-paypal/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
20
packages/money/payment/payment-provider-paypal/src/index.ts
Normal file
20
packages/money/payment/payment-provider-paypal/src/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
111
packages/money/payment/payment-provider-paypal/tests/fixtures/paypal-recorder.ts
vendored
Normal file
111
packages/money/payment/payment-provider-paypal/tests/fixtures/paypal-recorder.ts
vendored
Normal 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";
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-provider-paypal/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-paypal/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
104
packages/money/payment/payment-provider-square/README.md
Normal file
104
packages/money/payment/payment-provider-square/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @suaveplan/payment-provider-square - Universal Square Payments API adapter for @suaveplan/payment. Ships SquareProvider (implements PaymentProvider with literal name "square"), SquareConfig (extends ApiKeyConfig with locationId, sandbox, apiVersion, randomId), and the Square-specific payment/refund mappers. Built on the @suaveplan/payment-providers foundation (httpRequest, composeAbortSignal, PAYMENT_PROVIDER_CODES, PaymentProviderError).
|
||||
|
||||
Universal Square Payments API adapter for `@suaveplan/payment`.
|
||||
Ships the `SquareProvider` class (implements the structural
|
||||
`PaymentProvider` contract with literal `name: "square"`), the
|
||||
`SquareConfig` interface (extends `ApiKeyConfig` from
|
||||
`@suaveplan/payment-providers` with the Square-specific `locationId`,
|
||||
`sandbox`, `apiVersion`, and `randomId` fields), and the
|
||||
Square-specific payment / refund mappers (`mapPayment`, `mapRefund`,
|
||||
`mapPaymentStatus`, `mapRefundStatus`).
|
||||
|
||||
Built on the `@suaveplan/payment-providers` foundation — every
|
||||
outbound HTTP call goes through `httpRequest`, which handles JSON
|
||||
serialisation, the per-call timeout signal, external `AbortSignal`
|
||||
composition, and typed `PaymentProviderError` raising.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [payment](./src/mapping/payment.md) — Translates between Square's `Payment` envelope (as returned by `POST /v2/payments`, `POST /v2/payments/{id}/complete`,…
|
||||
- [refund](./src/mapping/refund.md) — Translates Square's `Refund` envelope (as returned by `POST /v2/refunds`) into `@suaveplan/payment`'s `Refund` shape. T…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [square](./src/square/square.md) — `SquareProvider` is the Square Payments API adapter for `@suaveplan/payment`. It implements the structural `PaymentProv…
|
||||
- [types](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
- [config](./src/types/config.md) — Defines the configuration shape `SquareProvider` accepts and the three default constants the provider falls back to whe…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-square @suaveplan/payment-providers @suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { SquareProvider } from "@suaveplan/payment-provider-square";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new SquareProvider({
|
||||
apiKey: process.env.SQUARE_ACCESS_TOKEN ?? "",
|
||||
locationId: process.env.SQUARE_LOCATION_ID ?? "",
|
||||
})
|
||||
);
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("square") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`SquareProvider`** — full Square Payments API lifecycle
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
|
||||
`refund`, `getIntent`) with literal `name: "square"`.
|
||||
- **`SquareConfig`** — extends `ApiKeyConfig`; adds required
|
||||
`locationId` plus optional `sandbox`, `apiVersion`, `randomId`.
|
||||
- **Sandbox toggle** — `sandbox: true` flips the default base URL to
|
||||
`https://connect.squareupsandbox.com/v2`. Explicit `baseUrl`
|
||||
always wins.
|
||||
- **Pinned `Square-Version`** — defaults to `2024-01-18`; override via
|
||||
`apiVersion` to opt into a newer API surface.
|
||||
- **Injectable `randomId`** — fills missing `idempotency_key` fields
|
||||
on `CreatePayment` + `RefundPayment` calls. Tests pass a
|
||||
deterministic counter; production defaults to `crypto.randomUUID()`.
|
||||
- **Universal** — no `node:`, `bun:`, `fs`, `path`, `crypto`
|
||||
imports; runs on Node, Bun, browsers, and Web Workers.
|
||||
- **`PaymentProviderError`-only** — every failure surfaces as a
|
||||
typed `PaymentProviderError` via the foundation's `httpRequest`.
|
||||
- **100% test coverage** (Istanbul) + sibling `.md` per
|
||||
implementation file + 5 subpath exports.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/square/square.md`](./src/square/square.md) — `SquareProvider`
|
||||
class and lifecycle methods.
|
||||
- [`src/types/config.md`](./src/types/config.md) — `SquareConfig`,
|
||||
`SQUARE_DEFAULT_BASE_URL`, `SQUARE_SANDBOX_BASE_URL`,
|
||||
`SQUARE_DEFAULT_API_VERSION`.
|
||||
- [`src/mapping/payment.md`](./src/mapping/payment.md) — `mapPayment`,
|
||||
`mapPaymentStatus`, `SquarePaymentDto`, `SquareMoney`, `SquareCard`.
|
||||
- [`src/mapping/refund.md`](./src/mapping/refund.md) — `mapRefund`,
|
||||
`mapRefundStatus`, `SquareRefundDto`.
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment-providers` — `ProviderRegistry`,
|
||||
`definePaymentProvider`, `httpRequest`, `composeAbortSignal`,
|
||||
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`,
|
||||
`ApiKeyConfig`/`OAuthConfig`/`MerchantKeyConfig`,
|
||||
`FetchLike`/`RandomIdFn`/`NowFn`/`Base64EncodeFn`.
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface, the canonical `PaymentIntent` /
|
||||
`Refund` / `PaymentStatus` / `RefundStatus` shapes.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
69
packages/money/payment/payment-provider-square/package.json
Normal file
69
packages/money/payment/payment-provider-square/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
19
packages/money/payment/payment-provider-square/src/index.ts
Normal file
19
packages/money/payment/payment-provider-square/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
mapPayment,
|
||||
mapPaymentStatus,
|
||||
type SquareCard,
|
||||
type SquareMoney,
|
||||
type SquarePaymentDto,
|
||||
} from "./payment.js";
|
||||
export {
|
||||
mapRefund,
|
||||
mapRefundStatus,
|
||||
type SquareRefundDto,
|
||||
} from "./refund.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SquareProvider } from "./square.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SQUARE_DEFAULT_API_VERSION,
|
||||
SQUARE_DEFAULT_BASE_URL,
|
||||
SQUARE_SANDBOX_BASE_URL,
|
||||
type SquareConfig,
|
||||
} from "./config.js";
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-provider-square/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-square/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
111
packages/money/payment/payment-provider-stripe/README.md
Normal file
111
packages/money/payment/payment-provider-stripe/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @suaveplan/payment-provider-stripe - Universal Stripe adapter for @suaveplan/payment. Implements PaymentProvider against the Stripe REST API via the slim @suaveplan/payment-providers foundation: Bearer auth on every call, Idempotency-Key forwarding on every mutating call, exhaustive Stripe status/refund-status/refund-reason mapping, payment-method normalisation, every error raised as PaymentProviderError with provider: 'stripe' discriminant. No Node / browser / React deps; every HTTP call flows through the injectable FetchLike from the slim core.
|
||||
|
||||
Universal Stripe adapter for `@suaveplan/payment`. Implements
|
||||
`PaymentProvider` against the Stripe REST API using the slim
|
||||
`@suaveplan/payment-providers` foundation: Bearer auth on every call,
|
||||
`Idempotency-Key` forwarding on every mutating call, exhaustive
|
||||
Stripe → domain status / refund-status / refund-reason mapping,
|
||||
payment-method normalisation, every error raised as
|
||||
`PaymentProviderError` with `provider: "stripe"` discriminant.
|
||||
|
||||
This package is one of the five sibling adapters that re-landed
|
||||
after `2026-05-17-slim-payment-providers` split the bundled
|
||||
`@suaveplan/payment-providers` into a foundation-with-sibling-plugins
|
||||
shape.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
|
||||
- [stripe](./src/stripe/stripe.md) — `StripeProvider` is the Stripe adapter for `@suaveplan/payment`. It implements the `PaymentProvider` contract (six meth…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-provider-stripe \
|
||||
@suaveplan/payment-providers \
|
||||
@suaveplan/payment
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const provider = new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
});
|
||||
|
||||
const engine = new PaymentEngine({ provider });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
```
|
||||
|
||||
## Compose with the foundation registry
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(
|
||||
new StripeProvider({ apiKey: process.env.STRIPE_SECRET_KEY ?? "" })
|
||||
);
|
||||
|
||||
const stripe = registry.get("stripe"); // typed as PaymentProvider
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`StripeProvider`** — implements `PaymentProvider` from
|
||||
`@suaveplan/payment` (literal `name: "stripe"`, six lifecycle
|
||||
methods).
|
||||
- **Bearer authentication** on every request (read + write).
|
||||
- **`Idempotency-Key` forwarding** — when the caller supplies
|
||||
`idempotencyKey` on `CreatePaymentIntentInput` or `RefundInput`,
|
||||
the provider forwards it verbatim to Stripe so retries collapse
|
||||
into a single side effect.
|
||||
- **Exhaustive status mappings** — seven Stripe payment-intent
|
||||
statuses, three Stripe refund statuses, four Stripe refund reasons,
|
||||
every unmapped value falls back to `failed` / `other`.
|
||||
- **Payment-method normalisation** — `card` carries `brand` + `last4`;
|
||||
any other Stripe `type` maps to `wallet`; absent payment method
|
||||
maps to `undefined`.
|
||||
- **Typed errors** — every failure is a `PaymentProviderError` from
|
||||
the slim core with `provider: "stripe"` discriminant.
|
||||
- **Universal tier** — zero Node, browser, React, or
|
||||
platform-specific imports. Runs unchanged on Node, Bun, browsers,
|
||||
and Web Workers.
|
||||
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
|
||||
exports.
|
||||
- **100% test coverage** (Istanbul) and **100% docs coverage**.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/stripe/stripe.md`](./src/stripe/stripe.md) — `StripeProvider`
|
||||
class, `StripeConfig`, `StripeIntent` / `StripeRefund` wire-format
|
||||
interfaces.
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface, `PaymentIntent` / `Refund` / status
|
||||
enums.
|
||||
- `@suaveplan/payment-providers` — slim foundation with
|
||||
`ProviderRegistry`, `httpRequest`, `composeAbortSignal`,
|
||||
`PaymentProviderError`, shared config shapes.
|
||||
- `@suaveplan/payment-provider-paypal`,
|
||||
`@suaveplan/payment-provider-square`,
|
||||
`@suaveplan/payment-provider-mollie`,
|
||||
`@suaveplan/payment-provider-adyen` — sibling provider packages.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
57
packages/money/payment/payment-provider-stripe/package.json
Normal file
57
packages/money/payment/payment-provider-stripe/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { StripeProvider } from "./stripe/stripe.js";
|
||||
export type {
|
||||
StripeConfig,
|
||||
StripeIntent,
|
||||
StripeRefund,
|
||||
} from "./stripe/stripe.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { StripeProvider } from "./stripe.js";
|
||||
export type {
|
||||
StripeConfig,
|
||||
StripeIntent,
|
||||
StripeRefund,
|
||||
} from "./stripe.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-provider-stripe/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-stripe/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
213
packages/money/payment/payment-providers/README.md
Normal file
213
packages/money/payment/payment-providers/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# @suaveplan/payment-providers - Slim universal foundation for the @suaveplan/payment-provider-* sibling packages. Ships ProviderRegistry, definePaymentProvider factory, shared httpRequest/composeAbortSignal helpers, FetchLike, the ApiKeyConfig / OAuthConfig / MerchantKeyConfig / HttpOptions / ProviderResponse types, and the PAYMENT_PROVIDER_CODES + PaymentProviderError error model. Concrete Stripe / PayPal / Square / Mollie / Adyen adapters ship as separate @suaveplan/payment-provider-<name> packages that depend on this foundation.
|
||||
|
||||
Universal **foundation** for the `@suaveplan/payment-provider-*`
|
||||
sibling-package family. This package ships only the shared abstractions
|
||||
every concrete adapter needs — the `ProviderRegistry`, the
|
||||
`definePaymentProvider` factory, the `httpRequest` +
|
||||
`composeAbortSignal` HTTP helpers, the `FetchLike` type, the shared
|
||||
config shapes (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
|
||||
`HttpOptions`, `ProviderResponse`), and the `PAYMENT_PROVIDER_CODES`
|
||||
registry + `PaymentProviderError` class. Concrete adapters
|
||||
(Stripe, PayPal, Square, Mollie, Adyen) live in **sibling packages** so
|
||||
consumers pay only for the providers they install.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
|
||||
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
|
||||
- [define](./src/registry/define.md) — Factory helper for sibling `@suaveplan/payment-provider-*` packages that want to expose a `PaymentProvider` (from `@sua…
|
||||
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
|
||||
- [types](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
|
||||
- [provider](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/payment-providers @suaveplan/payment
|
||||
# plus one or more of:
|
||||
bun add @suaveplan/payment-provider-stripe
|
||||
bun add @suaveplan/payment-provider-paypal
|
||||
bun add @suaveplan/payment-provider-square
|
||||
bun add @suaveplan/payment-provider-mollie
|
||||
bun add @suaveplan/payment-provider-adyen
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry } from "@suaveplan/payment-providers";
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
import { PaymentEngine } from "@suaveplan/payment";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new StripeProvider({
|
||||
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
}));
|
||||
|
||||
const engine = new PaymentEngine({ provider: registry.get("stripe") });
|
||||
|
||||
const intent = await engine.createIntent({
|
||||
amount: { amount: 5000n, currency: "USD" },
|
||||
captureMethod: "automatic",
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **`ProviderRegistry`** — process-local, synchronous,
|
||||
insertion-ordered registry of `PaymentProvider` instances keyed by
|
||||
`name`; strict `get` with typed miss, duplicate-safe `register`,
|
||||
idempotent `unregister`, frozen `list()` snapshot
|
||||
- **`definePaymentProvider<TName>(spec)`** — factory that returns a
|
||||
frozen, literal-typed `PaymentProvider` for sibling packages that
|
||||
prefer a closure to a class
|
||||
- **`httpRequest<T>(options)`** — universal `FetchLike` wrapper with
|
||||
per-call timeout, JSON serialisation, custom-header passthrough,
|
||||
typed errors, empty-body handling, and external-`AbortSignal`
|
||||
composition
|
||||
- **`composeAbortSignal(internal, external?)`** — reusable
|
||||
abort-signal composer (extracted from `httpRequest` so sibling
|
||||
packages can compose their own out-of-band HTTP calls with
|
||||
caller-supplied cancellation)
|
||||
- **`PAYMENT_PROVIDER_CODES`** + **`PaymentProviderError`** — 6-code
|
||||
registry (`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`,
|
||||
`OAUTH_TOKEN_FAILURE`, `PROVIDER_NOT_REGISTERED`,
|
||||
`PROVIDER_ALREADY_REGISTERED`) with optional `provider` /
|
||||
`httpStatus` / `url` / `bodyExcerpt` discriminants
|
||||
- **Shared config shapes** — `ApiKeyConfig`, `OAuthConfig`,
|
||||
`MerchantKeyConfig`, `HttpOptions`, `ProviderResponse`,
|
||||
`ProviderCommonOptions`, plus `NowFn` / `RandomIdFn` /
|
||||
`Base64EncodeFn` injectable seams
|
||||
- **Universal tier** — zero Node, browser, React, or `node:crypto`
|
||||
imports; runs unchanged on Node, Bun, browsers, and Web Workers
|
||||
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
|
||||
exports
|
||||
- **100% test coverage** (Istanbul) + 100% docs coverage (sibling
|
||||
`.md` per implementation file)
|
||||
|
||||
## Sibling provider packages
|
||||
|
||||
The five concrete provider adapters that used to ship in this package
|
||||
now live in dedicated sibling packages, each depending on this
|
||||
foundation:
|
||||
|
||||
- `@suaveplan/payment-provider-stripe` — `StripeProvider`
|
||||
- `@suaveplan/payment-provider-paypal` — `PayPalProvider`
|
||||
- `@suaveplan/payment-provider-square` — `SquareProvider` + the
|
||||
Square-specific `SquareConfig` type
|
||||
- `@suaveplan/payment-provider-mollie` — `MollieProvider`
|
||||
- `@suaveplan/payment-provider-adyen` — `AdyenProvider`
|
||||
|
||||
Install one or more alongside this foundation. Every sibling exports
|
||||
the same class name + `PaymentProvider`-interface contract as before
|
||||
the slim-down, so migration from a bundled import is a one-line
|
||||
package swap (see `## Migration` below).
|
||||
|
||||
## Migration
|
||||
|
||||
If you were importing a concrete provider from
|
||||
`@suaveplan/payment-providers`, switch to the matching sibling
|
||||
package. The class names are preserved verbatim:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
import { StripeProvider } from "@suaveplan/payment-providers";
|
||||
|
||||
// After
|
||||
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
|
||||
```
|
||||
|
||||
All shared types (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
|
||||
`HttpOptions`, `ProviderResponse`, `NowFn`, `RandomIdFn`,
|
||||
`Base64EncodeFn`), `httpRequest`, `composeAbortSignal`, the
|
||||
`PaymentProviderError` class, and the `PAYMENT_PROVIDER_CODES`
|
||||
registry continue to be exported from
|
||||
`@suaveplan/payment-providers` unchanged. The Square-specific
|
||||
`SquareConfig` type now ships with
|
||||
`@suaveplan/payment-provider-square`.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/types/provider.md`](./src/types/provider.md) — `ApiKeyConfig`,
|
||||
`OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
|
||||
`ProviderResponse`, plus `NowFn` / `RandomIdFn` / `Base64EncodeFn`
|
||||
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
|
||||
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`, factories
|
||||
(`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`,
|
||||
`providerNotRegistered`, `providerAlreadyRegistered`)
|
||||
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
|
||||
`composeAbortSignal` + `FetchLike`
|
||||
- [`src/registry/registry.md`](./src/registry/registry.md) —
|
||||
`ProviderRegistry`
|
||||
- [`src/registry/define.md`](./src/registry/define.md) —
|
||||
`definePaymentProvider` factory
|
||||
|
||||
## Building a custom provider
|
||||
|
||||
Sibling provider packages — and any consumer adding a non-standard
|
||||
provider — implement the `PaymentProvider` interface from
|
||||
`@suaveplan/payment` and use `httpRequest` from this foundation for
|
||||
every HTTP call:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type ApiKeyConfig,
|
||||
httpRequest,
|
||||
PaymentProviderError,
|
||||
} from "@suaveplan/payment-providers";
|
||||
import type {
|
||||
CreatePaymentIntentInput,
|
||||
PaymentIntent,
|
||||
PaymentProvider,
|
||||
} from "@suaveplan/payment";
|
||||
|
||||
export class CustomProvider implements PaymentProvider {
|
||||
public readonly name = "custom";
|
||||
private readonly config: ApiKeyConfig;
|
||||
|
||||
public constructor(config: ApiKeyConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
input: CreatePaymentIntentInput
|
||||
): Promise<PaymentIntent> {
|
||||
const { data } = await httpRequest<{ id: string }>({
|
||||
method: "POST",
|
||||
url: `${this.config.baseUrl ?? "https://api.custom.com"}/intents`,
|
||||
headers: { "X-API-Key": this.config.apiKey },
|
||||
body: { amount: Number(input.amount.amount) },
|
||||
fetch: this.config.fetch,
|
||||
signal: this.config.signal,
|
||||
timeoutMs: this.config.timeoutMs,
|
||||
provider: this.name,
|
||||
});
|
||||
return mapToPaymentIntent(data);
|
||||
}
|
||||
// ...implement remaining PaymentProvider methods
|
||||
}
|
||||
|
||||
function mapToPaymentIntent(_raw: unknown): PaymentIntent {
|
||||
throw new PaymentProviderError(
|
||||
"PAYMENT_PROVIDER.PARSE_ERROR" as never,
|
||||
"implement mapping"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
|
||||
`PaymentProvider` interface
|
||||
- `@suaveplan/money` — currency-aware arithmetic
|
||||
- `@suaveplan/error` — base error class + `defineCodes` registry helper
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
69
packages/money/payment/payment-providers/package.json
Normal file
69
packages/money/payment/payment-providers/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
105
packages/money/payment/payment-providers/src/http/http.md
Normal file
105
packages/money/payment/payment-providers/src/http/http.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `httpRequest` helper
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal HTTP helper used by every provider to issue typed, cancellable
|
||||
JSON requests. The helper is a thin wrapper over an injectable
|
||||
`FetchLike` (defaulting to `globalThis.fetch`) that JSON-serialises
|
||||
non-string bodies, applies a per-call timeout via `AbortSignal`,
|
||||
composes a caller-supplied `signal` with the internal timeout signal,
|
||||
and converts every failure path into a typed `PaymentProviderError` so
|
||||
callers can branch on `error.code` instead of parsing strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `FetchLike` (`fetch?: FetchLike`) — defaults to
|
||||
`globalThis.fetch`
|
||||
- JSON serialisation of object bodies (string bodies pass through
|
||||
unchanged, enabling `application/x-www-form-urlencoded` flows)
|
||||
- Per-call `timeoutMs` (default `30_000`) via internal `AbortController`
|
||||
- External `signal?: AbortSignal` composed with the internal timeout
|
||||
- Typed errors: `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`
|
||||
- Empty-body handling: 204 / empty responses resolve to `data: {}`
|
||||
- `provider?: string` discriminant surfaced on every raised error
|
||||
- `composeAbortSignal(internal, external)` public helper — reusable by
|
||||
sibling provider packages that issue out-of-band HTTP calls (OAuth
|
||||
token fetches, webhook signature fetches) and want the same
|
||||
caller-signal composition without re-implementing the wiring
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/payment-providers";
|
||||
|
||||
const { status, data } = await httpRequest<{ id: string }>({
|
||||
method: "POST",
|
||||
url: "https://api.example.com/charges",
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
body: { amount: 1000 },
|
||||
timeoutMs: 10_000,
|
||||
provider: "example",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
|
||||
|
||||
// Inject a recording FetchLike for tests — no globalThis monkey-patching.
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetch: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
};
|
||||
|
||||
const external = new AbortController();
|
||||
const promise = httpRequest({
|
||||
method: "GET",
|
||||
url: "https://api.example.com/intent/1",
|
||||
fetch,
|
||||
signal: external.signal,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
// Cancel externally — either signal firing aborts the request.
|
||||
external.abort();
|
||||
await promise.catch((err) => console.log(err.code));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|---|---|---|
|
||||
| `FetchLike` | `type` | `(input: string, init?: RequestInit) => Promise<Response>` |
|
||||
| `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>` | `function` | issues the HTTP call, returns parsed JSON |
|
||||
| `composeAbortSignal(internal, external?): AbortSignal` | `function` | returns `internal` when `external` is undefined; otherwise returns a fresh signal that aborts when either input aborts, with already-aborted external propagating synchronously |
|
||||
|
||||
### `HttpOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `method` | `"GET" \| "POST" \| "DELETE" \| "PATCH"` | — | HTTP verb |
|
||||
| `url` | `string` | — | request URL |
|
||||
| `headers` | `Record<string, string>` | `{}` | extra headers (override the JSON defaults if set) |
|
||||
| `body` | `unknown` | — | JSON-serialised unless already a `string` |
|
||||
| `timeoutMs` | `number` | `30_000` | per-call timeout |
|
||||
| `fetch` | `FetchLike` | `globalThis.fetch` | injectable HTTP client |
|
||||
| `signal` | `AbortSignal` | — | external cancellation |
|
||||
| `provider` | `string` | `"unknown"` | discriminant placed on raised errors |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `AbortController` always wins on timeout — its `abort()` is
|
||||
called from a `setTimeout` whose handle is cleared in `finally`, so no
|
||||
listener leaks past the call. The composed `AbortSignal` is created
|
||||
inline via a third `AbortController`; both source signals
|
||||
(`timeoutController.signal` and the caller's `signal`) propagate to it
|
||||
via `addEventListener("abort", …, { once: true })`, plus an upfront
|
||||
check for the already-aborted case. Non-`Error` throws from the
|
||||
injected `FetchLike` (e.g. raw strings) become `HTTP_ERROR` with a
|
||||
stringified body excerpt — the helper never lets an untyped failure
|
||||
escape.
|
||||
385
packages/money/payment/payment-providers/src/http/http.test.ts
Normal file
385
packages/money/payment/payment-providers/src/http/http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
packages/money/payment/payment-providers/src/http/http.ts
Normal file
152
packages/money/payment/payment-providers/src/http/http.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user