fix(payments): reconcile with gateway before expiring unpaid holds

Replace the fixed 5-minute grace with a settlement check at expiry:
expire() calls the payment API's reconcile endpoint — paid intents are
kept and allocated via payment.succeeded, unverifiable results defer
expiry to the next tick, only verifiably unpaid holds expire.
This commit is contained in:
Marshal
2026-07-30 12:01:59 +00:00
parent 22de05fe8e
commit acb6f286d4
8 changed files with 130 additions and 42 deletions

View File

@@ -61,7 +61,6 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_GRACE_MS,
PAYMENT_REMINDER_LEAD_MS,
} from "./booking-batch.constants";
import {
@@ -2368,11 +2367,11 @@ export class BookingBatchService implements OnModuleInit {
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
// Grace: a payment started inside the window may land minutes late via the
// gateway webhook — don't expire until the slack has passed too.
// 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.
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() + PAYMENT_GRACE_MS <= now
? b.paymentDeadline.getTime() <= now
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
@@ -3062,6 +3061,31 @@ export class BookingBatchService implements OnModuleInit {
}
return;
}
// Reconcile-before-expire (only when a pay window was actually open):
// no webhook arrived, so ask the gateway DIRECTLY whether the money
// landed. A late capture found there is registered as SUCCEEDED and
// emits payment.succeeded — that event marks the booking PAID and
// allocates it, so we just leave the hold alone here. `unverifiable`
// (provider query errored / payment still in flight) means we could not
// confirm "not paid" — never expire on unknown; the next settle tick
// asks again.
if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
const reconcile = await this.billing.reconcilePayable(booking.id);
if (reconcile.paid) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — gateway ` +
`reconcile found a settled payment; payment.succeeded will allocate it`,
);
return;
}
if (reconcile.unverifiable) {
this.logger.warn(
`[BATCH] expire deferred for ${booking.reference} — settlement ` +
`unverifiable at the gateway; retrying next settle tick`,
);
return;
}
}
}
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
@@ -4037,10 +4061,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 + grace has lapsed no longer
// blocks capacity, even before the 10s sweep flips it to EXPIRED — so
// availability shown to the next customer is honest between ticks.
const graceCutoff = Date.now() - PAYMENT_GRACE_MS;
// 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.
const deadlineCutoff = Date.now();
const reserved = (
await this.bookingsRepository.findReservedForSchedule(schedule.id)
).filter(
@@ -4048,7 +4074,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus === "PAID" ||
b.status === "PAID" ||
b.paymentDeadline == null ||
b.paymentDeadline.getTime() > graceCutoff,
b.paymentDeadline.getTime() > deadlineCutoff,
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
@@ -4144,7 +4170,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() + PAYMENT_GRACE_MS > now,
b.paymentDeadline.getTime() > now,
);
}
@@ -4368,10 +4394,7 @@ export class BookingBatchService implements OnModuleInit {
private armSettle(scheduleId: string): void {
void this.scheduleById(scheduleId)
.then((schedule) => this.paymentWindowMsFor(schedule))
// The timer covers the grace too — firing at the bare deadline would
// settle before the sweep's grace cutoff and find nothing to expire.
.then((windowMs: number) => {
const delayMs = windowMs + PAYMENT_GRACE_MS;
.then((delayMs: number) => {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>