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

@@ -30,6 +30,12 @@ FREIGHT_PORTAL_URL=http://localhost:5173
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# Drain tail (minutes) added to every booking pay window before anything expires:
# settlement is asynchronous, so a payment made in the window's last seconds lands
# after the deadline. Nothing is expired, no wagons are resold and no window cycle
# concludes until the tail passes. Defaults to 5 when unset.
FREIGHT_PAYMENT_DRAIN_MINUTES=5
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

@@ -203,6 +203,129 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
/**
* A gateway success can land after the pay window AND its drain tail (relay
* backlog, payment-api restart, a CBE bill paid at a counter). The money is
* captured either way, so the settle lookup must accept an EXPIRED invoice —
* matching only OPEN_STATUSES used to drop it silently, leaving a debited
* customer with an EXPIRED invoice, an EXPIRED booking and no alert.
*/
describe("BillingService.settleByPaymentId", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
// The lookup is by paymentId ALONE — status is judged on the resolved row,
// so a stale capture can never skip past a newer invoice to an older one.
const findOne = jest.fn(({ where }: { where: Record<string, unknown> }) => {
expect(where).toEqual({ paymentId: "pay-1" });
return Promise.resolve(invoice);
});
const dataSource = {
getRepository: () => ({ findOne }),
transaction: (cb: (mg: unknown) => unknown) => cb(mg),
manager: mg,
};
const service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
it("settles an EXPIRED invoice — the money was already captured", async () => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Expired,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
const settled = await service.settleByPaymentId("pay-1", "txn-1");
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.Paid,
paidAmount: 1500,
balanceAmount: 0,
}),
);
// The domain reacts to this — it is what revives the expired booking.
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1" }),
);
});
it("still settles an open (PENDING) invoice", async () => {
const { service, mg } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
await service.settleByPaymentId("pay-1");
expect(mg.update).toHaveBeenCalled();
});
/**
* `upsertIntent` keeps ONE local payments row per booking reference, so every
* invoice the booking was ever charged on carries the same `paymentId`. A
* capture from a lapsed first attempt must not reach back past the invoice the
* customer actually paid and settle the older EXPIRED one — that would mark two
* invoices paid off a single payment.
*/
it("no-ops when the booking's newest invoice is already PAID", async () => {
const { service, mg, events } = serviceFor({
id: "inv-2",
status: Freight.InvoiceStatus.Paid,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it.each([
["CANCELLED", Freight.InvoiceStatus.Cancelled],
["REFUNDED", Freight.InvoiceStatus.Refunded],
])("does not settle a %s invoice — that is a refund case", async (
_label,
status,
) => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {

View File

@@ -1162,6 +1162,25 @@ export class BillingService {
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
* to advance on. Idempotent — no-op when no open invoice is linked (already
* settled, or settled inline by {@link payInvoice}).
*
* EXPIRED is settleable HERE and only here: this is the gateway path, so the
* money is already captured and we are recording a fait accompli. A success can
* land after the pay window plus its drain tail (relay backlog, payment-api
* restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to
* drop it silently, leaving a debited customer with an EXPIRED invoice and no
* alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard:
* a teller must not accept cash against a lapsed invoice.
*
* The status is checked on the RESOLVED invoice, never inside the lookup.
* `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE
* row per booking reference across every pay attempt — so a booking that was
* re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same
* `paymentId`. Filtering by status inside the query would let a late capture from
* attempt 1 skip past the already-PAID attempt-2 invoice and settle the older
* EXPIRED one, marking two invoices paid off a single capture. Resolving the
* newest invoice first and then asking whether IT is settleable makes the answer
* "this booking's money is already recorded" instead. CANCELLED/REFUNDED are
* refund cases, not settlements, and are logged rather than settled.
*/
async settleByPaymentId(
paymentId: string,
@@ -1169,11 +1188,31 @@ export class BillingService {
paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
where: { paymentId },
// NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
if (!invoice) return null;
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
Freight.InvoiceStatus.Expired,
];
if (!settleable.includes(invoice.status)) {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +
`(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` +
`needs a refund or a manual settlement.`,
);
}
return null;
}
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,

View File

@@ -102,6 +102,10 @@ export class BookingInvoiceService {
);
switch (payload.type) {
case "PREPAID":
// Before advancing: if this invoice belonged to a partial offer that
// lapsed before the settlement landed, revive it, or the booking boards
// whole having paid only the offered part.
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
@@ -164,15 +168,24 @@ export class BookingInvoiceService {
// may have moved on or been terminated between invoicing and settlement.
// Only advance one that is still awaiting payment: no-op when already PAID,
// and refuse to advance a booking in a terminal/advanced status
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
// rewrite its status or re-run allocation.
// (CANCELLED/REJECTED or already past the payment gate) so we never rewrite
// its status or re-run allocation.
//
// EXPIRED is NOT in that list: settlement is async, so a payment can land
// after the pay window and its drain tail (relay backlog, payment-api
// restart, a CBE bill paid at a counter). The money was captured, so it gets
// exactly the same treatment as an in-window payment — the booking becomes
// PAID and ensurePaidBookingAllocated re-places it via
// replaceStrandedPaidBooking (a same-day train with room, or a manual-assign
// log). Leaving EXPIRED here debited the customer for nothing. CANCELLED and
// REJECTED stay: a person terminated those, so a payment against them is a
// refund case, not a boarding.
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
return;
}
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
"CANCELLED",
"REJECTED",
"EXPIRED",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",

View File

@@ -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;

View File

@@ -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,

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(() => {

View File

@@ -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' },

View File

@@ -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);
}
});
});