/** * 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(); const invoices = new Map(); 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); }); });