// Expire stale pending bookings. // // When a booking is started, its tickets are created with status 'pending' and // a 'pending' payment. Pending tickets count toward an event's capacity, so an // abandoned checkout would otherwise hold those seats forever. This job cancels // pending tickets whose payment is still 'pending' (i.e. never paid and not // awaiting admin approval) after a configurable TTL, freeing the seats. // // Two exclusions: // - Manual-verification providers (bank transfer / TPago / cash) are never // auto-failed; they are settled by an admin and instead follow the 72h on-hold // sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS. // - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action // (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather // than being immediately re-failed on the next run. import { and, eq, lt, inArray, notInArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments } from '../db/index.js'; import { getNow, toDbDate } from './utils.js'; import { getLock } from './stores/lock.js'; import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js'; function getTtlMs(): number { const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10); return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000; } /** * Cancel stale pending bookings. Returns the number of tickets cancelled. * * A booking is considered stale when its payment is still 'pending' (not * 'pending_approval', which means an admin is reviewing a manual transfer), * uses a non-manual provider, and has not been touched (updatedAt) for * PENDING_BOOKING_TTL_MINUTES. */ export async function cleanupStalePendingBookings(): Promise { const cutoff = toDbDate(new Date(Date.now() - getTtlMs())); const stale = await dbAll<{ ticketId: string | null; paymentId: string }>( (db as any) .select({ ticketId: (payments as any).ticketId, paymentId: (payments as any).id, }) .from(payments) .where(and( eq((payments as any).status, 'pending'), notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), lt((payments as any).updatedAt, cutoff) )) ); if (stale.length === 0) return 0; const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id); const paymentIds = stale.map((s) => s.paymentId); const now = getNow(); let cancelledTickets = 0; if (ticketIds.length > 0) { const result: any = await (db as any) .update(tickets) .set({ status: 'cancelled' }) .where(and( inArray((tickets as any).id, ticketIds), eq((tickets as any).status, 'pending') )); cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length; } await (db as any) .update(payments) .set({ status: 'failed', updatedAt: now }) .where(inArray((payments as any).id, paymentIds)); console.log( `[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` + `cancelled ${cancelledTickets} ticket(s).` ); return cancelledTickets; } let cleanupTimer: ReturnType | null = null; /** * Start a periodic cleanup of stale pending bookings. Each run is guarded by a * distributed lock so that, across multiple replicas, only one instance does * the work per interval. */ export function startBookingCleanup(): void { const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min const run = () => { getLock() .withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () => cleanupStalePendingBookings() ) .catch((err) => console.error('[BookingCleanup] Run failed:', err?.message || err) ); }; // Run shortly after startup, then on the interval. setTimeout(run, 30_000).unref?.(); cleanupTimer = setInterval(run, intervalMs); cleanupTimer.unref?.(); console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`); } export function stopBookingCleanup(): void { if (cleanupTimer) { clearInterval(cleanupTimer); cleanupTimer = null; } }