mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|