diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index f296dac74..d45309ac1 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -433,7 +433,7 @@ export class RescheduleService { async expireStale(now = new Date()): Promise { 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; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 5c3b74c53..e54c085a1 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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`, ); } } diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts index f0248233c..95d3aee75 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -603,7 +603,7 @@ export class UpgradeService { async expireStale(now = new Date()): Promise { 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; }