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