443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
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 {
|
|
const ordered = [...events].sort((left, right) => {
|
|
if (left.sequence !== undefined && right.sequence !== undefined) {
|
|
const numeric = Number(left.sequence) - Number(right.sequence);
|
|
if (Number.isFinite(numeric) && numeric !== 0) return numeric;
|
|
const lexical = String(left.sequence).localeCompare(String(right.sequence));
|
|
if (lexical) return lexical;
|
|
}
|
|
return (
|
|
(left.occurredAt ?? left.at) - (right.occurredAt ?? right.at) ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
});
|
|
let status: PaymentStatus = "created";
|
|
for (const event of ordered) {
|
|
if (status === "refunded") continue;
|
|
if (
|
|
status === "partially_refunded" &&
|
|
!["partially_refunded", "refunded"].includes(event.status)
|
|
)
|
|
continue;
|
|
if (
|
|
status === "captured" &&
|
|
["created", "pending", "authorized", "failed", "cancelled"].includes(event.status)
|
|
)
|
|
continue;
|
|
if (status === "authorized" && ["created", "pending"].includes(event.status)) continue;
|
|
status = event.status;
|
|
}
|
|
return status;
|
|
}
|
|
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(),
|
|
occurredAt: event.occurredAt,
|
|
sequence: event.sequence,
|
|
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,
|
|
providers: () =>
|
|
[...adapters.values()].map((adapter) => ({
|
|
id: adapter.id,
|
|
provider: adapter.provider ?? adapter.id,
|
|
capabilities: adapter.capabilities,
|
|
supports: adapter.supports,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|