// Auto-reject unconfirmed payments once their event is over. // // After an event ends, any booking whose payment was never confirmed // (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored. // This job silently fails those payments and cancels their tickets so they stop // lingering as "pending" forever. It deliberately sends NO email — unlike the // admin reject route, this is a housekeeping sweep and users are not notified. // // An event is considered over when COALESCE(end_datetime, start_datetime) is in // the past. Updates are guarded by the current status so re-running is a no-op. import { and, eq, inArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments, events } from '../db/index.js'; import { getNow } from './utils.js'; import { getLock } from './stores/lock.js'; // Payment statuses that represent an unconfirmed booking. const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold']; // Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled). const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold']; /** * Fail unconfirmed payments (and cancel their tickets) for events that have * already ended. Returns the number of payments rejected. */ export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise { // Pull candidate rows first, then decide "ended" in JS so the comparison works // identically for SQLite (ISO text) and Postgres (timestamp) datetime columns. const rows = await dbAll<{ paymentId: string; ticketId: string; endDatetime: string | Date | null; startDatetime: string | Date | null; }>( (db as any) .select({ paymentId: (payments as any).id, ticketId: (tickets as any).id, endDatetime: (events as any).endDatetime, startDatetime: (events as any).startDatetime, }) .from(payments) .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) .innerJoin(events, eq((tickets as any).eventId, (events as any).id)) .where(and( inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), )) ); const nowMs = Date.now(); const ended = rows.filter((r) => { const ref = r.endDatetime || r.startDatetime; if (!ref) return false; return new Date(ref as any).getTime() < nowMs; }); if (ended.length === 0) return 0; const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId))); const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id))); const now = getNow(); // Fail the payments. The status guard keeps this idempotent and avoids // clobbering anything that changed since we read the candidates. await (db as any) .update(payments) .set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now }) .where(and( inArray((payments as any).id, paymentIds), inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), )); // Cancel the associated tickets, freeing any seats they still hold. if (ticketIds.length > 0) { await (db as any) .update(tickets) .set({ status: 'cancelled' }) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), )); } console.log( `[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` + `cancelled ${ticketIds.length} ticket(s).` ); return paymentIds.length; } let sweepTimer: ReturnType | null = null; /** * Start a periodic sweep that auto-rejects unconfirmed payments for ended * events. Each run is guarded by a distributed lock so that, across multiple * replicas, only one instance does the work per interval. */ export function startEventEndSweep(): void { const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min const run = () => { getLock() .withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () => rejectUnconfirmedPaymentsForEndedEvents() ) .catch((err) => console.error('[EventEndSweep] Run failed:', err?.message || err) ); }; // Run shortly after startup, then on the interval. setTimeout(run, 60_000).unref?.(); sweepTimer = setInterval(run, intervalMs); sweepTimer.unref?.(); console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); } export function stopEventEndSweep(): void { if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; } }