/** * The swallowed late payment * (docs/dev-testing/finding-expired-invoice-swallows-payment.md). * * Settlement is asynchronous — customer taps pay → provider confirms → payment * API enqueues → relay delivers — so the success can land after the booking * window cron has already expired the invoice. Before the fix the money simply * vanished: the intent flipped to SUCCEEDED, `settleByPaymentId` matched no OPEN * invoice and returned null, the relay was told `processed: true`, and the * customer was left debited with an EXPIRED invoice and an EXPIRED booking. * * Two layers are asserted here: * 1. the drain tail — a deadline that just passed does NOT expire anything * (FREIGHT_PAYMENT_DRAIN_MINUTES, 1 in this stack); * 2. the backstop — a settlement that lands after the drain is treated exactly * like an in-window payment: invoice PAID, booking PAID. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { apiOk, chief, closeDb, db, gateway, poll } from "./client"; import { createImportSchedule, currentInvoice, departureAt, ensureCorridorRoute, forceWindowOpen, freightPayment, gatewayIntent, invoiceForBooking, payInvoice, prepareBooking, releaseUnpaidHolds, resetCorridorDay, runBatch, type ReadyBooking, } from "./flows"; const DEPARTURE = departureAt(4); const STAMP = String(Date.now()); /** The stack runs a 1-minute tail (FREIGHT_PAYMENT_DRAIN_MINUTES, docker-compose.it.yaml). */ const DRAIN_MS = 60_000; /** Long enough for several 10s settle ticks, comfortably inside the tail. */ const INSIDE_TAIL_MS = DRAIN_MS * 0.4; const bookingRow = async (id: string) => ( await db<{ status: string; payment_status: string; train_schedule_id: string | null }>( `SELECT status, payment_status, train_schedule_id FROM freight.bookings WHERE id = $1`, [id], ) )[0]; describe("a payment that lands after the pay window is not swallowed", () => { let booking: ReadyBooking; let invoiceId: string; beforeAll(async () => { await gateway.reset(); await releaseUnpaidHolds(); await ensureCorridorRoute(); await resetCorridorDay(DEPARTURE); const schedule = await createImportSchedule({ departure: DEPARTURE }); await forceWindowOpen(schedule.id, 45); booking = await prepareBooking({ suffix: "LATE1", departure: DEPARTURE, runStamp: STAMP, isoSeed: 0, twenty: 2, }); await runBatch(schedule.id); invoiceId = (await invoiceForBooking(booking.bookingId)).id; }); afterAll(closeDb); it("holds the reservation while the drain tail runs", async () => { // Deadline just behind now(): the settle tick sees it every 10s and must // leave it alone — this is the customer who tapped pay in the last seconds. await db( `UPDATE freight.bookings SET payment_deadline = now() - interval '5 seconds' WHERE id = $1`, [booking.bookingId], ); await new Promise((r) => setTimeout(r, INSIDE_TAIL_MS)); const held = await bookingRow(booking.bookingId); expect(held.status).toBe("SELECTED_FOR_BATCH"); // The wagons are still HIS — releasing them at the raw deadline would sell // them to the next customer while his settlement is still in flight. expect(held.train_schedule_id).toBeTruthy(); expect((await currentInvoice(invoiceId)).status).not.toBe("EXPIRED"); }, 60_000); it("expires the hold while the customer's payment is still in flight", async () => { // Open the intent first — payInvoice refuses once dueAt is behind us, which // is the point: no NEW payment may start, only an in-flight one may land. const res = await payInvoice(invoiceId, { method: "CBE_BIRR" }); expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); const intent = await gatewayIntent(booking.bookingId); expect(intent.status).toBe("REQUIRES_ACTION"); expect((await currentInvoice(invoiceId)).payment_id).toBeTruthy(); // The provider reported a failure and we retired the intent — the same shape // the payment API's own sweep writes. This is what makes the hold expirable: // reconcile-before-expire skips FAILED candidates (intents.service.ts:412) // and answers a clean "not paid". While the intent is live the mock answers // PROCESSING → `unverifiable` → NOTHING expires the hold, not the settle tick // and not staff. The money can still land afterwards: `applyProviderResult` // registers a late capture on a retired intent (intents.service.ts:576). await db( `UPDATE edr_payment.payment_intent SET status = 'FAILED' WHERE id = $1`, [intent.id], ); await apiOk(chief, "post", `/api/train-scheduling/bookings/${booking.bookingId}/expire`); const expired = await poll<{ status: string }>( "invoice EXPIRED with the hold", `SELECT status FROM freight.invoices WHERE id = $1`, [invoiceId], (row) => row?.status === "EXPIRED", { attempts: 20, intervalMs: 2000 }, ); expect(expired.status).toBe("EXPIRED"); expect((await bookingRow(booking.bookingId)).status).toBe("EXPIRED"); // The settlement correlation key survives the expiry — `paymentId` is what // settleByPaymentId looks the invoice up by when the money finally lands. const linked = (await currentInvoice(invoiceId)).payment_id!; expect((await freightPayment(linked)).merchant_order_id).toBe( intent.merchant_order_id, ); }, 180_000); it("settles the EXPIRED invoice and revives the booking when the money lands", async () => { const intent = await gatewayIntent(booking.bookingId); const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id }); expect(res.body.delivered).toBe(200); const settled = await poll<{ status: string }>( "payment intent SUCCEEDED", `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, [intent.id], (row) => row?.status === "SUCCEEDED", { attempts: 20, intervalMs: 1000 }, ); expect(settled.status).toBe("SUCCEEDED"); // The bug: this row used to sit at EXPIRED with paid_amount null forever, // because settleByPaymentId only matched OPEN_STATUSES. const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>( "expired invoice settled by the late payment", `SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`, [invoiceId], (row) => row?.status === "PAID", { attempts: 30, intervalMs: 2000 }, ); expect(Number(invoice.paid_amount)).toBeGreaterThan(0); expect(Number(invoice.balance_amount)).toBe(0); // …and the second swallow point: advanceBookingOnPayment used to refuse an // EXPIRED booking outright, so the money landed on a booking that stayed dead. const revived = await poll<{ status: string }>( "expired booking revived by the late payment", `SELECT status FROM freight.bookings WHERE id = $1`, [booking.bookingId], (row) => row?.status === "PAID", { attempts: 30, intervalMs: 2000 }, ); expect(revived.status).toBe("PAID"); expect((await bookingRow(booking.bookingId)).payment_status).toBe("PAID"); }, 180_000); });