feat: add gateway-neutral payment package
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.20",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user