feat: add gateway-neutral payment package
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-08-23 21:05:27 +05:30
1 parent a9670c2a1c
commit 98e0813061
23 files changed
+1902 -6

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
import type {
GatewayMethod,
Money,
PaymentEvent,
PaymentGateway,
PaymentIntent,
PaymentMethod,
PaymentRefund,
PaymentResult,
PaymentStatus,
PaymentStore,
WebhookEvent,
} from "./types.ts";
export interface InitializePaymentInput extends Money {
subject: string;
idempotencyKey: string;
gateway?: string;
country?: string;
capture?: "automatic" | "manual";
metadata?: Record<string, string>;
returnUrl?: string;
}
export interface PaymentOptions {
store: PaymentStore;
adapters: readonly PaymentGateway[];
default: string;
route?(input: InitializePaymentInput): string;
webhookSecrets?: Record<string, string>;
environment?: "development" | "test" | "production";
}
const validMoney = (money: Money) =>
Number.isSafeInteger(money.amount) && money.amount > 0 && /^[A-Z]{3}$/.test(money.currency);
const refusal = <T>(reason: string): PaymentResult<T> => ({ ok: false, fault: "refused", reason });
const gatewayFault = <T>(reason: string): PaymentResult<T> => ({
ok: false,
fault: "gateway",
reason,
});
export function derivePaymentStatus(events: readonly PaymentEvent[]): PaymentStatus {
return events.at(-1)?.status ?? "created";
}
export function defineGateway<T extends PaymentGateway>(gateway: T): T {
if (!gateway.id.trim()) throw new Error("WRN-PAYMENT-GATEWAY: id is required");
if (!gateway.capabilities.webhookSignature && process.env.NODE_ENV === "production")
throw new Error(`WRN-PAYMENT-WEBHOOK: gateway '${gateway.id}' cannot verify signatures`);
return Object.freeze(gateway);
}
export function definePayment(options: PaymentOptions) {
const adapters = new Map(options.adapters.map((adapter) => [adapter.id, adapter]));
if (adapters.size !== options.adapters.length)
throw new Error("WRN-PAYMENT-CONFIG: duplicate gateway id");
if (!adapters.has(options.default))
throw new Error(`WRN-PAYMENT-CONFIG: default gateway '${options.default}' is not registered`);
if ((options.environment ?? process.env.NODE_ENV) === "production")
for (const adapter of adapters.values())
if (!adapter.capabilities.webhookSignature)
throw new Error(`WRN-PAYMENT-WEBHOOK: gateway '${adapter.id}' cannot run in production`);
const adapterFor = (id: string) => adapters.get(id);
const status = async (id: string) => derivePaymentStatus(await options.store.events(id));
async function initializePayment(
input: InitializePaymentInput,
): Promise<PaymentResult<PaymentIntent>> {
if (!validMoney(input))
return refusal("amount must be positive minor units and currency must be ISO-4217");
if (!input.subject.trim() || !input.idempotencyKey.trim())
return refusal("subject and idempotencyKey are required");
try {
const existing = await options.store.findIntentByKey(input.idempotencyKey);
if (existing) return { ok: true, value: existing };
const gatewayId = input.gateway ?? options.route?.(input) ?? options.default;
const adapter = adapterFor(gatewayId);
if (!adapter) return refusal(`gateway '${gatewayId}' is not configured`);
if (
adapter.supports.currencies !== "any" &&
!adapter.supports.currencies.includes(input.currency)
)
return refusal(`gateway '${gatewayId}' does not support ${input.currency}`);
if (
input.country &&
adapter.supports.countries !== "any" &&
!adapter.supports.countries.includes(input.country)
)
return refusal(`gateway '${gatewayId}' does not support country ${input.country}`);
if (input.capture === "manual" && !adapter.capabilities.authorizeThenCapture)
return refusal(`gateway '${gatewayId}' does not support authorize-then-capture`);
const id = `pay_${crypto.randomUUID()}`;
const remote = await adapter.createIntent({ ...input, id });
const intent: PaymentIntent = {
id,
subject: input.subject,
amount: input.amount,
currency: input.currency,
gateway: gatewayId,
gatewayRef: remote.ref,
idempotencyKey: input.idempotencyKey,
createdAt: Date.now(),
checkoutUrl: remote.checkoutUrl,
client: adapter.clientConfig(remote),
metadata: { ...(input.metadata ?? {}) },
};
await options.store.putIntent(intent);
await options.store.appendEvent({
id: `local:${id}:created`,
intentId: id,
type: "payment.created",
status: remote.status,
at: Date.now(),
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "gateway failed");
}
}
async function getPayment(id: string) {
return options.store.getIntent(id);
}
async function checkPayment(id: string): Promise<PaymentStatus> {
return status(id);
}
async function syncPayment(id: string): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
const remote = await adapterFor(intent.gateway)!.fetchIntent(intent.gatewayRef);
await options.store.appendEvent({
id: `sync:${intent.gateway}:${remote.ref}:${remote.status}`,
intentId: id,
type: "payment.synced",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "sync failed");
}
}
const confirmPayment = syncPayment;
async function capturePayment(id: string, amount?: Money): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
const adapter = adapterFor(intent.gateway)!;
if (!adapter.capabilities.authorizeThenCapture || !adapter.capture)
return refusal(`gateway '${adapter.id}' does not support capture`);
const remote = await adapter.capture(intent.gatewayRef, amount);
await options.store.appendEvent({
id: `capture:${remote.ref}:${Date.now()}`,
intentId: id,
type: "payment.captured",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "capture failed");
}
}
async function cancelPayment(id: string, reason: string): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
if (!reason.trim()) return refusal("cancellation reason is required");
const remote = await adapterFor(intent.gateway)!.cancelIntent(intent.gatewayRef, reason);
await options.store.appendEvent({
id: `cancel:${remote.ref}:${Date.now()}`,
intentId: id,
type: "payment.cancelled",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "cancellation failed");
}
}
async function refundableAmount(id: string): Promise<Money> {
const intent = await options.store.getIntent(id);
if (!intent) return { amount: 0, currency: "XXX" };
const captured =
(await status(id)) === "captured" ||
(await status(id)) === "partially_refunded" ||
(await status(id)) === "refunded"
? intent.amount
: 0;
const refunded = (await options.store.refunds(id))
.filter((row) => row.status !== "failed")
.reduce((sum, row) => sum + row.amount, 0);
return { amount: Math.max(0, captured - refunded), currency: intent.currency };
}
async function refundPayment(input: {
id: string;
amount?: number;
reason: string;
idempotencyKey: string;
}): Promise<PaymentResult<PaymentRefund>> {
try {
const intent = await options.store.getIntent(input.id);
if (!intent) return refusal("payment not found");
if (!input.reason.trim() || !input.idempotencyKey.trim())
return refusal("refund reason and idempotencyKey are required");
const prior = (await options.store.refunds(input.id)).find(
(row) => row.idempotencyKey === input.idempotencyKey,
);
if (prior) return { ok: true, value: prior };
const available = await refundableAmount(input.id);
const amount = input.amount ?? available.amount;
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > available.amount)
return refusal("refund exceeds the refundable amount");
const adapter = adapterFor(intent.gateway)!;
if (amount < intent.amount && !adapter.capabilities.partialRefund)
return refusal(`gateway '${adapter.id}' does not support partial refunds`);
if (
(await options.store.refunds(input.id)).some((row) => row.status !== "failed") &&
!adapter.capabilities.multipleRefunds
)
return refusal(`gateway '${adapter.id}' does not support multiple refunds`);
const refund: PaymentRefund = {
id: `refund_${crypto.randomUUID()}`,
intentId: intent.id,
gatewayRef: "",
amount,
currency: intent.currency,
reason: input.reason,
idempotencyKey: input.idempotencyKey,
status: "pending",
createdAt: Date.now(),
};
if (!(await options.store.reserveRefund(refund, intent.amount)))
return refusal("refund is duplicated or exceeds the captured amount");
const remote = await adapter.refund({
intentRef: intent.gatewayRef,
amount,
currency: intent.currency,
reason: input.reason,
idempotencyKey: input.idempotencyKey,
});
await options.store.updateRefund(refund.id, remote.status, remote.ref);
const completed = { ...refund, status: remote.status, gatewayRef: remote.ref };
const remaining = available.amount - amount;
await options.store.appendEvent({
id: `refund:${remote.ref}`,
intentId: intent.id,
type: "payment.refunded",
status: remaining === 0 ? "refunded" : "partially_refunded",
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: completed };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "refund failed");
}
}
async function acceptWebhook(gatewayId: string, event: WebhookEvent): Promise<boolean> {
const intent = (await options.store.listIntents({ gateway: gatewayId })).find(
(row) => row.gatewayRef === event.intentRef,
);
if (!intent) return false;
return options.store.appendEvent({
id: `${gatewayId}:${event.id}`,
intentId: intent.id,
type: event.type,
status: event.status,
at: Date.now(),
payload: event.payload,
});
}
function paymentWebhookHandler(gatewayId: string) {
return async (ctx: { req: Request }) => {
const adapter = adapterFor(gatewayId);
const secret = options.webhookSecrets?.[gatewayId];
if (!adapter || !secret)
return Response.json({ error: "gateway webhook is not configured" }, { status: 404 });
try {
const event = await adapter.verifyWebhook(ctx.req, secret);
const accepted = await acceptWebhook(gatewayId, event);
return Response.json({ received: true, duplicate: !accepted });
} catch {
return Response.json({ error: "invalid webhook" }, { status: 400 });
}
};
}
async function paymentMethods(subject: string): Promise<PaymentMethod[]> {
return options.store.methods(subject);
}
async function attachPaymentMethod(
subject: string,
gatewayId: string,
customerRef: string,
token: string,
): Promise<PaymentResult<PaymentMethod>> {
try {
const adapter = adapterFor(gatewayId);
if (!adapter?.capabilities.storedMethods || !adapter.attachMethod)
return refusal(`gateway '${gatewayId}' does not support stored methods`);
const remote: GatewayMethod = await adapter.attachMethod(customerRef, token);
const method: PaymentMethod = {
id: `method_${crypto.randomUUID()}`,
subject,
gateway: gatewayId,
gatewayRef: remote.ref,
brand: remote.brand,
last4: remote.last4,
expires: remote.expires,
default: false,
};
await options.store.putMethod(method);
return { ok: true, value: method };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "method attach failed");
}
}
async function detachPaymentMethod(id: string): Promise<PaymentResult<void>> {
const method = await options.store.getMethod(id);
if (!method) return refusal("payment method not found");
const adapter = adapterFor(method.gateway);
try {
await adapter?.detachMethod?.(method.gatewayRef);
await options.store.removeMethod(id);
return { ok: true, value: undefined };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "method detach failed");
}
}
async function setDefaultPaymentMethod(subject: string, id: string) {
await options.store.defaultMethod(subject, id);
}
async function listPayments(filter?: { subject?: string; gateway?: string }) {
return options.store.listIntents(filter);
}
async function listRefunds(id: string) {
return options.store.refunds(id);
}
async function paymentTotals(filter?: { subject?: string; gateway?: string }) {
const rows = await options.store.listIntents(filter);
const byCurrency: Record<string, { captured: number; refunded: number; net: number }> = {};
for (const row of rows) {
const state = await status(row.id);
const captured = ["captured", "partially_refunded", "refunded"].includes(state)
? row.amount
: 0;
const refunded = (await options.store.refunds(row.id))
.filter((x) => x.status === "succeeded")
.reduce((s, x) => s + x.amount, 0);
const total = (byCurrency[row.currency] ??= { captured: 0, refunded: 0, net: 0 });
total.captured += captured;
total.refunded += refunded;
total.net = total.captured - total.refunded;
}
return { byCurrency };
}
async function reconcilePayments() {
const discrepancies: unknown[] = [];
for (const intent of await options.store.listIntents()) {
const local = await status(intent.id);
const remote = (await adapterFor(intent.gateway)!.fetchIntent(intent.gatewayRef)).status;
if (local !== remote)
discrepancies.push({ id: intent.id, local, remote, gateway: intent.gateway });
}
return discrepancies;
}
return {
initializePayment,
getPayment,
listPayments,
checkPayment,
confirmPayment,
syncPayment,
capturePayment,
cancelPayment,
refundPayment,
listRefunds,
refundableAmount,
paymentMethods,
attachPaymentMethod,
detachPaymentMethod,
setDefaultPaymentMethod,
paymentTotals,
reconcilePayments,
paymentWebhookHandler,
capabilitiesOf: (id: string) => adapterFor(id)?.capabilities,
};
}
export type PaymentService = ReturnType<typeof definePayment>;
const SERVICE = Symbol.for("@wrnexus/payment:service:v1");
type PaymentGlobal = typeof globalThis & { [SERVICE]?: PaymentService };
export function configurePayment(options: PaymentOptions): PaymentService {
const service = definePayment(options);
(globalThis as PaymentGlobal)[SERVICE] = service;
return service;
}
export function getPaymentService(): PaymentService {
const service = (globalThis as PaymentGlobal)[SERVICE];
if (!service)
throw new Error("WRN-PAYMENT-SETUP: call configurePayment() during application startup");
return service;
}