223 lines
7.5 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|