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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user