Files
suaveplan/packages/money/payment/payment-provider-adyen/src/adyen/adyen.ts
2026-05-27 23:47:53 +01:00

223 lines
7.5 KiB
TypeScript

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,
});
}
}