diff --git a/backend/src/lib/bookingCleanup.ts b/backend/src/lib/bookingCleanup.ts index 5d54a49..2e331f1 100644 --- a/backend/src/lib/bookingCleanup.ts +++ b/backend/src/lib/bookingCleanup.ts @@ -5,11 +5,20 @@ // 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 } from 'drizzle-orm'; +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 './manualProviders.js'; function getTtlMs(): number { const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10); @@ -20,8 +29,9 @@ function getTtlMs(): number { * 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) and - * older than PENDING_BOOKING_TTL_MINUTES. + * '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())); @@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise { .from(payments) .where(and( eq((payments as any).status, 'pending'), - lt((payments as any).createdAt, cutoff) + notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), + lt((payments as any).updatedAt, cutoff) )) ); diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index ebdb5c5..d80adb5 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -1,10 +1,15 @@ -// Shared capacity-checked recovery for on-hold bookings. +// Shared capacity-checked recovery for released bookings. // -// When a booking is put on hold, its ticket(s) drop out of the capacity-counting -// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering -// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it -// paid) must atomically re-check that the event still has room before re-reserving -// the seat, exactly like the original booking-creation flow in routes/tickets.ts. +// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the +// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. +// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking +// it paid / reopening a failed payment) must atomically re-check that the event still has +// room before re-reserving the seat, exactly like the original booking-creation flow in +// routes/tickets.ts. +// +// Callers choose which ticket statuses are eligible to be re-reserved via +// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and +// tickets that already hold a seat — are left untouched and don't consume capacity. import { eq, and, inArray, sql } from 'drizzle-orm'; import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js'; @@ -19,22 +24,29 @@ export class HoldCapacityError extends Error { interface ReserveOptions { paidByAdminId?: string; extraPaymentFields?: Record; + /** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */ + fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>; } /** - * Re-reserve seats for a group of on-hold tickets (e.g. all tickets sharing a + * Re-reserve seats for a group of released tickets (e.g. all tickets sharing a * bookingId), atomically re-checking capacity before flipping their status. - * Throws HoldCapacityError if the event no longer has room for ticketIds.length seats. + * Only tickets whose current status is in `fromTicketStatuses` are flipped. + * Capacity is asserted against the number of those tickets that don't already + * hold a seat, so re-reserving tickets that are already seated is a no-op. + * Throws HoldCapacityError if the event no longer has room. */ export async function reserveOnHoldBooking( eventId: string, ticketIds: string[], targetTicketStatus: 'pending' | 'confirmed', - targetPaymentStatus: 'pending_approval' | 'paid', + targetPaymentStatus: 'pending_approval' | 'paid' | 'pending', options: ReserveOptions = {} ): Promise { if (ticketIds.length === 0) return; + const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold']; + const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) ); @@ -53,12 +65,15 @@ export async function reserveOnHoldBooking( if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; } - const assertCapacity = (reserved: number) => { + // `needed` is how many of these tickets don't currently hold a seat and so must + // be found new capacity; tickets already in a seat-holding status cost nothing. + const assertCapacity = (reserved: number, needed: number) => { + if (needed <= 0) return; if (isEventSoldOut(event.capacity, reserved)) { throw new HoldCapacityError(0); } const seatsLeft = calculateAvailableSeats(event.capacity, reserved); - if (ticketIds.length > seatsLeft) { + if (needed > seatsLeft) { throw new HoldCapacityError(seatsLeft); } }; @@ -73,13 +88,21 @@ export async function reserveOnHoldBooking( sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` )) .get(); - assertCapacity(Number(countRow?.count || 0)); + const neededRow = tx + .select({ count: sql`count(*)` }) + .from(tickets) + .where(and( + inArray((tickets as any).id, ticketIds), + sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` + )) + .get(); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); tx.update(tickets) .set({ status: targetTicketStatus }) .where(and( inArray((tickets as any).id, ticketIds), - eq((tickets as any).status, 'on_hold') + inArray((tickets as any).status, fromTicketStatuses) )) .run(); @@ -99,13 +122,22 @@ export async function reserveOnHoldBooking( sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` )) ); - assertCapacity(Number(countRow?.count || 0)); + const neededRow = await dbGet( + tx + .select({ count: sql`count(*)` }) + .from(tickets) + .where(and( + inArray((tickets as any).id, ticketIds), + sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` + )) + ); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); await tx.update(tickets) .set({ status: targetTicketStatus }) .where(and( inArray((tickets as any).id, ticketIds), - eq((tickets as any).status, 'on_hold') + inArray((tickets as any).status, fromTicketStatuses) )); await tx.update(payments) diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts index ebc5905..912003c 100644 --- a/backend/src/lib/holdSweep.ts +++ b/backend/src/lib/holdSweep.ts @@ -1,17 +1,23 @@ -// Auto-hold stale pending-approval bookings. +// Auto-hold stale manual-payment bookings. // -// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual -// payment method (bank transfer / TPago) and is waiting for an admin to review it. -// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment -// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses -// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event. -// The user receives no notification — they can recover via "I've paid" again, and an -// admin can reactivate or mark it paid directly, both re-checking capacity. +// This job releases the seat held by an abandoned manual-payment booking (bank +// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of +// which keep a seat reserved while awaiting a human: +// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin. +// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was +// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts, +// so this is their only seat-release path). +// In either case the payment (and its ticket) is silently moved to 'on_hold', which +// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in') +// and so releases the seat back to the event. The user receives no notification — they +// can recover via "I've paid" again, and an admin can reactivate or mark it paid +// directly, both re-checking capacity. -import { and, eq, lt, inArray } from 'drizzle-orm'; +import { and, or, 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 './manualProviders.js'; function getThresholdMs(): number { const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); @@ -19,7 +25,8 @@ function getThresholdMs(): number { } /** - * Move stale pending-approval payments (and their tickets) to 'on_hold'. + * Move stale awaiting-verification payments (and their tickets) to 'on_hold'. + * Covers 'pending_approval' payments and 'pending' payments on manual providers. * Returns the number of payments put on hold. */ export async function sweepStaleApprovals(): Promise { @@ -33,7 +40,13 @@ export async function sweepStaleApprovals(): Promise { }) .from(payments) .where(and( - eq((payments as any).status, 'pending_approval'), + or( + eq((payments as any).status, 'pending_approval'), + and( + eq((payments as any).status, 'pending'), + inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]) + ) + ), lt((payments as any).createdAt, cutoff) )) ); @@ -59,7 +72,7 @@ export async function sweepStaleApprovals(): Promise { )); } - console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`); + console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`); return stale.length; } diff --git a/backend/src/lib/manualProviders.ts b/backend/src/lib/manualProviders.ts new file mode 100644 index 0000000..5252755 --- /dev/null +++ b/backend/src/lib/manualProviders.ts @@ -0,0 +1,7 @@ +// Payment providers that require a human to verify the money arrived. +// +// These methods (bank transfer / TPago / cash) are never auto-confirmed and — +// crucially — are never auto-failed by the stale-booking cleanup: an admin settles +// them by hand. Note this is broader than the set of methods that expose an online +// "I've paid" step (bank transfer / TPago only); cash is settled at the door. +export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 46dc1c1..1914b73 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -26,6 +26,10 @@ const rejectPaymentSchema = z.object({ sendEmail: z.boolean().optional().default(true), }); +const reopenPaymentSchema = z.object({ + adminNote: z.string().optional(), +}); + // Get all payments (admin) - with ticket and event details paymentsRouter.get('/', requireAuth(['admin']), async (c) => { const status = c.req.query('status'); @@ -232,10 +236,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json return c.json({ error: 'Payment not found' }, 404); } + // Confirming a failed payment must go through /approve, which re-checks event + // capacity before re-reserving the (previously released) seat. Block the raw path. + if (data.status === 'paid' && existing.status === 'failed') { + return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400); + } + const now = getNow(); - + const updateData: any = { ...data, updatedAt: now }; - + // If marking as paid, record who approved it and when if (data.status === 'paid' && existing.status !== 'paid') { updateData.paidAt = now; @@ -321,8 +331,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida return c.json({ error: 'Payment not found' }, 404); } - // Can approve pending, pending_approval, or on_hold payments - if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) { + // Can approve pending, pending_approval, on_hold, or failed payments. + // 'failed' covers an admin confirming a payment that was auto-failed or rejected + // in error; its tickets are cancelled, so recovery re-checks capacity below. + if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) { return c.json({ error: 'Payment cannot be approved in its current state' }, 400); } @@ -350,16 +362,39 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`); } - if (payment.status === 'on_hold') { - // The seat was released when this booking went on hold - re-check capacity - // before confirming it directly. + // For a failed payment, only recover tickets whose own payment is also failed. + // This protects mixed bookings (e.g. a sibling ticket was refunded) from being + // resurrected or having its payment flipped to paid. + if (payment.status === 'failed') { + const bookingPayments = await dbAll( + (db as any) + .select({ ticketId: (payments as any).ticketId, status: (payments as any).status }) + .from(payments) + .where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id))) + ); + const failedTicketIds = new Set( + bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId) + ); + ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id)); + if (ticketsToConfirm.length === 0) { + return c.json({ error: 'Payment cannot be approved in its current state' }, 400); + } + } + + if (payment.status === 'on_hold' || payment.status === 'failed') { + // The seat was released when this booking went on hold or failed - re-check + // capacity before confirming it directly. try { await reserveOnHoldBooking( ticket.eventId, ticketsToConfirm.map((t: any) => t.id), 'confirmed', 'paid', - { paidByAdminId: user.id } + { + paidByAdminId: user.id, + fromTicketStatuses: + payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'], + } ); } catch (err) { if (err instanceof HoldCapacityError) { @@ -548,6 +583,87 @@ paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), asyn return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' }); }); +// Reopen a failed payment back to pending (admin) - re-reserves the seat. +// For when a payment was failed in error (auto-fail or rejection) and should +// return to the normal pending flow (reminders, "I've paid", approve/reject). +paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => { + const id = c.req.param('id'); + const { adminNote } = c.req.valid('json'); + + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).id, id)) + ); + + if (!payment) { + return c.json({ error: 'Payment not found' }, 404); + } + + if (payment.status !== 'failed') { + return c.json({ error: 'Only failed payments can be reopened' }, 400); + } + + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId)) + ); + if (!ticket) { + return c.json({ error: 'Ticket not found' }, 404); + } + + let ticketsToReopen: any[] = [ticket]; + if (ticket.bookingId) { + ticketsToReopen = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + + // Only reopen tickets whose own payment is also failed (protects mixed bookings). + const bookingPayments = await dbAll( + (db as any) + .select({ ticketId: (payments as any).ticketId, status: (payments as any).status }) + .from(payments) + .where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id))) + ); + const failedTicketIds = new Set( + bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId) + ); + ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id)); + if (ticketsToReopen.length === 0) { + return c.json({ error: 'Only failed payments can be reopened' }, 400); + } + + const reopenIds = ticketsToReopen.map((t: any) => t.id); + + try { + await reserveOnHoldBooking( + ticket.eventId, + reopenIds, + 'pending', + 'pending', + { fromTicketStatuses: ['cancelled', 'on_hold'] } + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'This event is now full. Your spot was released after the payment deadline passed.', + }, 400); + } + throw err; + } + + if (adminNote) { + await (db as any) + .update(payments) + .set({ adminNote }) + .where(inArray((payments as any).ticketId, reopenIds)); + } + + const updated = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).id, id)) + ); + + return c.json({ payment: updated, message: 'Payment reopened and set to pending' }); +}); + // Send payment reminder email paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 905823c..40a08fb 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -162,6 +162,22 @@ export default function AdminPaymentsPage() { } }; + const handleReopen = async (payment: PaymentWithDetails) => { + setProcessing(true); + try { + await paymentsApi.reopen(payment.id, noteText); + toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending'); + setSelectedPayment(null); + setNoteText(''); + setSendEmail(true); + loadData(); + } catch (error: any) { + toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment')); + } finally { + setProcessing(false); + } + }; + const handleRefund = async (id: string) => { if (!confirm('Are you sure you want to process this refund?')) return; @@ -503,19 +519,32 @@ export default function AdminPaymentsPage() { )} -
- - -
+ {selectedPayment.status === 'failed' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} - {selectedPayment.status !== 'on_hold' && ( + {!['on_hold', 'failed'].includes(selectedPayment.status) && (
@@ -1042,7 +1071,7 @@ export default function AdminPaymentsPage() {

{formatDate(payment.createdAt)}

- {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && ( + {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold' || payment.status === 'failed') && ( diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index 2a86e6a..b057238 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -51,4 +51,10 @@ export const paymentsApi = { fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, { method: 'POST', }), + + reopen: (id: string, adminNote?: string) => + fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reopen`, { + method: 'POST', + body: JSON.stringify({ adminNote }), + }), };