feat: add gateway-neutral payment package
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-23 21:05:27 +05:30
parent a9670c2a1c
commit 98e0813061
23 changed files with 1902 additions and 6 deletions
+15 -5
View File
@@ -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"],
+5
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
component PayNow {
props { checkoutUrl: string label?: string = "Pay now" disabled?: boolean = false }
view {
<a class="wrn-payment-button" href={checkoutUrl} aria-disabled={disabled}>
{label}
</a>
}
}
@@ -0,0 +1,4 @@
component PaymentHistory {
props { payments: array = [] }
view { <ol class="wrn-payment-history">{#each payments as payment}<li>{payment.currency} {payment.amount} — {payment.status}</li>{/each}</ol> }
}
@@ -0,0 +1,4 @@
component PaymentMethodList {
props { methods: array = [] }
view { <ul class="wrn-payment-methods">{#each methods as method}<li>{method.brand} ending {method.last4}</li>{/each}</ul> }
}
@@ -0,0 +1,4 @@
component PaymentSheet {
props { checkoutUrl: string gateway: string }
view { <section class="wrn-payment-sheet" data-gateway={gateway}><p>Secure payment is completed on the payment provider.</p><a href={checkoutUrl}>Continue securely</a></section> }
}
@@ -0,0 +1,4 @@
component PaymentStatus {
props { status: string label?: string = "Payment status" }
view { <div class="wrn-payment-status" role="status"><strong>{label}</strong><span>{status}</span></div> }
}
@@ -0,0 +1,4 @@
component PaymentSummary {
props { captured: number = 0 refunded: number = 0 net: number = 0 currency: string }
view { <dl><dt>Captured</dt><dd>{currency} {captured}</dd><dt>Refunded</dt><dd>{currency} {refunded}</dd><dt>Net</dt><dd>{currency} {net}</dd></dl> }
}
@@ -0,0 +1,4 @@
component RefundButton {
props { paymentId: string refundable: number partial?: boolean = false action?: string = "/api/payments/refund" }
view { <form method="post" action={action}><input type="hidden" name="paymentId" value={paymentId} /><label>Reason<input name="reason" required /></label>{#if partial}<label>Amount<input name="amount" type="number" min="1" max={refundable} /></label>{/if}<button type="submit">Refund</button></form> }
}
+22
View File
@@ -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:*"
}
}
+550
View File
@@ -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<string, GatewayIntent>();
const refunds = new Map<string, GatewayRefund>();
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<StripeConfig> {
const auth = {
authorization: `Bearer ${config.secretKey}`,
"content-type": "application/x-www-form-urlencoded",
"stripe-version": config.apiVersion ?? "2026-03-31",
};
const form = (data: Record<string, unknown>) =>
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<RazorpayConfig> {
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<PayPalConfig> {
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 };
},
});
}
+38
View File
@@ -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 };
}
+405
View File
@@ -0,0 +1,405 @@
import type {
GatewayMethod,
Money,
PaymentEvent,
PaymentGateway,
PaymentIntent,
PaymentMethod,
PaymentRefund,
PaymentResult,
PaymentStatus,
PaymentStore,
WebhookEvent,
} from "./types.ts";
export interface InitializePaymentInput extends Money {
subject: string;
idempotencyKey: string;
gateway?: string;
country?: string;
capture?: "automatic" | "manual";
metadata?: Record<string, string>;
returnUrl?: string;
}
export interface PaymentOptions {
store: PaymentStore;
adapters: readonly PaymentGateway[];
default: string;
route?(input: InitializePaymentInput): string;
webhookSecrets?: Record<string, string>;
environment?: "development" | "test" | "production";
}
const validMoney = (money: Money) =>
Number.isSafeInteger(money.amount) && money.amount > 0 && /^[A-Z]{3}$/.test(money.currency);
const refusal = <T>(reason: string): PaymentResult<T> => ({ ok: false, fault: "refused", reason });
const gatewayFault = <T>(reason: string): PaymentResult<T> => ({
ok: false,
fault: "gateway",
reason,
});
export function derivePaymentStatus(events: readonly PaymentEvent[]): PaymentStatus {
return events.at(-1)?.status ?? "created";
}
export function defineGateway<T extends PaymentGateway>(gateway: T): T {
if (!gateway.id.trim()) throw new Error("WRN-PAYMENT-GATEWAY: id is required");
if (!gateway.capabilities.webhookSignature && process.env.NODE_ENV === "production")
throw new Error(`WRN-PAYMENT-WEBHOOK: gateway '${gateway.id}' cannot verify signatures`);
return Object.freeze(gateway);
}
export function definePayment(options: PaymentOptions) {
const adapters = new Map(options.adapters.map((adapter) => [adapter.id, adapter]));
if (adapters.size !== options.adapters.length)
throw new Error("WRN-PAYMENT-CONFIG: duplicate gateway id");
if (!adapters.has(options.default))
throw new Error(`WRN-PAYMENT-CONFIG: default gateway '${options.default}' is not registered`);
if ((options.environment ?? process.env.NODE_ENV) === "production")
for (const adapter of adapters.values())
if (!adapter.capabilities.webhookSignature)
throw new Error(`WRN-PAYMENT-WEBHOOK: gateway '${adapter.id}' cannot run in production`);
const adapterFor = (id: string) => adapters.get(id);
const status = async (id: string) => derivePaymentStatus(await options.store.events(id));
async function initializePayment(
input: InitializePaymentInput,
): Promise<PaymentResult<PaymentIntent>> {
if (!validMoney(input))
return refusal("amount must be positive minor units and currency must be ISO-4217");
if (!input.subject.trim() || !input.idempotencyKey.trim())
return refusal("subject and idempotencyKey are required");
try {
const existing = await options.store.findIntentByKey(input.idempotencyKey);
if (existing) return { ok: true, value: existing };
const gatewayId = input.gateway ?? options.route?.(input) ?? options.default;
const adapter = adapterFor(gatewayId);
if (!adapter) return refusal(`gateway '${gatewayId}' is not configured`);
if (
adapter.supports.currencies !== "any" &&
!adapter.supports.currencies.includes(input.currency)
)
return refusal(`gateway '${gatewayId}' does not support ${input.currency}`);
if (
input.country &&
adapter.supports.countries !== "any" &&
!adapter.supports.countries.includes(input.country)
)
return refusal(`gateway '${gatewayId}' does not support country ${input.country}`);
if (input.capture === "manual" && !adapter.capabilities.authorizeThenCapture)
return refusal(`gateway '${gatewayId}' does not support authorize-then-capture`);
const id = `pay_${crypto.randomUUID()}`;
const remote = await adapter.createIntent({ ...input, id });
const intent: PaymentIntent = {
id,
subject: input.subject,
amount: input.amount,
currency: input.currency,
gateway: gatewayId,
gatewayRef: remote.ref,
idempotencyKey: input.idempotencyKey,
createdAt: Date.now(),
checkoutUrl: remote.checkoutUrl,
client: adapter.clientConfig(remote),
metadata: { ...(input.metadata ?? {}) },
};
await options.store.putIntent(intent);
await options.store.appendEvent({
id: `local:${id}:created`,
intentId: id,
type: "payment.created",
status: remote.status,
at: Date.now(),
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "gateway failed");
}
}
async function getPayment(id: string) {
return options.store.getIntent(id);
}
async function checkPayment(id: string): Promise<PaymentStatus> {
return status(id);
}
async function syncPayment(id: string): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
const remote = await adapterFor(intent.gateway)!.fetchIntent(intent.gatewayRef);
await options.store.appendEvent({
id: `sync:${intent.gateway}:${remote.ref}:${remote.status}`,
intentId: id,
type: "payment.synced",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "sync failed");
}
}
const confirmPayment = syncPayment;
async function capturePayment(id: string, amount?: Money): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
const adapter = adapterFor(intent.gateway)!;
if (!adapter.capabilities.authorizeThenCapture || !adapter.capture)
return refusal(`gateway '${adapter.id}' does not support capture`);
const remote = await adapter.capture(intent.gatewayRef, amount);
await options.store.appendEvent({
id: `capture:${remote.ref}:${Date.now()}`,
intentId: id,
type: "payment.captured",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "capture failed");
}
}
async function cancelPayment(id: string, reason: string): Promise<PaymentResult<PaymentIntent>> {
try {
const intent = await options.store.getIntent(id);
if (!intent) return refusal("payment not found");
if (!reason.trim()) return refusal("cancellation reason is required");
const remote = await adapterFor(intent.gateway)!.cancelIntent(intent.gatewayRef, reason);
await options.store.appendEvent({
id: `cancel:${remote.ref}:${Date.now()}`,
intentId: id,
type: "payment.cancelled",
status: remote.status,
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: intent };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "cancellation failed");
}
}
async function refundableAmount(id: string): Promise<Money> {
const intent = await options.store.getIntent(id);
if (!intent) return { amount: 0, currency: "XXX" };
const captured =
(await status(id)) === "captured" ||
(await status(id)) === "partially_refunded" ||
(await status(id)) === "refunded"
? intent.amount
: 0;
const refunded = (await options.store.refunds(id))
.filter((row) => row.status !== "failed")
.reduce((sum, row) => sum + row.amount, 0);
return { amount: Math.max(0, captured - refunded), currency: intent.currency };
}
async function refundPayment(input: {
id: string;
amount?: number;
reason: string;
idempotencyKey: string;
}): Promise<PaymentResult<PaymentRefund>> {
try {
const intent = await options.store.getIntent(input.id);
if (!intent) return refusal("payment not found");
if (!input.reason.trim() || !input.idempotencyKey.trim())
return refusal("refund reason and idempotencyKey are required");
const prior = (await options.store.refunds(input.id)).find(
(row) => row.idempotencyKey === input.idempotencyKey,
);
if (prior) return { ok: true, value: prior };
const available = await refundableAmount(input.id);
const amount = input.amount ?? available.amount;
if (!Number.isSafeInteger(amount) || amount <= 0 || amount > available.amount)
return refusal("refund exceeds the refundable amount");
const adapter = adapterFor(intent.gateway)!;
if (amount < intent.amount && !adapter.capabilities.partialRefund)
return refusal(`gateway '${adapter.id}' does not support partial refunds`);
if (
(await options.store.refunds(input.id)).some((row) => row.status !== "failed") &&
!adapter.capabilities.multipleRefunds
)
return refusal(`gateway '${adapter.id}' does not support multiple refunds`);
const refund: PaymentRefund = {
id: `refund_${crypto.randomUUID()}`,
intentId: intent.id,
gatewayRef: "",
amount,
currency: intent.currency,
reason: input.reason,
idempotencyKey: input.idempotencyKey,
status: "pending",
createdAt: Date.now(),
};
if (!(await options.store.reserveRefund(refund, intent.amount)))
return refusal("refund is duplicated or exceeds the captured amount");
const remote = await adapter.refund({
intentRef: intent.gatewayRef,
amount,
currency: intent.currency,
reason: input.reason,
idempotencyKey: input.idempotencyKey,
});
await options.store.updateRefund(refund.id, remote.status, remote.ref);
const completed = { ...refund, status: remote.status, gatewayRef: remote.ref };
const remaining = available.amount - amount;
await options.store.appendEvent({
id: `refund:${remote.ref}`,
intentId: intent.id,
type: "payment.refunded",
status: remaining === 0 ? "refunded" : "partially_refunded",
at: Date.now(),
payload: remote.raw,
});
return { ok: true, value: completed };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "refund failed");
}
}
async function acceptWebhook(gatewayId: string, event: WebhookEvent): Promise<boolean> {
const intent = (await options.store.listIntents({ gateway: gatewayId })).find(
(row) => row.gatewayRef === event.intentRef,
);
if (!intent) return false;
return options.store.appendEvent({
id: `${gatewayId}:${event.id}`,
intentId: intent.id,
type: event.type,
status: event.status,
at: Date.now(),
payload: event.payload,
});
}
function paymentWebhookHandler(gatewayId: string) {
return async (ctx: { req: Request }) => {
const adapter = adapterFor(gatewayId);
const secret = options.webhookSecrets?.[gatewayId];
if (!adapter || !secret)
return Response.json({ error: "gateway webhook is not configured" }, { status: 404 });
try {
const event = await adapter.verifyWebhook(ctx.req, secret);
const accepted = await acceptWebhook(gatewayId, event);
return Response.json({ received: true, duplicate: !accepted });
} catch {
return Response.json({ error: "invalid webhook" }, { status: 400 });
}
};
}
async function paymentMethods(subject: string): Promise<PaymentMethod[]> {
return options.store.methods(subject);
}
async function attachPaymentMethod(
subject: string,
gatewayId: string,
customerRef: string,
token: string,
): Promise<PaymentResult<PaymentMethod>> {
try {
const adapter = adapterFor(gatewayId);
if (!adapter?.capabilities.storedMethods || !adapter.attachMethod)
return refusal(`gateway '${gatewayId}' does not support stored methods`);
const remote: GatewayMethod = await adapter.attachMethod(customerRef, token);
const method: PaymentMethod = {
id: `method_${crypto.randomUUID()}`,
subject,
gateway: gatewayId,
gatewayRef: remote.ref,
brand: remote.brand,
last4: remote.last4,
expires: remote.expires,
default: false,
};
await options.store.putMethod(method);
return { ok: true, value: method };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "method attach failed");
}
}
async function detachPaymentMethod(id: string): Promise<PaymentResult<void>> {
const method = await options.store.getMethod(id);
if (!method) return refusal("payment method not found");
const adapter = adapterFor(method.gateway);
try {
await adapter?.detachMethod?.(method.gatewayRef);
await options.store.removeMethod(id);
return { ok: true, value: undefined };
} catch (error) {
return gatewayFault(error instanceof Error ? error.message : "method detach failed");
}
}
async function setDefaultPaymentMethod(subject: string, id: string) {
await options.store.defaultMethod(subject, id);
}
async function listPayments(filter?: { subject?: string; gateway?: string }) {
return options.store.listIntents(filter);
}
async function listRefunds(id: string) {
return options.store.refunds(id);
}
async function paymentTotals(filter?: { subject?: string; gateway?: string }) {
const rows = await options.store.listIntents(filter);
const byCurrency: Record<string, { captured: number; refunded: number; net: number }> = {};
for (const row of rows) {
const state = await status(row.id);
const captured = ["captured", "partially_refunded", "refunded"].includes(state)
? row.amount
: 0;
const refunded = (await options.store.refunds(row.id))
.filter((x) => x.status === "succeeded")
.reduce((s, x) => s + x.amount, 0);
const total = (byCurrency[row.currency] ??= { captured: 0, refunded: 0, net: 0 });
total.captured += captured;
total.refunded += refunded;
total.net = total.captured - total.refunded;
}
return { byCurrency };
}
async function reconcilePayments() {
const discrepancies: unknown[] = [];
for (const intent of await options.store.listIntents()) {
const local = await status(intent.id);
const remote = (await adapterFor(intent.gateway)!.fetchIntent(intent.gatewayRef)).status;
if (local !== remote)
discrepancies.push({ id: intent.id, local, remote, gateway: intent.gateway });
}
return discrepancies;
}
return {
initializePayment,
getPayment,
listPayments,
checkPayment,
confirmPayment,
syncPayment,
capturePayment,
cancelPayment,
refundPayment,
listRefunds,
refundableAmount,
paymentMethods,
attachPaymentMethod,
detachPaymentMethod,
setDefaultPaymentMethod,
paymentTotals,
reconcilePayments,
paymentWebhookHandler,
capabilitiesOf: (id: string) => adapterFor(id)?.capabilities,
};
}
export type PaymentService = ReturnType<typeof definePayment>;
const SERVICE = Symbol.for("@wrnexus/payment:service:v1");
type PaymentGlobal = typeof globalThis & { [SERVICE]?: PaymentService };
export function configurePayment(options: PaymentOptions): PaymentService {
const service = definePayment(options);
(globalThis as PaymentGlobal)[SERVICE] = service;
return service;
}
export function getPaymentService(): PaymentService {
const service = (globalThis as PaymentGlobal)[SERVICE];
if (!service)
throw new Error("WRN-PAYMENT-SETUP: call configurePayment() during application startup");
return service;
}
+209
View File
@@ -0,0 +1,209 @@
import { getDb } from "@wrnexus/db";
import type { PaymentIntent, PaymentRefund, PaymentStore } from "./types.ts";
const parse = <T>(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<any>(
"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<any>(
"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<any>("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,
]);
});
},
};
}
+7
View File
@@ -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";
+53
View File
@@ -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<string, unknown>; 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;
+7
View File
@@ -0,0 +1,7 @@
import { getPaymentService } from "./core.ts";
export async function POST(ctx: { req: Request; params?: Record<string, string> }) {
const gateway = ctx.params?.gateway?.trim();
if (!gateway) return Response.json({ error: "payment gateway is required" }, { status: 400 });
return getPaymentService().paymentWebhookHandler(gateway)(ctx);
}
+101
View File
@@ -0,0 +1,101 @@
import type {
PaymentEvent,
PaymentIntent,
PaymentMethod,
PaymentRefund,
PaymentStore,
} from "./types.ts";
export function memoryPaymentStore(): PaymentStore {
const intents = new Map<string, PaymentIntent>();
const keys = new Map<string, string>();
const eventRows = new Map<string, PaymentEvent>();
const refundRows = new Map<string, PaymentRefund>();
const methodRows = new Map<string, PaymentMethod>();
let lock = Promise.resolve();
const exclusive = async <T>(run: () => T | Promise<T>): Promise<T> => {
const previous = lock;
let release!: () => void;
lock = new Promise<void>((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 });
},
};
}
+156
View File
@@ -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<string, string>;
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<TConfig = unknown> {
readonly id: string;
readonly config?: TConfig;
readonly capabilities: GatewayCapabilities;
readonly supports: GatewaySupport;
createIntent(input: CreateIntentInput): Promise<GatewayIntent>;
fetchIntent(intentRef: string): Promise<GatewayIntent>;
cancelIntent(intentRef: string, reason: string): Promise<GatewayIntent>;
refund(input: RefundInput): Promise<GatewayRefund>;
verifyWebhook(req: Request, secret: string): Promise<WebhookEvent>;
capture?(intentRef: string, amount?: Money): Promise<GatewayIntent>;
listMethods?(customerRef: string): Promise<GatewayMethod[]>;
attachMethod?(customerRef: string, token: string): Promise<GatewayMethod>;
detachMethod?(methodRef: string): Promise<void>;
createCustomer?(input: CustomerInput): Promise<string>;
clientConfig(intent: GatewayIntent): Record<string, string>;
}
export interface PaymentIntent extends Money {
id: string;
subject: string;
gateway: string;
gatewayRef: string;
idempotencyKey: string;
createdAt: number;
checkoutUrl?: string;
client: Record<string, string>;
metadata: Record<string, string>;
}
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<T> =
| { ok: true; value: T }
| { ok: false; fault: "refused" | "gateway" | "storage" | "configuration"; reason: string };
export interface PaymentStore {
findIntentByKey(key: string): Promise<PaymentIntent | null>;
putIntent(intent: PaymentIntent): Promise<void>;
getIntent(id: string): Promise<PaymentIntent | null>;
listIntents(filter?: { subject?: string; gateway?: string }): Promise<PaymentIntent[]>;
appendEvent(event: PaymentEvent): Promise<boolean>;
events(intentId: string): Promise<PaymentEvent[]>;
reserveRefund(refund: PaymentRefund, captured: number): Promise<boolean>;
updateRefund(id: string, status: PaymentRefund["status"], gatewayRef: string): Promise<void>;
refunds(intentId: string): Promise<PaymentRefund[]>;
methods(subject: string): Promise<PaymentMethod[]>;
getMethod(id: string): Promise<PaymentMethod | null>;
putMethod(method: PaymentMethod): Promise<void>;
removeMethod(id: string): Promise<void>;
defaultMethod(subject: string, id: string): Promise<void>;
}
@@ -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<string, unknown>();
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<string, unknown>();
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);
}
});
+186
View File
@@ -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: [] });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.8.19",
"version": "0.8.20",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -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<string, Record<string, unknown>>;
};
/**
* File-upload storage. Declare named stores (local dir or S3-compatible),
* upload with `handleUpload`/`upload` from `@wrnexus/uploader`, and serve