fix: (bookings) release the seat hold when a reschedule or upgrade expires and honour shortened payment windows

This commit is contained in:
Abubeker Yasin
2026-09-03 14:39:40 +03:00
parent e914aaeb53
commit 9a9955cbaa
3 changed files with 44 additions and 10 deletions

View File

@@ -433,7 +433,7 @@ export class RescheduleService {
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingReschedule.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
select: { id: true, supplementaryChargeId: true, holdId: true },
});
for (const r of stale) {
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
@@ -443,6 +443,14 @@ export class RescheduleService {
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (r.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } });
}
}
return stale.length;
}

View File

@@ -728,24 +728,42 @@ export class SeatsService {
? 0 // unused — the override wins below
: await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES);
let extended = 0;
let aligned = 0;
await Promise.all(
holds.map(async (hold) => {
const departureAt = departureById.get(hold.scheduleId);
if (!departureAt) return;
const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes);
// Only ever extend forward — never shorten a hold that's already valid longer
// than the payment deadline would give it (e.g. a second confirmSeats call on
// the same booking, or a hold that was already extended).
if (deadlineOverride) {
// Authoritative in BOTH directions. The caller already issued a payment link with this
// exact deadline, so the hold must match it — including when it is EARLIER than the
// hold's own TTL. Extending only would leave the seat held after the link that pays for
// it has died (reachable whenever a flow's payment window is shorter than
// seat_hold_duration_minutes), so the seat sits unsellable in between.
if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return;
await this.prisma.seatHold.update({
where: { id: hold.id },
data: { expiresAt: deadlineOverride },
});
aligned++;
return;
}
const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes);
// Normal booking path: only ever extend forward — never shorten a hold that's already
// valid longer than the payment deadline would give it. A round trip calls confirmSeats
// up to four times, and a later call must not pull in a hold an earlier one set.
if (deadline <= hold.expiresAt) return;
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
extended++;
aligned++;
}),
);
if (extended > 0) {
if (aligned > 0) {
this.logger.log(
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
deadlineOverride
? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline`
: `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
);
}
}

View File

@@ -603,7 +603,7 @@ export class UpgradeService {
async expireStale(now = new Date()): Promise<number> {
const stale = await this.prisma.bookingUpgrade.findMany({
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
select: { id: true, supplementaryChargeId: true },
select: { id: true, supplementaryChargeId: true, holdId: true },
});
for (const u of stale) {
await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } });
@@ -613,6 +613,14 @@ export class UpgradeService {
data: { status: 'EXPIRED' },
});
}
// Release the seat the instant the request dies instead of leaving it to the hold's own
// TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at
// creation, and nothing keeps them in step afterwards — so without this the seat can sit
// unsellable long after the link that pays for it has expired. deleteMany: an already-swept
// hold must not throw.
if (u.holdId) {
await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } });
}
}
return stale.length;
}