Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.

Exclude manual providers from booking cleanup, hold them via the 72h sweep instead, and let admins reopen failed payments to pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-20 03:53:51 +00:00
co-authored by Cursor
parent 0d47156071
commit 4772b85f3d
7 changed files with 267 additions and 53 deletions
+124 -8
View File
@@ -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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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');