feat: add gateway-neutral payment package
This commit is contained in:
@@ -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: [] });
|
||||
});
|
||||
Reference in New Issue
Block a user