Harden auth, payments, and frontend against review findings.

Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-24 19:59:02 +00:00
co-authored by Cursor
parent fc4af38e8a
commit a6840ea953
37 changed files with 1432 additions and 528 deletions
+87 -64
View File
@@ -162,6 +162,29 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
return c.json({ payments: enrichedPayments });
});
// 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([
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({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
]);
return c.json({
stats: {
total: Number(totalRow?.count || 0),
pending: Number(pendingRow?.count || 0),
paid: Number(paidRow?.count || 0),
refunded: Number(refundedRow?.count || 0),
failed: Number(failedRow?.count || 0),
totalRevenue: Number(revenueRow?.total || 0),
},
});
});
// Get payment by ID (admin)
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
@@ -387,26 +410,40 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
}
const now = getNow();
// Update payment status to failed
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, payment.ticketId));
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
const rejectTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
if (rejectTicket?.bookingId) {
ticketsToReject = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
);
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
}
for (const t of ticketsToReject) {
// Fail the payment for each ticket in the booking
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).ticketId, (t as any).id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, (t as any).id));
}
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
@@ -529,56 +566,42 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
}
const now = getNow();
// Update payment status
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(eq((payments as any).id, id));
// Cancel associated ticket
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, payment.ticketId));
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
const refundTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
if (refundTicket?.bookingId) {
ticketsToRefund = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
);
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
}
for (const t of ticketsToRefund) {
// Only refund payments that were actually paid; leave others untouched
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid')));
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, (t as any).id));
}
return c.json({ message: 'Refund processed successfully' });
});
// Payment webhook (for Stripe/MercadoPago)
// Not implemented: there is deliberately NO status mutation here. Until provider
// signature verification is implemented, accepting webhooks would let anyone forge
// a "paid" status. Returns 501 and never updates payments/tickets.
paymentsRouter.post('/webhook', async (c) => {
// This would handle webhook notifications from payment providers
// Implementation depends on which provider is used
const body = await c.req.json();
// Log webhook for debugging
console.log('Payment webhook received:', body);
// TODO: Implement provider-specific webhook handling
// - Verify webhook signature
// - Update payment status
// - Update ticket status
return c.json({ received: true });
});
// Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await dbAll<any>((db as any).select().from(payments));
const stats = {
total: allPayments.length,
pending: allPayments.filter((p: any) => p.status === 'pending').length,
paid: allPayments.filter((p: any) => p.status === 'paid').length,
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments
.filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
};
return c.json({ stats });
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
return c.json({ error: 'Webhook handling is not implemented' }, 501);
});
export default paymentsRouter;