/** * BULK EXPORT — pay or lose the seat. * * Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T) * and never pays; while that hold lives a late booking is rejected for * space. ZC expires → the late customer immediately books the freed 980 T * and pays. * * Day 2: five reservations fill the train, only three pay. The window close * passes → phase DONE, the two unpaid expire, and export never reopens * (the cycle counter stays 1 — unlike import, which reopens). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { closeDb, db, gateway } from "./client"; import { EXP_DEST, EXP_ORIGIN, bookBulk, bookBulkReady, expectDayRefused, bookingRow, createSchedule, departureAt, eatDayStr, ensureExportRoute, extendPayWindow, forceReservationExpiry, forceWindowOpen, payViaGateway, pollAllocations, pollBookingStatus, pollWindow, releaseUnpaidHolds, resetCorridorDay, scheduleRow, seedTenantContracts, } from "./flows"; const DEPARTURE = departureAt(33); const BOOKING_DAY = eatDayStr(DEPARTURE); const CLOSE_DEPARTURE = departureAt(34); const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE); const STAMP = String(Date.now()); const CLOSERS = [ { suffix: "ZQA", tons: 840, wagons: 12, pays: true }, { suffix: "ZQB", tons: 840, wagons: 12, pays: true }, { suffix: "ZQC", tons: 840, wagons: 12, pays: true }, { suffix: "ZQD", tons: 840, wagons: 12, pays: false }, { suffix: "ZQE", tons: 420, wagons: 6, pays: false }, ]; describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", () => { const booking = new Map(); let contracts: Map; let scheduleId: string; let closeScheduleId: string; beforeAll(async () => { await gateway.reset(); await releaseUnpaidHolds(); await ensureExportRoute(); await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST); contracts = await seedTenantContracts( STAMP, ["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({ suffix, freight: "BULK" as const, direction: "EXPORT" as const, })), ); scheduleId = ( await createSchedule({ departure: DEPARTURE, kind: "bulk", locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"], originCode: EXP_ORIGIN, destCode: EXP_DEST, }) ).id; await forceWindowOpen(scheduleId, 60); }, 1_800_000); afterAll(closeDb); it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => { for (const [suffix, wagons] of [ ["ZA", 20], ["ZB", 20], ] as const) { const id = await bookBulkReady({ contractId: contracts.get(suffix)!, tons: 1400, scheduledDate: BOOKING_DAY, mode: "export", }); booking.set(suffix, id); await payViaGateway(id); await pollAllocations(id, wagons); } const zc = await bookBulkReady({ contractId: contracts.get("ZC")!, tons: 980, scheduledDate: BOOKING_DAY, mode: "export", }); booking.set("ZC", zc); const schedule = await scheduleRow(scheduleId); expect( new Date((await bookingRow(zc)).payment_deadline!).getTime(), "ZC deadline clamped to the window close", ).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime()); }, 900_000); it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => { const res = await expectDayRefused({ contractId: contracts.get("ZDLATE")!, tons: 980, scheduledDate: BOOKING_DAY, }); expect(res.status).toBeGreaterThanOrEqual(400); expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i); }); it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => { await forceReservationExpiry(booking.get("ZC")!); const zd = await bookBulkReady({ contractId: contracts.get("ZD")!, tons: 980, scheduledDate: BOOKING_DAY, mode: "export", }); booking.set("ZD", zd); await payViaGateway(zd); await pollAllocations(zd, 14); expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId); }, 900_000); it("window-close day: five reservations, three payments", async () => { closeScheduleId = ( await createSchedule({ departure: CLOSE_DEPARTURE, kind: "bulk", locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"], originCode: EXP_ORIGIN, destCode: EXP_DEST, }) ).id; await forceWindowOpen(closeScheduleId, 60); for (const c of CLOSERS) { booking.set( c.suffix, await bookBulkReady({ contractId: contracts.get(c.suffix)!, tons: c.tons, scheduledDate: CLOSE_DAY, mode: "export", }), ); } await extendPayWindow( closeScheduleId, CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!), ); for (const c of CLOSERS.filter((x) => x.pays)) { await payViaGateway(booking.get(c.suffix)!); await pollAllocations(booking.get(c.suffix)!, c.wagons); } }, 1_800_000); it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => { await db( `UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second' WHERE id = $1 AND window_phase = 'OPEN'`, [closeScheduleId], ); await pollWindow( closeScheduleId, (s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1, "DONE, no reopen", ); // A forced close needs the matching deadline clamp on the unpaid pair. for (const c of CLOSERS.filter((x) => !x.pays)) { await forceReservationExpiry(booking.get(c.suffix)!); await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40); } for (const c of CLOSERS.filter((x) => x.pays)) { expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID"); } }, 900_000); });