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}- {payment.currency} {payment.amount} — {payment.status}
{/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 { {#each methods as method}- {method.brand} ending {method.last4}
{/each}
}
+}
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 { }
+}
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 { }
+}
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