mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
feat: add drain tail to the payments
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user