Files
edr-platform/integration/src/payment-failure.it.ts
2026-08-03 10:51:43 +00:00

250 lines
9.2 KiB
TypeScript

/**
* What happens when the bank misbehaves. Each test forces the gateway mock
* into a failure mode and asserts the platform's answer — the point being that
* NO failure may ever settle an invoice that was not paid, and no failure may
* lose a payment that was.
*
* Covered: provider down at initiate, hard decline, forged signature, replayed
* callback, silent settlement found only by the reconciliation sweep, and the
* unverifiable answer that must stop freight from expiring a paying customer.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
import {
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
releaseUnpaidHolds,
forceWindowOpen,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(6);
const STAMP = String(Date.now());
/** Four independent bookings so one test's terminal state can't poison another. */
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
type CaseName = (typeof CASES)[number];
describe("payment failure and recovery", () => {
const bookings = new Map<CaseName, ReadyBooking>();
const invoices = new Map<CaseName, string>();
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
await forceWindowOpen(schedule.id, 45);
let isoSeed = 100;
for (const suffix of CASES) {
bookings.set(
suffix,
await prepareBooking({
suffix,
departure: DEPARTURE,
runStamp: STAMP,
isoSeed,
twenty: 2,
}),
);
isoSeed += 2;
}
await runBatch(schedule.id);
for (const suffix of CASES) {
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
}
}, 600_000);
afterAll(closeDb);
it("leaves the invoice payable when the provider is unreachable", async () => {
const invoiceId = invoices.get("FAIL1")!;
await gateway.mode("cbe-birr", "fail");
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
expect(res.status).toBeGreaterThanOrEqual(400);
// No phantom settlement, and the customer can retry.
const invoice = await currentInvoice(invoiceId);
expect(invoice.status).not.toBe("PAID");
expect(invoice.paid_at).toBeNull();
await gateway.mode("cbe-birr", "ok");
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
});
it("keeps the invoice open on a declined payment", async () => {
const { bookingId } = bookings.get("FAIL2")!;
const invoiceId = invoices.get("FAIL2")!;
await gateway.mode("cbe-birr", "ok");
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
const failed = await poll<{ status: string }>(
"intent FAILED",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "FAILED",
{ attempts: 20, intervalMs: 1000 },
);
expect(failed.status).toBe("FAILED");
const invoice = await currentInvoice(invoiceId);
expect(invoice.status).not.toBe("PAID");
});
it("ignores a forged signature — event recorded, money untouched", async () => {
const { bookingId } = bookings.get("FAIL3")!;
const invoiceId = invoices.get("FAIL3")!;
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
const res = await gateway.webhook({
merchantOrderId: intent.merchant_order_id,
signature: "bad",
});
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
expect(res.body.delivered).toBe(200);
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
"forged webhook recorded",
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
[intent.merchant_order_id],
(row) => !!row,
{ attempts: 15, intervalMs: 1000 },
);
expect(event.signature_valid).toBe(false);
expect(event.processing_error).toBe("signature-invalid");
await sleep(3000);
const after = await db<{ status: string }>(
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
);
expect(after[0].status).not.toBe("SUCCEEDED");
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
});
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
const { bookingId } = bookings.get("FAIL4")!;
const invoiceId = invoices.get("FAIL4")!;
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
// The customer pays at the bank, but the callback is lost in the network.
await gateway.settle(intent.merchant_order_id);
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
const settled = await poll<{ status: string }>(
"intent settled by sweep",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "SUCCEEDED",
{ attempts: 30, intervalMs: 3000 },
);
expect(settled.status).toBe("SUCCEEDED");
const invoice = await poll<{ status: string }>(
"invoice PAID via sweep",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(invoice.status).toBe("PAID");
// No webhook was ever delivered for this one.
const [{ n }] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
WHERE merchant_order_id = $1`,
[intent.merchant_order_id],
);
expect(Number(n)).toBe(0);
});
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
// reference with nothing to verify legitimately answers "not paid".
const { bookingId } = bookings.get("FAIL1")!;
await gateway.mode("cbe-birr", "timeout");
const res = await payment("post", "/payments/reconcile", {
service: "FREIGHT",
referenceType: "SHIPMENT",
referenceId: bookingId,
});
await gateway.mode("cbe-birr", "ok");
expect([200, 201]).toContain(res.status);
const body = res.body?.data ?? res.body;
expect(body.paid).toBe(false);
// An unknown answer must never read as "definitely unpaid" — that is what
// stops the batch engine from expiring a customer who actually paid.
expect(body.unverifiable).toBe(true);
});
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
const { bookingId } = bookings.get("FAIL3")!;
const invoiceId = invoices.get("FAIL3")!;
const intent = await gatewayIntent(bookingId);
// Retire the intent the way an expiry sweep would, then let the money land.
await db(
`UPDATE edr_payment.payment_intent
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
WHERE id = $1`,
[intent.id],
);
await gateway.webhook({
merchantOrderId: intent.merchant_order_id,
eventId: `LATE-${intent.merchant_order_id}`,
});
const captured = await poll<{ status: string }>(
"late capture flips the intent",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "SUCCEEDED",
{ attempts: 20, intervalMs: 1000 },
);
expect(captured.status).toBe("SUCCEEDED");
const invoice = await poll<{ status: string }>(
"invoice settled by late capture",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(invoice.status).toBe("PAID");
});
it("retries delivery until the consumer is back", async () => {
// Deliberately not covered here: it needs the freight container stopped
// mid-test, which would break every other file sharing this stack.
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
// ponytail: outbox retry asserted only via attempts>0 below; add a
// dedicated single-file stack if this ever regresses.
const rows = await db<{ status: string; attempts: number }>(
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
});
});