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; returnUrl?: string; } export interface PaymentOptions { store: PaymentStore; adapters: readonly PaymentGateway[]; default: string; route?(input: InitializePaymentInput): string; webhookSecrets?: Record; environment?: "development" | "test" | "production"; } const validMoney = (money: Money) => Number.isSafeInteger(money.amount) && money.amount > 0 && /^[A-Z]{3}$/.test(money.currency); const refusal = (reason: string): PaymentResult => ({ ok: false, fault: "refused", reason }); const gatewayFault = (reason: string): PaymentResult => ({ 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(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> { 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 { return status(id); } async function syncPayment(id: string): Promise> { 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> { 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> { 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 { 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> { 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 { 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 { return options.store.methods(subject); } async function attachPaymentMethod( subject: string, gatewayId: string, customerRef: string, token: string, ): Promise> { 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> { 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 = {}; 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; 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; }