Replace manualProviders.ts with a paymentProviders.ts registry (automatic vs manual settlement) and move all seat counting into capacity.ts as the single source of truth: only paid/checked-in tickets and pending_approval payments hold a seat, so abandoned checkouts never block sales. Admins can now knowingly approve a payment over capacity (allowOverCapacity), with the booking and admin UIs updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
// 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<number> {
|
|
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<typeof setInterval> | 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;
|
|
}
|
|
}
|