Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email. Co-authored-by: Cursor <cursoragent@cursor.com>
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
// 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<number> {
|
|
// 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<typeof setInterval> | 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;
|
|
}
|
|
}
|