fix: make payment event reduction monotonic
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import {
|
||||
exerciseGatewayContract,
|
||||
paypalGateway,
|
||||
razorpayGateway,
|
||||
sandboxGateway,
|
||||
signSandboxWebhook,
|
||||
stripeGateway,
|
||||
type GatewayIntent,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
|
||||
const encoder = new TextEncoder();
|
||||
async function signature(body: string, secret: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
return [...new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body)))]
|
||||
.map((x) => x.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
const input = {
|
||||
id: "contract-pay",
|
||||
subject: "contract-user",
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
idempotencyKey: "contract-init",
|
||||
};
|
||||
|
||||
test("shared executable contract drives sandbox", async () => {
|
||||
const adapter = sandboxGateway();
|
||||
const report = await exerciseGatewayContract(adapter, {
|
||||
input,
|
||||
async webhook(intent) {
|
||||
const signed = await signSandboxWebhook(
|
||||
{
|
||||
id: "sb-event",
|
||||
intentRef: intent.ref,
|
||||
type: "payment.captured",
|
||||
status: "captured",
|
||||
occurredAt: 100,
|
||||
},
|
||||
"contract-webhook-secret",
|
||||
);
|
||||
return new Request("https://test", {
|
||||
method: "POST",
|
||||
headers: { "x-wrnexus-signature": signed.signature },
|
||||
body: signed.body,
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(report).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
test("shared executable contract drives Stripe through its HTTP adapter", async () => {
|
||||
globalThis.fetch = (async (value, init) => {
|
||||
const url = String(value),
|
||||
method = init?.method ?? "GET";
|
||||
if (
|
||||
url.includes("/payment_intents") &&
|
||||
method === "POST" &&
|
||||
!url.endsWith("/capture") &&
|
||||
!url.endsWith("/cancel")
|
||||
) {
|
||||
const body = String(init?.body);
|
||||
const id = body.includes("cancel") ? "pi_cancel" : "pi_contract";
|
||||
return json({
|
||||
id,
|
||||
status: "requires_capture",
|
||||
amount: 1000,
|
||||
amount_received: 1000,
|
||||
currency: "usd",
|
||||
client_secret: "pi_client_safe",
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/capture"))
|
||||
return json({
|
||||
id: "pi_contract",
|
||||
status: "succeeded",
|
||||
amount_received: 1000,
|
||||
currency: "usd",
|
||||
});
|
||||
if (url.endsWith("/cancel"))
|
||||
return json({ id: "pi_cancel", status: "canceled", amount: 1000, currency: "usd" });
|
||||
if (url.includes("/payment_intents/"))
|
||||
return json({ id: "pi_contract", status: "requires_capture", amount: 1000, currency: "usd" });
|
||||
if (url.endsWith("/refunds")) return json({ id: "re_1", status: "succeeded", amount: 500 });
|
||||
if (url.endsWith("/customers")) return json({ id: "cus_1" });
|
||||
if (url.includes("/payment_methods?")) return json({ data: [] });
|
||||
if (url.includes("/payment_methods/") && url.endsWith("/attach"))
|
||||
return json({
|
||||
id: "pm_1",
|
||||
card: { brand: "visa", last4: "4242", exp_month: 1, exp_year: 2030 },
|
||||
});
|
||||
if (url.includes("/payment_methods/") && url.endsWith("/detach")) return json({ id: "pm_1" });
|
||||
return json({}, 404);
|
||||
}) as typeof fetch;
|
||||
const adapter = stripeGateway({
|
||||
secretKey: "sk_test_secret",
|
||||
publishableKey: "pk_test",
|
||||
webhookSecret: "contract-webhook-secret",
|
||||
});
|
||||
const report = await exerciseGatewayContract(adapter, {
|
||||
input,
|
||||
async webhook(intent) {
|
||||
const body = JSON.stringify({
|
||||
id: "evt_1",
|
||||
created: 100,
|
||||
data: { object: { id: intent.ref, status: "succeeded" } },
|
||||
type: "payment_intent.succeeded",
|
||||
});
|
||||
return new Request("https://test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"stripe-signature": `t=100,v1=${await signature(`100.${body}`, "contract-webhook-secret")}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(report).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
test("shared executable contract drives Razorpay through its HTTP adapter", async () => {
|
||||
globalThis.fetch = (async (value, init) => {
|
||||
const url = String(value),
|
||||
method = init?.method ?? "GET";
|
||||
if (url.endsWith("/orders") && method === "POST") {
|
||||
const body = String(init?.body);
|
||||
return json({
|
||||
id: body.includes("cancel") ? "order_cancel" : "order_1",
|
||||
status: "created",
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
});
|
||||
}
|
||||
if (url.includes("/orders/"))
|
||||
return json({
|
||||
id: url.endsWith("order_cancel") ? "order_cancel" : "order_1",
|
||||
status: "created",
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
});
|
||||
if (url.includes("/refund")) return json({ id: "rfnd_1", status: "processed", amount: 500 });
|
||||
return json({}, 404);
|
||||
}) as typeof fetch;
|
||||
const adapter = razorpayGateway({
|
||||
keyId: "rzp_test",
|
||||
keySecret: "key_secret",
|
||||
webhookSecret: "contract-webhook-secret",
|
||||
});
|
||||
const report = await exerciseGatewayContract(adapter, {
|
||||
input,
|
||||
async webhook(intent) {
|
||||
const body = JSON.stringify({
|
||||
id: "rz_evt",
|
||||
event: "payment.captured",
|
||||
payload: {
|
||||
payment: {
|
||||
entity: { id: "pay_1", order_id: intent.ref, status: "captured", created_at: 100 },
|
||||
},
|
||||
},
|
||||
});
|
||||
return new Request("https://test", {
|
||||
method: "POST",
|
||||
headers: { "x-razorpay-signature": await signature(body, "contract-webhook-secret") },
|
||||
body,
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(report).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
test("shared executable contract drives PayPal through its HTTP adapter", async () => {
|
||||
globalThis.fetch = (async (value, init) => {
|
||||
const url = String(value),
|
||||
method = init?.method ?? "GET";
|
||||
if (url.endsWith("/oauth2/token")) return json({ access_token: "access" });
|
||||
if (url.endsWith("/checkout/orders") && method === "POST") {
|
||||
const body = String(init?.body);
|
||||
return json({
|
||||
id: body.includes("cancel") ? "PP-CANCEL" : "PP-ORDER",
|
||||
status: "CREATED",
|
||||
links: [{ rel: "approve", href: "https://paypal.test/approve" }],
|
||||
});
|
||||
}
|
||||
if (url.includes("/checkout/orders/"))
|
||||
return json({
|
||||
id: url.endsWith("PP-CANCEL") ? "PP-CANCEL" : "PP-ORDER",
|
||||
status: "CREATED",
|
||||
purchase_units: [{ amount: { value: "10.00", currency_code: "USD" } }],
|
||||
});
|
||||
if (url.includes("/refund")) return json({ id: "PP-REFUND", status: "COMPLETED" });
|
||||
if (url.endsWith("verify-webhook-signature")) return json({ verification_status: "SUCCESS" });
|
||||
return json({}, 404);
|
||||
}) as typeof fetch;
|
||||
const adapter = paypalGateway({
|
||||
clientId: "client",
|
||||
clientSecret: "paypal_secret",
|
||||
webhookId: "hook",
|
||||
});
|
||||
const report = await exerciseGatewayContract(adapter, {
|
||||
input,
|
||||
webhook(intent: GatewayIntent) {
|
||||
return new Request("https://test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"paypal-auth-algo": "SHA256withRSA",
|
||||
"paypal-cert-url": "https://paypal.test/cert",
|
||||
"paypal-transmission-id": "tx",
|
||||
"paypal-transmission-sig": "sig",
|
||||
"paypal-transmission-time": "time",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: "WH-1",
|
||||
create_time: "2026-08-23T00:00:00Z",
|
||||
event_type: "PAYMENT.CAPTURE.COMPLETED",
|
||||
resource: {
|
||||
id: "CAPTURE",
|
||||
status: "COMPLETED",
|
||||
supplementary_data: { related_ids: { order_id: intent.ref } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(report).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
@@ -20,7 +20,10 @@ test("payment plugin contributes its schema only when configured", async () => {
|
||||
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.migrations.map((row) => row.id)).toEqual([
|
||||
"wrnexus-payment-001",
|
||||
"wrnexus-payment-002-event-order",
|
||||
]);
|
||||
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() });
|
||||
@@ -41,13 +44,14 @@ test("database store persists append-only events and atomically caps refunds", a
|
||||
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);
|
||||
for (const migration of (await runner.contributions()).migrations) {
|
||||
const up = migration.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",
|
||||
|
||||
@@ -8,9 +8,35 @@ import {
|
||||
sandboxGateway,
|
||||
signSandboxWebhook,
|
||||
stripeGateway,
|
||||
derivePaymentStatus,
|
||||
verifyGatewayContract,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("gateway occurrence order and sticky terminal states prevent webhook regression", () => {
|
||||
const events = [
|
||||
{
|
||||
id: "refund",
|
||||
intentId: "p",
|
||||
type: "refunded",
|
||||
status: "refunded" as const,
|
||||
at: 100,
|
||||
occurredAt: 300,
|
||||
},
|
||||
{
|
||||
id: "late-capture-retry",
|
||||
intentId: "p",
|
||||
type: "captured",
|
||||
status: "captured" as const,
|
||||
at: 400,
|
||||
occurredAt: 200,
|
||||
},
|
||||
];
|
||||
expect(derivePaymentStatus(events)).toBe("refunded");
|
||||
expect(
|
||||
derivePaymentStatus([...events, { ...events[1], id: "new-id", at: 500, occurredAt: 500 }]),
|
||||
).toBe("refunded");
|
||||
});
|
||||
|
||||
test("sandbox drives webhook-authoritative capture and bounded partial refunds", async () => {
|
||||
const store = memoryPaymentStore(),
|
||||
gateway = sandboxGateway({ secret: "test-secret" });
|
||||
@@ -69,6 +95,34 @@ test("sandbox drives webhook-authoritative capture and bounded partial refunds",
|
||||
idempotencyKey: "refund-2",
|
||||
});
|
||||
expect(excessive).toMatchObject({ ok: false, fault: "refused" });
|
||||
expect(
|
||||
(
|
||||
await payment.refundPayment({
|
||||
id: created.value.id,
|
||||
amount: 600,
|
||||
reason: "remaining return",
|
||||
idempotencyKey: "refund-remaining",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const stale = await signSandboxWebhook(
|
||||
{
|
||||
id: "evt-delayed-capture",
|
||||
intentRef: created.value.gatewayRef,
|
||||
type: "payment.captured",
|
||||
status: "captured",
|
||||
occurredAt: 1,
|
||||
},
|
||||
"test-secret",
|
||||
);
|
||||
await handler({
|
||||
req: new Request("https://app.test/webhook", {
|
||||
method: "POST",
|
||||
headers: { "x-wrnexus-signature": stale.signature },
|
||||
body: stale.body,
|
||||
}),
|
||||
});
|
||||
expect(await payment.checkPayment(created.value.id)).toBe("refunded");
|
||||
});
|
||||
|
||||
test("initialize, webhook, and refund replay change records once", async () => {
|
||||
|
||||
Reference in New Issue
Block a user