diff --git a/bun.lock b/bun.lock index 503c6821..f1fa015f 100644 --- a/bun.lock +++ b/bun.lock @@ -292,7 +292,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.59", + "version": "0.8.60", "bin": { "wrnexus": "src/index.ts", }, @@ -320,7 +320,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.20", + "version": "0.8.21", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -357,7 +357,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.51", + "version": "0.8.52", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -487,7 +487,7 @@ }, "packages/mail": { "name": "@wrnexus/mail", - "version": "0.8.1", + "version": "0.8.2", "dependencies": { "@wrnexus/queue": "workspace:*", }, @@ -528,6 +528,14 @@ "@wrnexus/core": "workspace:*", }, }, + "packages/payment": { + "name": "@wrnexus/payment", + "version": "0.8.0", + "dependencies": { + "@wrnexus/db": "workspace:*", + "@wrnexus/plugin": "workspace:*", + }, + }, "packages/playground": { "name": "@wrnexus/playground", "version": "0.8.8", @@ -561,7 +569,7 @@ }, "packages/queue": { "name": "@wrnexus/queue", - "version": "0.8.13", + "version": "0.8.14", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/db": "workspace:*", @@ -1066,6 +1074,8 @@ "@wrnexus/observability": ["@wrnexus/observability@workspace:packages/observability"], + "@wrnexus/payment": ["@wrnexus/payment@workspace:packages/payment"], + "@wrnexus/playground": ["@wrnexus/playground@workspace:packages/playground"], "@wrnexus/plugin": ["@wrnexus/plugin@workspace:packages/plugin"], diff --git a/packages/payment/README.md b/packages/payment/README.md new file mode 100644 index 00000000..34e0ac1b --- /dev/null +++ b/packages/payment/README.md @@ -0,0 +1,5 @@ +# @wrnexus/payment + +Gateway-neutral, webhook-authoritative payments for WrNexus. Amounts are integer minor units. Every initialize and refund operation requires an idempotency key. The package never accepts or stores card numbers, CVVs, or expiry input; its components navigate to provider-hosted checkout. + +Built-in adapters: offline `sandbox`, Stripe, Razorpay, and PayPal. Applications may add adapters with `defineGateway`. Capabilities are explicit, unsupported operations are refused, signed webhooks are mandatory in production, and package-owned migrations provision the append-only event ledger. diff --git a/packages/payment/components/PayNow.wrn b/packages/payment/components/PayNow.wrn new file mode 100644 index 00000000..d0a7b7f4 --- /dev/null +++ b/packages/payment/components/PayNow.wrn @@ -0,0 +1,8 @@ +component PayNow { + props { checkoutUrl: string label?: string = "Pay now" disabled?: boolean = false } + view { + + {label} + + } +} diff --git a/packages/payment/components/PaymentHistory.wrn b/packages/payment/components/PaymentHistory.wrn new file mode 100644 index 00000000..dadf2a98 --- /dev/null +++ b/packages/payment/components/PaymentHistory.wrn @@ -0,0 +1,4 @@ +component PaymentHistory { + props { payments: array = [] } + view {
    {#each payments as payment}
  1. {payment.currency} {payment.amount} — {payment.status}
  2. {/each}
} +} diff --git a/packages/payment/components/PaymentMethodList.wrn b/packages/payment/components/PaymentMethodList.wrn new file mode 100644 index 00000000..fdffba02 --- /dev/null +++ b/packages/payment/components/PaymentMethodList.wrn @@ -0,0 +1,4 @@ +component PaymentMethodList { + props { methods: array = [] } + view { } +} diff --git a/packages/payment/components/PaymentSheet.wrn b/packages/payment/components/PaymentSheet.wrn new file mode 100644 index 00000000..fafe2001 --- /dev/null +++ b/packages/payment/components/PaymentSheet.wrn @@ -0,0 +1,4 @@ +component PaymentSheet { + props { checkoutUrl: string gateway: string } + view {

Secure payment is completed on the payment provider.

Continue securely
} +} diff --git a/packages/payment/components/PaymentStatus.wrn b/packages/payment/components/PaymentStatus.wrn new file mode 100644 index 00000000..9f8dec32 --- /dev/null +++ b/packages/payment/components/PaymentStatus.wrn @@ -0,0 +1,4 @@ +component PaymentStatus { + props { status: string label?: string = "Payment status" } + view {
{label}{status}
} +} diff --git a/packages/payment/components/PaymentSummary.wrn b/packages/payment/components/PaymentSummary.wrn new file mode 100644 index 00000000..93c1a714 --- /dev/null +++ b/packages/payment/components/PaymentSummary.wrn @@ -0,0 +1,4 @@ +component PaymentSummary { + props { captured: number = 0 refunded: number = 0 net: number = 0 currency: string } + view {
Captured
{currency} {captured}
Refunded
{currency} {refunded}
Net
{currency} {net}
} +} diff --git a/packages/payment/components/RefundButton.wrn b/packages/payment/components/RefundButton.wrn new file mode 100644 index 00000000..47aae034 --- /dev/null +++ b/packages/payment/components/RefundButton.wrn @@ -0,0 +1,4 @@ +component RefundButton { + props { paymentId: string refundable: number partial?: boolean = false action?: string = "/api/payments/refund" } + view {
{#if partial}{/if}
} +} diff --git a/packages/payment/package.json b/packages/payment/package.json new file mode 100644 index 00000000..0293c2bb --- /dev/null +++ b/packages/payment/package.json @@ -0,0 +1,22 @@ +{ + "name": "@wrnexus/payment", + "version": "0.8.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "files": [ + "src", + "components", + "README.md" + ], + "exports": { + ".": "./src/index.ts", + "./adapters": "./src/adapters.ts", + "./plugin": "./src/plugin.ts", + "./runtime-webhook": "./src/runtime-webhook.ts" + }, + "dependencies": { + "@wrnexus/db": "workspace:*", + "@wrnexus/plugin": "workspace:*" + } +} diff --git a/packages/payment/src/adapters.ts b/packages/payment/src/adapters.ts new file mode 100644 index 00000000..91f10610 --- /dev/null +++ b/packages/payment/src/adapters.ts @@ -0,0 +1,550 @@ +import { defineGateway } from "./core.ts"; +import type { + GatewayCapabilities, + GatewayIntent, + GatewayRefund, + PaymentGateway, + PaymentStatus, +} from "./types.ts"; + +const encoder = new TextEncoder(); +const hex = (bytes: ArrayBuffer) => + [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, "0")).join(""); +async function hmac(body: string, secret: string) { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hex(await crypto.subtle.sign("HMAC", key, encoder.encode(body))); +} +function equal(left: string, right: string) { + if (left.length !== right.length) return false; + let difference = 0; + for (let i = 0; i < left.length; i++) difference |= left.charCodeAt(i) ^ right.charCodeAt(i); + return difference === 0; +} +const CAPABILITIES: GatewayCapabilities = { + authorizeThenCapture: false, + partialRefund: true, + multipleRefunds: true, + storedMethods: false, + customerVault: false, + hostedFields: false, + hostedCheckout: true, + webhookSignature: true, + payouts: false, + disputes: false, +}; +const statusOf = (value: string): PaymentStatus => + (({ + succeeded: "captured", + captured: "captured", + paid: "captured", + authorized: "authorized", + requires_capture: "authorized", + cancelled: "cancelled", + canceled: "cancelled", + failed: "failed", + refunded: "refunded", + })[value] as PaymentStatus | undefined) ?? "pending"; + +export interface SandboxOptions { + secret?: string; + failure?: "create" | "refund" | "capture"; + authorize?: boolean; +} +export function sandboxGateway(options: SandboxOptions = {}): PaymentGateway { + const intents = new Map(); + const refunds = new Map(); + return defineGateway({ + id: "sandbox", + capabilities: { + ...CAPABILITIES, + authorizeThenCapture: true, + storedMethods: true, + customerVault: true, + hostedFields: true, + }, + supports: { currencies: "any", countries: "any" }, + async createIntent(input) { + if (options.failure === "create") throw new Error("sandbox create failure"); + const value = { + ref: `sb_${input.id}`, + status: (options.authorize || input.capture === "manual" + ? "authorized" + : "pending") as PaymentStatus, + amount: input.amount, + currency: input.currency, + checkoutUrl: `https://sandbox.wrnexus.test/pay/${input.id}`, + clientSecret: `sandbox_${input.id}`, + }; + intents.set(value.ref, value); + return value; + }, + async fetchIntent(ref) { + const value = intents.get(ref); + if (!value) throw new Error("sandbox intent not found"); + return structuredClone(value); + }, + async cancelIntent(ref) { + const value = await this.fetchIntent(ref); + const next = { ...value, status: "cancelled" as const }; + intents.set(ref, next); + return next; + }, + async capture(ref, amount) { + if (options.failure === "capture") throw new Error("sandbox capture failure"); + const value = await this.fetchIntent(ref); + const next = { + ...value, + status: "captured" as const, + amount: amount?.amount ?? value.amount, + }; + intents.set(ref, next); + return next; + }, + async refund(input) { + if (options.failure === "refund") throw new Error("sandbox refund failure"); + const existing = refunds.get(input.idempotencyKey); + if (existing) return existing; + const value = { + ref: `sbr_${crypto.randomUUID()}`, + status: "succeeded" as const, + amount: input.amount, + }; + refunds.set(input.idempotencyKey, value); + return value; + }, + async verifyWebhook(req, expected) { + const body = await req.text(); + const signature = req.headers.get("x-wrnexus-signature") ?? ""; + if (!equal(signature, await hmac(body, expected))) + throw new Error("invalid sandbox signature"); + const payload = JSON.parse(body); + const intent = intents.get(payload.intentRef); + if (intent) intents.set(payload.intentRef, { ...intent, status: payload.status }); + return { + id: String(payload.id), + intentRef: String(payload.intentRef), + type: String(payload.type ?? "payment.updated"), + status: payload.status, + payload, + }; + }, + async listMethods() { + return []; + }, + async attachMethod(_customer, token) { + return { ref: `sbm_${token}`, brand: "sandbox", last4: token.slice(-4) }; + }, + async detachMethod() {}, + async createCustomer(input) { + return `sb_customer_${input.subject}`; + }, + clientConfig(intent) { + return { + gateway: "sandbox", + clientSecret: intent.clientSecret ?? "", + checkoutUrl: intent.checkoutUrl ?? "", + }; + }, + }); +} +export async function signSandboxWebhook(payload: unknown, secret = "sandbox-secret") { + const body = JSON.stringify(payload); + return { body, signature: await hmac(body, secret) }; +} + +async function jsonRequest(url: string, init: RequestInit) { + const response = await fetch(url, init); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`payment gateway request failed (${response.status})`); + return data as any; +} +export interface StripeConfig { + secretKey: string; + publishableKey: string; + webhookSecret: string; + apiVersion?: string; +} +export function stripeGateway(config: StripeConfig): PaymentGateway { + const auth = { + authorization: `Bearer ${config.secretKey}`, + "content-type": "application/x-www-form-urlencoded", + "stripe-version": config.apiVersion ?? "2026-03-31", + }; + const form = (data: Record) => + new URLSearchParams( + Object.entries(data) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]), + ); + return defineGateway({ + id: "stripe", + config, + capabilities: { + ...CAPABILITIES, + authorizeThenCapture: true, + storedMethods: true, + customerVault: true, + hostedFields: true, + disputes: true, + }, + supports: { currencies: "any", countries: "any" }, + async createIntent(input) { + const row = await jsonRequest("https://api.stripe.com/v1/payment_intents", { + method: "POST", + headers: { ...auth, "idempotency-key": input.idempotencyKey }, + body: form({ + amount: input.amount, + currency: input.currency.toLowerCase(), + capture_method: input.capture === "manual" ? "manual" : "automatic", + "metadata[wrnexus_id]": input.id, + }), + }); + return { + ref: row.id, + status: statusOf(row.status), + amount: row.amount, + currency: String(row.currency).toUpperCase(), + clientSecret: row.client_secret, + raw: row, + }; + }, + async fetchIntent(ref) { + const row = await jsonRequest( + `https://api.stripe.com/v1/payment_intents/${encodeURIComponent(ref)}`, + { headers: auth }, + ); + return { + ref: row.id, + status: statusOf(row.status), + amount: row.amount, + currency: String(row.currency).toUpperCase(), + raw: row, + }; + }, + async cancelIntent(ref, reason) { + const row = await jsonRequest( + `https://api.stripe.com/v1/payment_intents/${encodeURIComponent(ref)}/cancel`, + { method: "POST", headers: auth, body: form({ cancellation_reason: reason }) }, + ); + return { + ref: row.id, + status: "cancelled", + amount: row.amount, + currency: String(row.currency).toUpperCase(), + raw: row, + }; + }, + async capture(ref, amount) { + const row = await jsonRequest( + `https://api.stripe.com/v1/payment_intents/${encodeURIComponent(ref)}/capture`, + { method: "POST", headers: auth, body: form({ amount_to_capture: amount?.amount }) }, + ); + return { + ref: row.id, + status: statusOf(row.status), + amount: row.amount_received, + currency: String(row.currency).toUpperCase(), + raw: row, + }; + }, + async refund(input) { + const row = await jsonRequest("https://api.stripe.com/v1/refunds", { + method: "POST", + headers: { ...auth, "idempotency-key": input.idempotencyKey }, + body: form({ + payment_intent: input.intentRef, + amount: input.amount, + reason: "requested_by_customer", + }), + }); + return { + ref: row.id, + status: row.status === "succeeded" ? "succeeded" : "pending", + amount: row.amount, + raw: row, + }; + }, + async verifyWebhook(req, secret) { + const body = await req.text(); + const header = req.headers.get("stripe-signature") ?? ""; + const timestamp = /t=([^,]+)/.exec(header)?.[1] ?? ""; + const signature = /v1=([^,]+)/.exec(header)?.[1] ?? ""; + if (!timestamp || !equal(signature, await hmac(`${timestamp}.${body}`, secret))) + throw new Error("invalid Stripe signature"); + const row = JSON.parse(body), + object = row.data.object; + return { + id: row.id, + intentRef: object.payment_intent ?? object.id, + type: row.type, + status: statusOf(object.status), + payload: row, + }; + }, + async listMethods(customerRef) { + const row = await jsonRequest( + `https://api.stripe.com/v1/payment_methods?customer=${encodeURIComponent(customerRef)}&type=card`, + { headers: auth }, + ); + return (row.data ?? []).map((method: any) => ({ + ref: method.id, + brand: method.card?.brand, + last4: method.card?.last4, + expires: method.card ? `${method.card.exp_month}/${method.card.exp_year}` : undefined, + })); + }, + async attachMethod(customerRef, token) { + const row = await jsonRequest( + `https://api.stripe.com/v1/payment_methods/${encodeURIComponent(token)}/attach`, + { method: "POST", headers: auth, body: form({ customer: customerRef }) }, + ); + return { + ref: row.id, + brand: row.card?.brand, + last4: row.card?.last4, + expires: row.card ? `${row.card.exp_month}/${row.card.exp_year}` : undefined, + }; + }, + async detachMethod(methodRef) { + await jsonRequest( + `https://api.stripe.com/v1/payment_methods/${encodeURIComponent(methodRef)}/detach`, + { method: "POST", headers: auth }, + ); + }, + async createCustomer(input) { + const row = await jsonRequest("https://api.stripe.com/v1/customers", { + method: "POST", + headers: auth, + body: form({ email: input.email, name: input.name, "metadata[subject]": input.subject }), + }); + return row.id; + }, + clientConfig(intent) { + return { + gateway: "stripe", + publishableKey: config.publishableKey, + clientSecret: intent.clientSecret ?? "", + }; + }, + }); +} +export interface RazorpayConfig { + keyId: string; + keySecret: string; + webhookSecret: string; + theme?: { color?: string }; +} +export function razorpayGateway(config: RazorpayConfig): PaymentGateway { + const auth = `Basic ${btoa(`${config.keyId}:${config.keySecret}`)}`; + const request = (path: string, init: RequestInit = {}) => + jsonRequest(`https://api.razorpay.com/v1${path}`, { + ...init, + headers: { authorization: auth, "content-type": "application/json", ...(init.headers ?? {}) }, + }); + return defineGateway({ + id: "razorpay", + config, + capabilities: { + ...CAPABILITIES, + authorizeThenCapture: false, + storedMethods: false, + customerVault: false, + }, + supports: { currencies: ["INR"], countries: ["IN"] }, + async createIntent(input) { + const row = await request("/orders", { + method: "POST", + body: JSON.stringify({ + amount: input.amount, + currency: input.currency, + receipt: input.id, + notes: input.metadata, + }), + }); + return { + ref: row.id, + status: "pending", + amount: row.amount, + currency: row.currency, + raw: row, + }; + }, + async fetchIntent(ref) { + const row = await request(`/orders/${encodeURIComponent(ref)}`); + return { + ref: row.id, + status: statusOf(row.status), + amount: row.amount, + currency: row.currency, + raw: row, + }; + }, + async cancelIntent(ref) { + const row = await this.fetchIntent(ref); + return { ...row, status: "cancelled" }; + }, + async refund(input) { + const row = await request(`/payments/${encodeURIComponent(input.intentRef)}/refund`, { + method: "POST", + headers: { "X-Razorpay-Idempotency": input.idempotencyKey }, + body: JSON.stringify({ amount: input.amount, notes: { reason: input.reason } }), + }); + return { + ref: row.id, + status: row.status === "processed" ? "succeeded" : "pending", + amount: row.amount, + raw: row, + }; + }, + async verifyWebhook(req, secret) { + const body = await req.text(); + if (!equal(req.headers.get("x-razorpay-signature") ?? "", await hmac(body, secret))) + throw new Error("invalid Razorpay signature"); + const row = JSON.parse(body), + payment = row.payload?.payment?.entity ?? row.payload?.order?.entity; + return { + id: row.id ?? `${row.event}:${payment.id}`, + intentRef: payment.order_id ?? payment.id, + type: row.event, + status: statusOf(payment.status), + payload: row, + }; + }, + clientConfig(intent) { + return { + gateway: "razorpay", + keyId: config.keyId, + orderId: intent.ref, + themeColor: config.theme?.color ?? "", + }; + }, + }); +} + +export interface PayPalConfig { + clientId: string; + clientSecret: string; + webhookId: string; + environment?: "sandbox" | "live"; +} +export function paypalGateway(config: PayPalConfig): PaymentGateway { + const base = + config.environment === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; + let token = ""; + async function headers() { + if (!token) { + const row = await jsonRequest(`${base}/v1/oauth2/token`, { + method: "POST", + headers: { + authorization: `Basic ${btoa(`${config.clientId}:${config.clientSecret}`)}`, + "content-type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + token = row.access_token; + } + return { authorization: `Bearer ${token}`, "content-type": "application/json" }; + } + const request = async (path: string, init: RequestInit = {}) => + jsonRequest(`${base}${path}`, { + ...init, + headers: { ...(await headers()), ...(init.headers ?? {}) }, + }); + return defineGateway({ + id: "paypal", + config, + capabilities: { ...CAPABILITIES, partialRefund: true, multipleRefunds: true }, + supports: { currencies: "any", countries: "any" }, + async createIntent(input) { + const row = await request("/v2/checkout/orders", { + method: "POST", + headers: { "PayPal-Request-Id": input.idempotencyKey }, + body: JSON.stringify({ + intent: "CAPTURE", + purchase_units: [ + { + reference_id: input.id, + amount: { currency_code: input.currency, value: (input.amount / 100).toFixed(2) }, + }, + ], + application_context: { return_url: input.returnUrl }, + }), + }); + return { + ref: row.id, + status: statusOf(String(row.status).toLowerCase()), + amount: input.amount, + currency: input.currency, + checkoutUrl: row.links?.find((x: any) => x.rel === "approve")?.href, + raw: row, + }; + }, + async fetchIntent(ref) { + const row = await request(`/v2/checkout/orders/${encodeURIComponent(ref)}`); + const unit = row.purchase_units?.[0]?.amount; + return { + ref: row.id, + status: statusOf(String(row.status).toLowerCase()), + amount: Math.round(Number(unit?.value ?? 0) * 100), + currency: unit?.currency_code ?? "USD", + raw: row, + }; + }, + async cancelIntent(ref) { + const row = await this.fetchIntent(ref); + return { ...row, status: "cancelled" }; + }, + async refund(input) { + const row = await request( + `/v2/payments/captures/${encodeURIComponent(input.intentRef)}/refund`, + { + method: "POST", + headers: { "PayPal-Request-Id": input.idempotencyKey }, + body: JSON.stringify({ + amount: { value: (input.amount / 100).toFixed(2), currency_code: input.currency }, + note_to_payer: input.reason, + }), + }, + ); + return { + ref: row.id, + status: String(row.status).toUpperCase() === "COMPLETED" ? "succeeded" : "pending", + amount: input.amount, + raw: row, + }; + }, + async verifyWebhook(req) { + const row = await req.json(); + const verification = await request("/v1/notifications/verify-webhook-signature", { + method: "POST", + body: JSON.stringify({ + auth_algo: req.headers.get("paypal-auth-algo"), + cert_url: req.headers.get("paypal-cert-url"), + transmission_id: req.headers.get("paypal-transmission-id"), + transmission_sig: req.headers.get("paypal-transmission-sig"), + transmission_time: req.headers.get("paypal-transmission-time"), + webhook_id: config.webhookId, + webhook_event: row, + }), + }); + if (verification.verification_status !== "SUCCESS") + throw new Error("invalid PayPal signature"); + return { + id: row.id, + intentRef: row.resource?.supplementary_data?.related_ids?.order_id ?? row.resource?.id, + type: row.event_type, + status: statusOf(String(row.resource?.status ?? "").toLowerCase()), + payload: row, + }; + }, + clientConfig(intent) { + return { gateway: "paypal", clientId: config.clientId, orderId: intent.ref }; + }, + }); +} diff --git a/packages/payment/src/contract.ts b/packages/payment/src/contract.ts new file mode 100644 index 00000000..8766f609 --- /dev/null +++ b/packages/payment/src/contract.ts @@ -0,0 +1,38 @@ +import type { PaymentGateway } from "./types.ts"; + +export interface GatewayContractReport { + ok: boolean; + issues: string[]; +} +/** Structural half of the shared adapter suite, usable by third-party adapters in CI. */ +export function verifyGatewayContract(adapter: PaymentGateway): GatewayContractReport { + const issues: string[] = []; + const capability = (name: string, declared: boolean, present: boolean) => { + if (declared !== present) + issues.push( + `${adapter.id}: capability '${name}' is ${declared ? "declared but not implemented" : "implemented but not declared"}`, + ); + }; + capability( + "authorizeThenCapture", + adapter.capabilities.authorizeThenCapture, + typeof adapter.capture === "function", + ); + capability( + "storedMethods", + adapter.capabilities.storedMethods, + typeof adapter.listMethods === "function" && + typeof adapter.attachMethod === "function" && + typeof adapter.detachMethod === "function", + ); + capability( + "customerVault", + adapter.capabilities.customerVault, + typeof adapter.createCustomer === "function", + ); + if (!adapter.capabilities.hostedFields && !adapter.capabilities.hostedCheckout) + issues.push(`${adapter.id}: a hosted payment surface is required`); + if (!adapter.capabilities.webhookSignature) + issues.push(`${adapter.id}: signed webhooks are required`); + return { ok: issues.length === 0, issues }; +} diff --git a/packages/payment/src/core.ts b/packages/payment/src/core.ts new file mode 100644 index 00000000..91aefad9 --- /dev/null +++ b/packages/payment/src/core.ts @@ -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; + 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 { + return events.at(-1)?.status ?? "created"; +} +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(), + 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, + }; +} + +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; +} diff --git a/packages/payment/src/database.ts b/packages/payment/src/database.ts new file mode 100644 index 00000000..cebc8e22 --- /dev/null +++ b/packages/payment/src/database.ts @@ -0,0 +1,209 @@ +import { getDb } from "@wrnexus/db"; +import type { PaymentIntent, PaymentRefund, PaymentStore } from "./types.ts"; + +const parse = (value: unknown, fallback: T): T => { + try { + return typeof value === "string" ? JSON.parse(value) : (value as T); + } catch { + return fallback; + } +}; +export function databasePaymentStore(database = "default"): PaymentStore { + const db = () => getDb(database); + const intent = (row: any): PaymentIntent => ({ + id: String(row.id), + subject: String(row.subject), + gateway: String(row.gateway), + gatewayRef: String(row.gateway_ref), + amount: Number(row.amount), + currency: String(row.currency), + idempotencyKey: String(row.idempotency_key), + createdAt: Number(row.created_at), + checkoutUrl: row.checkout_url ? String(row.checkout_url) : undefined, + client: parse(row.client_json, {}), + metadata: parse(row.metadata_json, {}), + }); + const refund = (row: any): PaymentRefund => ({ + id: String(row.id), + intentId: String(row.intent_id), + gatewayRef: String(row.gateway_ref ?? ""), + amount: Number(row.amount), + currency: String(row.currency), + reason: String(row.reason), + idempotencyKey: String(row.idempotency_key), + status: row.status, + createdAt: Number(row.created_at), + }); + return { + async findIntentByKey(key) { + const row = await db().one("SELECT * FROM wrn_payment_intent WHERE idempotency_key = ?", [ + key, + ]); + return row ? intent(row) : null; + }, + async putIntent(row) { + await db().exec( + "INSERT INTO wrn_payment_intent (id,subject,gateway,gateway_ref,amount,currency,idempotency_key,created_at,checkout_url,client_json,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + [ + row.id, + row.subject, + row.gateway, + row.gatewayRef, + row.amount, + row.currency, + row.idempotencyKey, + row.createdAt, + row.checkoutUrl ?? null, + JSON.stringify(row.client), + JSON.stringify(row.metadata), + ], + ); + }, + async getIntent(id) { + const row = await db().one("SELECT * FROM wrn_payment_intent WHERE id = ?", [id]); + return row ? intent(row) : null; + }, + async listIntents(filter = {}) { + const clauses: string[] = [], + values: unknown[] = []; + if (filter.subject) { + clauses.push("subject = ?"); + values.push(filter.subject); + } + if (filter.gateway) { + clauses.push("gateway = ?"); + values.push(filter.gateway); + } + return ( + await db().all( + `SELECT * FROM wrn_payment_intent${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at DESC`, + values, + ) + ).map(intent); + }, + async appendEvent(row) { + try { + const result = await db().exec( + "INSERT INTO wrn_payment_event (id,intent_id,type,status,created_at,payload_json) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING", + [row.id, row.intentId, row.type, row.status, row.at, JSON.stringify(row.payload ?? null)], + ); + return result.changes > 0; + } catch { + return false; + } + }, + async events(id) { + return ( + await db().all( + "SELECT * FROM wrn_payment_event WHERE intent_id = ? ORDER BY created_at ASC, id ASC", + [id], + ) + ).map((row) => ({ + id: String(row.id), + intentId: String(row.intent_id), + type: String(row.type), + status: row.status, + at: Number(row.created_at), + payload: parse(row.payload_json, null), + })); + }, + async reserveRefund(row, captured) { + try { + const result = await db().exec( + "INSERT INTO wrn_payment_refund (id,intent_id,gateway_ref,amount,currency,reason,idempotency_key,status,created_at) SELECT ?,?,?,?,?,?,?,?,? WHERE ? >= COALESCE((SELECT SUM(amount) FROM wrn_payment_refund WHERE intent_id = ? AND status <> 'failed'),0) + ?", + [ + row.id, + row.intentId, + row.gatewayRef, + row.amount, + row.currency, + row.reason, + row.idempotencyKey, + row.status, + row.createdAt, + captured, + row.intentId, + row.amount, + ], + ); + return result.changes > 0; + } catch { + return false; + } + }, + async updateRefund(id, status, gatewayRef) { + await db().exec("UPDATE wrn_payment_refund SET status = ?, gateway_ref = ? WHERE id = ?", [ + status, + gatewayRef, + id, + ]); + }, + async refunds(id) { + return ( + await db().all("SELECT * FROM wrn_payment_refund WHERE intent_id = ? ORDER BY created_at", [ + id, + ]) + ).map(refund); + }, + async methods(subject) { + return ( + await db().all( + "SELECT * FROM wrn_payment_method WHERE subject = ? ORDER BY is_default DESC, created_at", + [subject], + ) + ).map((row) => ({ + id: String(row.id), + subject: String(row.subject), + gateway: String(row.gateway), + gatewayRef: String(row.gateway_ref), + brand: row.brand ? String(row.brand) : undefined, + last4: row.last4 ? String(row.last4) : undefined, + expires: row.expires ? String(row.expires) : undefined, + default: Boolean(row.is_default), + })); + }, + async getMethod(id) { + const row = await db().one("SELECT * FROM wrn_payment_method WHERE id = ?", [id]); + return row + ? { + id: String(row.id), + subject: String(row.subject), + gateway: String(row.gateway), + gatewayRef: String(row.gateway_ref), + brand: row.brand ? String(row.brand) : undefined, + last4: row.last4 ? String(row.last4) : undefined, + expires: row.expires ? String(row.expires) : undefined, + default: Boolean(row.is_default), + } + : null; + }, + async putMethod(row) { + await db().exec( + "INSERT INTO wrn_payment_method (id,subject,gateway,gateway_ref,brand,last4,expires,is_default,created_at) VALUES (?,?,?,?,?,?,?,?,?)", + [ + row.id, + row.subject, + row.gateway, + row.gatewayRef, + row.brand ?? null, + row.last4 ?? null, + row.expires ?? null, + row.default ? 1 : 0, + Date.now(), + ], + ); + }, + async removeMethod(id) { + await db().exec("DELETE FROM wrn_payment_method WHERE id = ?", [id]); + }, + async defaultMethod(subject, id) { + await db().tx(async (tx) => { + await tx.exec("UPDATE wrn_payment_method SET is_default = 0 WHERE subject = ?", [subject]); + await tx.exec("UPDATE wrn_payment_method SET is_default = 1 WHERE subject = ? AND id = ?", [ + subject, + id, + ]); + }); + }, + }; +} diff --git a/packages/payment/src/index.ts b/packages/payment/src/index.ts new file mode 100644 index 00000000..830c5ea4 --- /dev/null +++ b/packages/payment/src/index.ts @@ -0,0 +1,7 @@ +export * from "./types.ts"; +export * from "./store.ts"; +export * from "./database.ts"; +export * from "./core.ts"; +export * from "./adapters.ts"; +export * from "./contract.ts"; +export { paymentPlugin } from "./plugin.ts"; diff --git a/packages/payment/src/plugin.ts b/packages/payment/src/plugin.ts new file mode 100644 index 00000000..d9041458 --- /dev/null +++ b/packages/payment/src/plugin.ts @@ -0,0 +1,53 @@ +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { definePlugin, type PluginContext } from "@wrnexus/plugin"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const extension = basename(dirname(fileURLToPath(import.meta.url))) === "dist" ? ".js" : ".ts"; +const webhookEntry = join(dirname(fileURLToPath(import.meta.url)), `runtime-webhook${extension}`); +const migration = `-- +up +CREATE TABLE IF NOT EXISTS wrn_payment_intent (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, checkout_url TEXT, client_json TEXT NOT NULL, metadata_json TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS wrn_payment_intent_subject ON wrn_payment_intent(subject, created_at); +CREATE TABLE IF NOT EXISTS wrn_payment_event (id TEXT PRIMARY KEY, intent_id TEXT NOT NULL, type TEXT NOT NULL, status TEXT NOT NULL, created_at BIGINT NOT NULL, payload_json TEXT, FOREIGN KEY(intent_id) REFERENCES wrn_payment_intent(id)); +CREATE INDEX IF NOT EXISTS wrn_payment_event_intent ON wrn_payment_event(intent_id, created_at, id); +CREATE TABLE IF NOT EXISTS wrn_payment_refund (id TEXT PRIMARY KEY, intent_id TEXT NOT NULL, gateway_ref TEXT NOT NULL, amount BIGINT NOT NULL, currency TEXT NOT NULL, reason TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, status TEXT NOT NULL, created_at BIGINT NOT NULL, FOREIGN KEY(intent_id) REFERENCES wrn_payment_intent(id)); +CREATE TABLE IF NOT EXISTS wrn_payment_method (id TEXT PRIMARY KEY, subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, brand TEXT, last4 TEXT, expires TEXT, is_default INTEGER NOT NULL DEFAULT 0, created_at BIGINT NOT NULL); +CREATE TABLE IF NOT EXISTS wrn_payment_customer (subject TEXT NOT NULL, gateway TEXT NOT NULL, gateway_ref TEXT NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY(subject,gateway)); +-- +down +DROP TABLE IF EXISTS wrn_payment_customer; +DROP TABLE IF EXISTS wrn_payment_method; +DROP TABLE IF EXISTS wrn_payment_refund; +DROP TABLE IF EXISTS wrn_payment_event; +DROP TABLE IF EXISTS wrn_payment_intent;`; + +export function paymentPlugin() { + const key = "@wrnexus/payment:enabled"; + return definePlugin({ + name: "@wrnexus/payment", + version: "0.8.0", + componentDirs: [join(root, "components")], + routeEntries(context) { + return context.metadata.get(key) + ? [{ kind: "api" as const, path: "/api/payments/webhook/:gateway", entry: webhookEntry }] + : []; + }, + configure(config, context) { + const value = config.payment as + { default?: string; gateways?: Record; sandbox?: boolean } | undefined; + context.metadata.set(key, Boolean(value)); + if (!value) return; + if (!value.default) throw new Error("WRN-PAYMENT-CONFIG: payment.default is required"); + if (!value.gateways?.[value.default] && value.default !== "sandbox") + throw new Error( + `WRN-PAYMENT-CONFIG: default gateway '${value.default}' has no configuration`, + ); + if (process.env.NODE_ENV === "production" && value.sandbox) + throw new Error("WRN-PAYMENT-CONFIG: sandbox cannot be enabled in production"); + }, + migrations(context: PluginContext) { + return context.metadata.get(key) ? [{ id: "wrnexus-payment-001", source: migration }] : []; + }, + documentation: [join(root, "README.md")], + }); +} +export default paymentPlugin; diff --git a/packages/payment/src/runtime-webhook.ts b/packages/payment/src/runtime-webhook.ts new file mode 100644 index 00000000..7af7b5f5 --- /dev/null +++ b/packages/payment/src/runtime-webhook.ts @@ -0,0 +1,7 @@ +import { getPaymentService } from "./core.ts"; + +export async function POST(ctx: { req: Request; params?: Record }) { + const gateway = ctx.params?.gateway?.trim(); + if (!gateway) return Response.json({ error: "payment gateway is required" }, { status: 400 }); + return getPaymentService().paymentWebhookHandler(gateway)(ctx); +} diff --git a/packages/payment/src/store.ts b/packages/payment/src/store.ts new file mode 100644 index 00000000..e8dc78a9 --- /dev/null +++ b/packages/payment/src/store.ts @@ -0,0 +1,101 @@ +import type { + PaymentEvent, + PaymentIntent, + PaymentMethod, + PaymentRefund, + PaymentStore, +} from "./types.ts"; + +export function memoryPaymentStore(): PaymentStore { + const intents = new Map(); + const keys = new Map(); + const eventRows = new Map(); + const refundRows = new Map(); + const methodRows = new Map(); + let lock = Promise.resolve(); + const exclusive = async (run: () => T | Promise): Promise => { + const previous = lock; + let release!: () => void; + lock = new Promise((resolve) => (release = resolve)); + await previous; + try { + return await run(); + } finally { + release(); + } + }; + return { + async findIntentByKey(key) { + const id = keys.get(key); + return id ? structuredClone(intents.get(id)!) : null; + }, + async putIntent(intent) { + intents.set(intent.id, structuredClone(intent)); + keys.set(intent.idempotencyKey, intent.id); + }, + async getIntent(id) { + const row = intents.get(id); + return row ? structuredClone(row) : null; + }, + async listIntents(filter = {}) { + return [...intents.values()] + .filter( + (row) => + (!filter.subject || row.subject === filter.subject) && + (!filter.gateway || row.gateway === filter.gateway), + ) + .map((row) => structuredClone(row)); + }, + async appendEvent(event) { + if (eventRows.has(event.id)) return false; + eventRows.set(event.id, structuredClone(event)); + return true; + }, + async events(intentId) { + return [...eventRows.values()] + .filter((row) => row.intentId === intentId) + .sort((a, b) => a.at - b.at) + .map((row) => structuredClone(row)); + }, + async reserveRefund(refund, captured) { + return exclusive(() => { + if ([...refundRows.values()].some((row) => row.idempotencyKey === refund.idempotencyKey)) + return false; + const used = [...refundRows.values()] + .filter((row) => row.intentId === refund.intentId && row.status !== "failed") + .reduce((sum, row) => sum + row.amount, 0); + if (used + refund.amount > captured) return false; + refundRows.set(refund.id, structuredClone(refund)); + return true; + }); + }, + async updateRefund(id, status, gatewayRef) { + const row = refundRows.get(id); + if (row) refundRows.set(id, { ...row, status, gatewayRef }); + }, + async refunds(intentId) { + return [...refundRows.values()] + .filter((row) => row.intentId === intentId) + .map((row) => structuredClone(row)); + }, + async methods(subject) { + return [...methodRows.values()] + .filter((row) => row.subject === subject) + .map((row) => structuredClone(row)); + }, + async getMethod(id) { + const row = methodRows.get(id); + return row ? structuredClone(row) : null; + }, + async putMethod(method) { + methodRows.set(method.id, structuredClone(method)); + }, + async removeMethod(id) { + methodRows.delete(id); + }, + async defaultMethod(subject, id) { + for (const [key, row] of methodRows) + if (row.subject === subject) methodRows.set(key, { ...row, default: key === id }); + }, + }; +} diff --git a/packages/payment/src/types.ts b/packages/payment/src/types.ts new file mode 100644 index 00000000..930797ef --- /dev/null +++ b/packages/payment/src/types.ts @@ -0,0 +1,156 @@ +export type PaymentStatus = + | "created" + | "pending" + | "authorized" + | "captured" + | "failed" + | "cancelled" + | "partially_refunded" + | "refunded"; + +export interface Money { + amount: number; + currency: string; +} +export interface GatewayCapabilities { + authorizeThenCapture: boolean; + partialRefund: boolean; + multipleRefunds: boolean; + storedMethods: boolean; + customerVault: boolean; + hostedFields: boolean; + hostedCheckout: boolean; + webhookSignature: boolean; + payouts: boolean; + disputes: boolean; +} +export interface GatewaySupport { + currencies: readonly string[] | "any"; + countries: readonly string[] | "any"; +} +export interface CreateIntentInput extends Money { + id: string; + subject: string; + idempotencyKey: string; + country?: string; + capture?: "automatic" | "manual"; + metadata?: Record; + returnUrl?: string; +} +export interface GatewayIntent { + ref: string; + status: PaymentStatus; + amount: number; + currency: string; + checkoutUrl?: string; + clientSecret?: string; + raw?: unknown; +} +export interface GatewayRefund { + ref: string; + status: "pending" | "succeeded" | "failed"; + amount: number; + raw?: unknown; +} +export interface GatewayMethod { + ref: string; + brand?: string; + last4?: string; + expires?: string; +} +export interface CustomerInput { + subject: string; + email?: string; + name?: string; +} +export interface RefundInput extends Money { + intentRef: string; + idempotencyKey: string; + reason: string; +} +export interface WebhookEvent { + id: string; + intentRef: string; + type: string; + status: PaymentStatus; + payload: unknown; +} + +export interface PaymentGateway { + readonly id: string; + readonly config?: TConfig; + readonly capabilities: GatewayCapabilities; + readonly supports: GatewaySupport; + createIntent(input: CreateIntentInput): Promise; + fetchIntent(intentRef: string): Promise; + cancelIntent(intentRef: string, reason: string): Promise; + refund(input: RefundInput): Promise; + verifyWebhook(req: Request, secret: string): Promise; + capture?(intentRef: string, amount?: Money): Promise; + listMethods?(customerRef: string): Promise; + attachMethod?(customerRef: string, token: string): Promise; + detachMethod?(methodRef: string): Promise; + createCustomer?(input: CustomerInput): Promise; + clientConfig(intent: GatewayIntent): Record; +} + +export interface PaymentIntent extends Money { + id: string; + subject: string; + gateway: string; + gatewayRef: string; + idempotencyKey: string; + createdAt: number; + checkoutUrl?: string; + client: Record; + metadata: Record; +} +export interface PaymentEvent { + id: string; + intentId: string; + type: string; + status: PaymentStatus; + at: number; + payload?: unknown; +} +export interface PaymentRefund { + id: string; + intentId: string; + gatewayRef: string; + amount: number; + currency: string; + reason: string; + idempotencyKey: string; + status: "pending" | "succeeded" | "failed"; + createdAt: number; +} +export interface PaymentMethod { + id: string; + subject: string; + gateway: string; + gatewayRef: string; + brand?: string; + last4?: string; + expires?: string; + default: boolean; +} + +export type PaymentResult = + | { ok: true; value: T } + | { ok: false; fault: "refused" | "gateway" | "storage" | "configuration"; reason: string }; +export interface PaymentStore { + findIntentByKey(key: string): Promise; + putIntent(intent: PaymentIntent): Promise; + getIntent(id: string): Promise; + listIntents(filter?: { subject?: string; gateway?: string }): Promise; + appendEvent(event: PaymentEvent): Promise; + events(intentId: string): Promise; + reserveRefund(refund: PaymentRefund, captured: number): Promise; + updateRefund(id: string, status: PaymentRefund["status"], gatewayRef: string): Promise; + refunds(intentId: string): Promise; + methods(subject: string): Promise; + getMethod(id: string): Promise; + putMethod(method: PaymentMethod): Promise; + removeMethod(id: string): Promise; + defaultMethod(subject: string, id: string): Promise; +} diff --git a/packages/payment/test/database-plugin.test.ts b/packages/payment/test/database-plugin.test.ts new file mode 100644 index 00000000..1e383d26 --- /dev/null +++ b/packages/payment/test/database-plugin.test.ts @@ -0,0 +1,107 @@ +import { afterEach, expect, test } from "bun:test"; +import { closeDatabases, setDb } from "@wrnexus/db"; +import { connectFromConfig } from "@wrnexus/db/connect"; +import { createPluginRunner } from "@wrnexus/plugin"; +import { parse } from "@wrnexus/syntax"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { databasePaymentStore, paymentPlugin } from "../src/index.ts"; + +afterEach(() => closeDatabases()); +test("payment plugin contributes its schema only when configured", async () => { + const metadata = new Map(); + const context = { + root: process.cwd(), + mode: "development" as const, + command: "dev" as const, + metadata, + warn() {}, + }; + const runner = createPluginRunner(paymentPlugin(), context); + await runner.configure({ payment: { default: "sandbox", sandbox: true } } as any); + const contributions = await runner.contributions(); + expect(contributions.migrations.map((row) => row.id)).toEqual(["wrnexus-payment-001"]); + expect(contributions.componentDirs).toHaveLength(1); + expect(contributions.routes.map((row) => row.path)).toEqual(["/api/payments/webhook/:gateway"]); + const off = createPluginRunner(paymentPlugin(), { ...context, metadata: new Map() }); + await off.configure({} as any); + expect((await off.contributions()).migrations).toHaveLength(0); + expect((await off.contributions()).routes).toHaveLength(0); +}); + +test("database store persists append-only events and atomically caps refunds", async () => { + const db = connectFromConfig({ driver: "sqlite", url: ":memory:" }); + setDb(db); + const metadata = new Map(); + const runner = createPluginRunner(paymentPlugin(), { + root: process.cwd(), + mode: "development", + command: "dev", + metadata, + warn() {}, + }); + await runner.configure({ payment: { default: "sandbox" } } as any); + const source = (await runner.contributions()).migrations[0].source!; + const up = source.split("-- +down")[0].replace("-- +up", ""); + for (const statement of up + .split(";") + .map((x) => x.trim()) + .filter(Boolean)) + await db.exec(statement); + const store = databasePaymentStore(); + const intent = { + id: "pay-1", + subject: "u", + gateway: "sandbox", + gatewayRef: "sb-1", + amount: 100, + currency: "USD", + idempotencyKey: "once", + createdAt: 1, + client: {}, + metadata: {}, + }; + await store.putIntent(intent); + expect((await store.findIntentByKey("once"))?.id).toBe("pay-1"); + expect( + await store.appendEvent({ + id: "e1", + intentId: "pay-1", + type: "captured", + status: "captured", + at: 1, + }), + ).toBe(true); + expect( + await store.appendEvent({ + id: "e1", + intentId: "pay-1", + type: "captured", + status: "captured", + at: 1, + }), + ).toBe(false); + const base = { + intentId: "pay-1", + gatewayRef: "", + currency: "USD", + reason: "test", + status: "pending" as const, + createdAt: 1, + }; + const results = await Promise.all([ + store.reserveRefund({ id: "r1", amount: 60, idempotencyKey: "r1", ...base }, 100), + store.reserveRefund({ id: "r2", amount: 60, idempotencyKey: "r2", ...base }, 100), + ]); + expect(results.filter(Boolean)).toHaveLength(1); +}); + +test("all payment components parse and never collect raw card data", () => { + for (const file of readdirSync(join(import.meta.dir, "../components")).filter((x) => + x.endsWith(".wrn"), + )) { + const source = readFileSync(join(import.meta.dir, "../components", file), "utf8"); + expect(() => parse(source)).not.toThrow(); + expect(source).not.toMatch(/cardNumber|cvv|\bpan\b/i); + } +}); diff --git a/packages/payment/test/payment.test.ts b/packages/payment/test/payment.test.ts new file mode 100644 index 00000000..0758e5f4 --- /dev/null +++ b/packages/payment/test/payment.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from "bun:test"; +import { + defineGateway, + definePayment, + memoryPaymentStore, + paypalGateway, + razorpayGateway, + sandboxGateway, + signSandboxWebhook, + stripeGateway, + verifyGatewayContract, +} from "../src/index.ts"; + +test("sandbox drives webhook-authoritative capture and bounded partial refunds", async () => { + const store = memoryPaymentStore(), + gateway = sandboxGateway({ secret: "test-secret" }); + const payment = definePayment({ + store, + adapters: [gateway], + default: "sandbox", + webhookSecrets: { sandbox: "test-secret" }, + environment: "test", + }); + const created = await payment.initializePayment({ + subject: "user-1", + amount: 1000, + currency: "USD", + idempotencyKey: "checkout-1", + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(await payment.checkPayment(created.value.id)).toBe("pending"); + // A browser redirect cannot advance local state. + expect(await payment.checkPayment(created.value.id)).toBe("pending"); + const signed = await signSandboxWebhook( + { + id: "evt-1", + intentRef: created.value.gatewayRef, + type: "payment.captured", + status: "captured", + }, + "test-secret", + ); + const handler = payment.paymentWebhookHandler("sandbox"); + expect( + ( + await handler({ + req: new Request("https://app.test/webhook", { + method: "POST", + headers: { "x-wrnexus-signature": signed.signature }, + body: signed.body, + }), + }) + ).status, + ).toBe(200); + expect(await payment.checkPayment(created.value.id)).toBe("captured"); + const first = await payment.refundPayment({ + id: created.value.id, + amount: 400, + reason: "partial return", + idempotencyKey: "refund-1", + }); + expect(first.ok).toBe(true); + expect((await payment.refundableAmount(created.value.id)).amount).toBe(600); + const excessive = await payment.refundPayment({ + id: created.value.id, + amount: 601, + reason: "too much", + idempotencyKey: "refund-2", + }); + expect(excessive).toMatchObject({ ok: false, fault: "refused" }); +}); + +test("initialize, webhook, and refund replay change records once", async () => { + const store = memoryPaymentStore(), + gateway = sandboxGateway(); + const payment = definePayment({ + store, + adapters: [gateway], + default: "sandbox", + webhookSecrets: { sandbox: "sandbox-secret" }, + }); + const input = { subject: "user-2", amount: 500, currency: "EUR", idempotencyKey: "same" }; + const one = await payment.initializePayment(input), + two = await payment.initializePayment(input); + expect(one.ok && two.ok && one.value.id).toBe(two.ok ? two.value.id : ""); + if (!one.ok) return; + const signed = await signSandboxWebhook({ + id: "evt-replay", + intentRef: one.value.gatewayRef, + type: "payment.captured", + status: "captured", + }); + const request = () => + new Request("https://app.test/webhook", { + method: "POST", + headers: { "x-wrnexus-signature": signed.signature }, + body: signed.body, + }); + const handler = payment.paymentWebhookHandler("sandbox"); + await handler({ req: request() }); + const replay = await handler({ req: request() }); + expect(await replay.json()).toEqual({ received: true, duplicate: true }); + const refund = await payment.refundPayment({ + id: one.value.id, + amount: 100, + reason: "return", + idempotencyKey: "same-refund", + }); + const duplicate = await payment.refundPayment({ + id: one.value.id, + amount: 100, + reason: "return", + idempotencyKey: "same-refund", + }); + expect(refund.ok && duplicate.ok && refund.value.id).toBe(duplicate.ok ? duplicate.value.id : ""); + expect(await payment.listRefunds(one.value.id)).toHaveLength(1); +}); + +test("selection and capability differences are refused before money moves", async () => { + const sandbox = sandboxGateway(); + const noPartial = defineGateway({ + ...sandbox, + id: "full-only", + capabilities: { ...sandbox.capabilities, partialRefund: false }, + }); + const payment = definePayment({ + store: memoryPaymentStore(), + adapters: [sandbox, noPartial], + default: "sandbox", + route: (input) => (input.currency === "INR" ? "full-only" : "sandbox"), + }); + const manual = await payment.initializePayment({ + subject: "u", + amount: 100, + currency: "INR", + country: "IN", + capture: "manual", + idempotencyKey: "route", + }); + expect(manual.ok).toBe(true); + expect(verifyGatewayContract(sandbox)).toEqual({ ok: true, issues: [] }); + const broken = defineGateway({ + ...sandbox, + id: "broken", + capabilities: { ...sandbox.capabilities, authorizeThenCapture: false }, + }); + expect(verifyGatewayContract(broken).ok).toBe(false); +}); + +test("invalid webhook signatures never update payment status", async () => { + const gateway = sandboxGateway(), + payment = definePayment({ + store: memoryPaymentStore(), + adapters: [gateway], + default: "sandbox", + webhookSecrets: { sandbox: "right" }, + }); + const created = await payment.initializePayment({ + subject: "u", + amount: 100, + currency: "USD", + idempotencyKey: "bad-hook", + }); + if (!created.ok) return; + const response = await payment.paymentWebhookHandler("sandbox")({ + req: new Request("https://app.test", { + method: "POST", + headers: { "x-wrnexus-signature": "wrong" }, + body: JSON.stringify({ id: "bad", intentRef: created.value.gatewayRef, status: "captured" }), + }), + }); + expect(response.status).toBe(400); + expect(await payment.checkPayment(created.value.id)).toBe("pending"); +}); + +test("every Tier 1 adapter passes the shared capability contract", () => { + const adapters = [ + sandboxGateway(), + stripeGateway({ secretKey: "sk_test", publishableKey: "pk_test", webhookSecret: "whsec" }), + razorpayGateway({ keyId: "rzp_test", keySecret: "secret", webhookSecret: "hook" }), + paypalGateway({ clientId: "client", clientSecret: "secret", webhookId: "hook" }), + ]; + for (const adapter of adapters) + expect(verifyGatewayContract(adapter)).toEqual({ ok: true, issues: [] }); +}); diff --git a/packages/styles/package.json b/packages/styles/package.json index 3ef9a82a..d2c22199 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.19", + "version": "0.8.20", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/config.ts b/packages/styles/src/config.ts index 999392c9..f9be6b7b 100644 --- a/packages/styles/src/config.ts +++ b/packages/styles/src/config.ts @@ -313,6 +313,14 @@ export interface AppConfig { queue?: string | boolean; sandbox?: { enabled?: boolean; allowlist?: string[] }; }; + /** Payment gateway configuration consumed by @wrnexus/payment. */ + payment?: { + default: string; + currency?: string; + sandbox?: boolean; + databaseName?: string; + gateways?: Record>; + }; /** * File-upload storage. Declare named stores (local dir or S3-compatible), * upload with `handleUpload`/`upload` from `@wrnexus/uploader`, and serve