Initial Commit
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user