Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
+115
-31
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const paymentsRouter = new Hono();
|
||||
|
||||
const updatePaymentSchema = z.object({
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
||||
reference: z.string().optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
@@ -85,6 +86,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
attendeeRuc: ticket.attendeeRuc,
|
||||
status: ticket.status,
|
||||
} : null,
|
||||
event: event ? {
|
||||
@@ -95,7 +97,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter by event(s)
|
||||
if (eventId) {
|
||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||
@@ -164,12 +166,13 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
|
||||
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
|
||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
|
||||
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
]);
|
||||
|
||||
@@ -180,6 +183,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
paid: Number(paidRow?.count || 0),
|
||||
refunded: Number(refundedRow?.count || 0),
|
||||
failed: Number(failedRow?.count || 0),
|
||||
onHold: Number(onHoldRow?.count || 0),
|
||||
totalRevenue: Number(revenueRow?.total || 0),
|
||||
},
|
||||
});
|
||||
@@ -317,13 +321,13 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending or pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
// Can approve pending, pending_approval, or on_hold payments
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -331,10 +335,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
@@ -345,27 +349,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
|
||||
if (payment.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} 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, ticketsToConfirm.map((t: any) => t.id)));
|
||||
}
|
||||
} else {
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
@@ -405,7 +436,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||
}
|
||||
|
||||
@@ -464,6 +495,59 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||
});
|
||||
|
||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
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 !== 'on_hold') {
|
||||
return c.json({ error: 'Only on-hold payments can be reactivated' }, 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 ticketsToReactivate: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReactivate = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToReactivate.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval'
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
||||
});
|
||||
|
||||
// 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