mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
feat: add drain tail to the payments
This commit is contained in:
@@ -7,11 +7,51 @@
|
||||
* (BookingWindowService) drives all timing off that config.
|
||||
*/
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
export const BATCH_TIMEZONE = "Africa/Addis_Ababa";
|
||||
|
||||
/** How long before the pay deadline the one reminder notification goes out. */
|
||||
export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
|
||||
|
||||
/** Drain tail applied to every pay window when FREIGHT_PAYMENT_DRAIN_MINUTES is unset. */
|
||||
export const DEFAULT_PAYMENT_DRAIN_MINUTES = 7;
|
||||
|
||||
/**
|
||||
* Drain tail on every pay window, in ms. Read per call so the env var can be
|
||||
* changed without a rebuild (and so tests can set it).
|
||||
*/
|
||||
export function paymentDrainMs(): number {
|
||||
// An empty/blank value is UNSET, not zero — a bare `FREIGHT_PAYMENT_DRAIN_MINUTES=`
|
||||
// left in a .env must not silently disable the drain (Number('') is 0).
|
||||
const raw = process.env.FREIGHT_PAYMENT_DRAIN_MINUTES?.trim();
|
||||
const minutes = raw ? Number(raw) : NaN;
|
||||
return (
|
||||
(Number.isFinite(minutes) && minutes >= 0
|
||||
? minutes
|
||||
: DEFAULT_PAYMENT_DRAIN_MINUTES) * 60_000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pay window AND its drain tail have closed.
|
||||
*
|
||||
* Settlement is asynchronous (provider confirm → payment-api → outbox relay), so
|
||||
* a payment made in the last seconds of the window lands after `paymentDeadline`.
|
||||
* The drain defers the WHOLE expiry pipeline — wagons stay held, the waiting list
|
||||
* is not promoted, the window cycle does not conclude — so that settlement still
|
||||
* has a live booking to land on. A payment that arrives even later is not lost
|
||||
* either: it settles the expired invoice and revives the booking (see
|
||||
* BillingService.settleByPaymentId + BookingInvoiceService.advanceBookingOnPayment).
|
||||
*
|
||||
* No deadline ⇒ never lapsed; callers decide what an unknown deadline means.
|
||||
*/
|
||||
export function payWindowLapsed(
|
||||
deadline: Date | null | undefined,
|
||||
now: number,
|
||||
drainMs: number = paymentDrainMs(),
|
||||
): boolean {
|
||||
return deadline != null && deadline.getTime() + drainMs <= now;
|
||||
}
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { paymentDrainMs } from './booking-batch.constants';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
|
||||
/**
|
||||
* A pay window closed long enough ago to be past its drain tail as well — i.e.
|
||||
* genuinely expirable. Inside the tail nothing expires (see payWindowLapsed).
|
||||
*/
|
||||
const fullyLapsedDeadline = () => new Date(Date.now() - 60_000 - paymentDrainMs());
|
||||
|
||||
describe('BookingBatchService — PAID reconcile', () => {
|
||||
const scheduleId = 'schedule-1';
|
||||
const bookingId = 'booking-1';
|
||||
@@ -856,7 +863,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
// One reservation whose pay window lapsed, and one booking on the waiting list.
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
paymentDeadline: fullyLapsedDeadline(),
|
||||
});
|
||||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||||
|
||||
@@ -888,10 +895,41 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||||
});
|
||||
|
||||
it('holds a reservation whose deadline passed but whose drain tail has not', async () => {
|
||||
// Settlement is asynchronous, so a payment made in the window's last
|
||||
// seconds lands after the deadline. Expiring here would free the wagons
|
||||
// out from under it — the swallowed-payment finding.
|
||||
const draining = booking('draining', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
});
|
||||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||||
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([draining])
|
||||
.mockResolvedValue([]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay
|
||||
.mockResolvedValueOnce([waiting])
|
||||
.mockResolvedValue([]);
|
||||
const byId: Record<string, Booking> = { draining, waiting };
|
||||
dataSource
|
||||
.getRepository()
|
||||
.findOne.mockImplementation(
|
||||
async (opts: { where?: { id?: string } }) =>
|
||||
byId[opts?.where?.id ?? ''] ?? null,
|
||||
);
|
||||
|
||||
await service.settleDueReservations(trainId);
|
||||
|
||||
expect(notifier.expired).not.toHaveBeenCalled();
|
||||
// ...and its wagons were NOT handed to the waiting list either.
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
|
||||
const lapsed = booking('lapsed', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
paymentDeadline: fullyLapsedDeadline(),
|
||||
});
|
||||
// Both callers read the reservation; the lock must stop the second from
|
||||
// acting on rows the first already expired. (The PAYMENT transition and the
|
||||
@@ -922,7 +960,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
it('never expires a reservation whose payment landed — allocates it instead', async () => {
|
||||
const latePaid = booking('late-paid', 50, {
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
paymentDeadline: fullyLapsedDeadline(),
|
||||
});
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([latePaid])
|
||||
@@ -1036,7 +1074,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
...(waiting as unknown as Record<string, unknown>),
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
trainScheduleId: exportScheduleId,
|
||||
paymentDeadline: new Date(Date.now() - 60_000),
|
||||
paymentDeadline: fullyLapsedDeadline(),
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
priorityScore: 0,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -302,6 +302,19 @@ export class BookingSplitService {
|
||||
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
|
||||
}
|
||||
|
||||
/**
|
||||
* A late gateway settlement paid the invoice of an offer whose window had
|
||||
* already lapsed. The customer bought the offered part, so the offer is live
|
||||
* again and {@link applySplit} must reduce the booking to it — otherwise the
|
||||
* booking boards WHOLE having paid only the offered portion. Keyed on the paid
|
||||
* invoice, never on the booking: an offer that simply timed out stays dead.
|
||||
*/
|
||||
async reviveOfferForInvoice(invoiceId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update({ invoiceId, status: 'EXPIRED' }, { status: 'OFFERED' });
|
||||
}
|
||||
|
||||
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
|
||||
return this.dataSource.getRepository(BookingBatchOffer).findOne({
|
||||
where: { bookingId, status: 'OFFERED' },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DEFAULT_PAYMENT_DRAIN_MINUTES,
|
||||
paymentDrainMs,
|
||||
payWindowLapsed,
|
||||
} from "./booking-batch.constants";
|
||||
|
||||
/**
|
||||
* The drain tail is what keeps a pay window's LAST payment from being thrown
|
||||
* away: settlement is asynchronous (provider confirm → payment-api → outbox
|
||||
* relay), so a payment made in the window's final seconds lands after
|
||||
* `paymentDeadline`. Nothing may expire, no wagons may be resold and no window
|
||||
* cycle may conclude until the tail has passed.
|
||||
*/
|
||||
describe("payWindowLapsed — pay-window drain tail", () => {
|
||||
const deadline = new Date("2026-08-02T22:04:41Z");
|
||||
const at = (offsetMs: number) => deadline.getTime() + offsetMs;
|
||||
const MIN = 60_000;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.FREIGHT_PAYMENT_DRAIN_MINUTES;
|
||||
});
|
||||
|
||||
it("is not lapsed before the deadline", () => {
|
||||
expect(payWindowLapsed(deadline, at(-1 * MIN))).toBe(false);
|
||||
});
|
||||
|
||||
it("is not lapsed inside the drain tail", () => {
|
||||
// The reproduced finding: settled ~7 minutes late. With the default 5-minute
|
||||
// tail the booking is still live at 4 minutes.
|
||||
expect(payWindowLapsed(deadline, at(4 * MIN))).toBe(false);
|
||||
expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN - 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is lapsed once the tail passes", () => {
|
||||
expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(payWindowLapsed(deadline, at(7 * MIN))).toBe(true);
|
||||
});
|
||||
|
||||
it("never lapses without a deadline — callers decide what unknown means", () => {
|
||||
expect(payWindowLapsed(null, at(60 * MIN))).toBe(false);
|
||||
expect(payWindowLapsed(undefined, at(60 * MIN))).toBe(false);
|
||||
});
|
||||
|
||||
it("honours FREIGHT_PAYMENT_DRAIN_MINUTES", () => {
|
||||
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "20";
|
||||
expect(paymentDrainMs()).toBe(20 * MIN);
|
||||
expect(payWindowLapsed(deadline, at(10 * MIN))).toBe(false);
|
||||
expect(payWindowLapsed(deadline, at(20 * MIN))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an explicit zero drain (old deadline-is-the-line behaviour)", () => {
|
||||
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "0";
|
||||
expect(paymentDrainMs()).toBe(0);
|
||||
expect(payWindowLapsed(deadline, at(0))).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the default on garbage or negative values", () => {
|
||||
for (const bad of ["", "abc", "-3"]) {
|
||||
process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = bad;
|
||||
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user