feat: add drain tail to the payments

This commit is contained in:
Nathnael
2026-08-04 08:59:33 +00:00
parent a20498fd77
commit acd2cfbe8c
9 changed files with 379 additions and 22 deletions

View File

@@ -64,6 +64,8 @@ import {
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_REMINDER_LEAD_MS,
payWindowLapsed,
paymentDrainMs,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
@@ -692,6 +694,15 @@ export class BookingBatchService implements OnModuleInit {
await this.ensurePaidBookingAllocated(bookingId);
}
/**
* A late settlement paid a partial offer whose window had lapsed — bring the
* offer back so `ensurePaidBookingAllocated`'s applySplit still reduces the
* booking to what was actually bought. No-op without the split feature.
*/
async reviveOfferForInvoice(invoiceId: string): Promise<void> {
await this.splitService?.reviveOfferForInvoice(invoiceId);
}
/**
* Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
* keyed on train_schedule_id, so a booking whose hold was expired (schedule
@@ -2730,11 +2741,13 @@ export class BookingBatchService implements OnModuleInit {
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
// Deadline is the line — no fixed slack. A payment that beat the deadline
// but whose webhook is late is caught by expire()'s gateway reconcile.
// The deadline carries a drain tail (payWindowLapsed): settlement is async,
// so a payment made in the window's last seconds lands after it. Nothing is
// expired until the tail passes. expire()'s gateway reconcile is the second
// line of defence, not the first.
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
? payWindowLapsed(b.paymentDeadline, now)
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
@@ -4596,11 +4609,12 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
// Lazy-expiry guard: a hold whose deadline lapsed no longer blocks
// capacity, even before the 10s sweep flips it to EXPIRED — availability
// shown to the next customer is honest between ticks. A late capture the
// gateway reconcile later confirms lands as PAID and, if the wagons went
// meanwhile, degrades to WAITING_FOR_WAGON for manual placement.
// Lazy-expiry guard: a hold whose deadline AND drain tail lapsed no longer
// blocks capacity, even before the 10s sweep flips it to EXPIRED —
// availability shown to the next customer is honest between ticks. The drain
// has to be honoured here too: releasing the wagons at the raw deadline
// would resell them to someone else while the paying customer's settlement
// is still in flight, stranding it into WAITING_FOR_WAGON.
const deadlineCutoff = Date.now();
const reserved = (
await this.bookingsRepository.findReservedForSchedule(schedule.id)
@@ -4608,8 +4622,7 @@ export class BookingBatchService implements OnModuleInit {
(b) =>
b.paymentStatus === "PAID" ||
b.status === "PAID" ||
b.paymentDeadline == null ||
b.paymentDeadline.getTime() > deadlineCutoff,
!payWindowLapsed(b.paymentDeadline, deadlineCutoff),
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
@@ -4684,7 +4697,9 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* A reservation on this schedule still has time left to pay.
* A reservation on this schedule still has time left to pay — including its
* drain tail, so the cycle cannot conclude out from under a settlement that is
* still in flight.
*
* The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
* is stamped when the phase starts, then `reserve()` gives each booking
@@ -4705,7 +4720,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() > now,
!payWindowLapsed(b.paymentDeadline, now),
);
}
@@ -4914,7 +4929,10 @@ export class BookingBatchService implements OnModuleInit {
*/
private armSettle(scheduleId: string): void {
void this.scheduleById(scheduleId)
// + drain tail: firing at the raw deadline is a guaranteed no-op pass now
// that nothing expires until the tail passes.
.then((schedule) => this.paymentWindowMsFor(schedule))
.then((windowMs: number) => windowMs + paymentDrainMs())
.then((delayMs: number) => {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {