// Auto-hold stale unsettled manual-payment bookings. // // This job moves abandoned manual-payment bookings (bank transfer / TPago / cash) // to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e. // the customer never clicked "I've paid" and no admin settled them. These are exempt // from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in // lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps // dead checkouts out of the admin's pending queues. // // 'pending_approval' (customer claims they paid) is deliberately NOT swept: a // claimed payment keeps its seat until an admin approves or rejects it — the admin // UI surfaces aging claims instead of silently releasing them. // // The user receives no notification — they can recover via "I've paid", and an // admin can approve/reactivate directly; every recovery path re-checks capacity. import { and, eq, lt, inArray } 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 getThresholdMs(): number { const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000; } /** * Move stale unsettled manual payments (and their tickets) to 'on_hold'. * Covers only bare 'pending' payments on manual providers; 'pending_approval' * is never swept. Returns the number of payments put on hold. */ export async function sweepStaleApprovals(): Promise { const cutoff = toDbDate(new Date(Date.now() - getThresholdMs())); 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'), inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), lt((payments as any).createdAt, 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(); await (db as any) .update(payments) .set({ status: 'on_hold', updatedAt: now }) .where(inArray((payments as any).id, paymentIds)); if (ticketIds.length > 0) { await (db as any) .update(tickets) .set({ status: 'on_hold' }) .where(and( inArray((tickets as any).id, ticketIds), eq((tickets as any).status, 'pending') )); } console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`); return stale.length; } let sweepTimer: ReturnType | null = null; /** * Start a periodic sweep of stale pending-approval payments. Each run is guarded by * a distributed lock so that, across multiple replicas, only one instance does the * work per interval. */ export function startHoldSweep(): void { const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min const run = () => { getLock() .withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () => sweepStaleApprovals() ) .catch((err) => console.error('[HoldSweep] Run failed:', err?.message || err) ); }; // Run shortly after startup, then on the interval. setTimeout(run, 45_000).unref?.(); sweepTimer = setInterval(run, intervalMs); sweepTimer.unref?.(); console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); } export function stopHoldSweep(): void { if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; } }