feat: add gateway-neutral payment package
This commit is contained in:
1 parent
a9670c2a1c
commit
98e0813061
23 files changed
+1902
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user