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>
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
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 { requireAuth } from '../lib/auth.js';
|
|
import { getNow } from '../lib/utils.js';
|
|
import emailService from '../lib/email.js';
|
|
|
|
const paymentsRouter = new Hono();
|
|
|
|
const updatePaymentSchema = z.object({
|
|
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
|
reference: z.string().optional(),
|
|
adminNote: z.string().optional(),
|
|
});
|
|
|
|
const approvePaymentSchema = z.object({
|
|
adminNote: z.string().optional(),
|
|
sendEmail: z.boolean().optional().default(true),
|
|
});
|
|
|
|
const rejectPaymentSchema = z.object({
|
|
adminNote: z.string().optional(),
|
|
sendEmail: z.boolean().optional().default(true),
|
|
});
|
|
|
|
// Get all payments (admin) - with ticket and event details
|
|
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|
const status = c.req.query('status');
|
|
const provider = c.req.query('provider');
|
|
const pendingApproval = c.req.query('pendingApproval');
|
|
const eventId = c.req.query('eventId');
|
|
const eventIds = c.req.query('eventIds');
|
|
|
|
// Get all payments with their associated tickets
|
|
let allPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.orderBy(desc((payments as any).createdAt))
|
|
);
|
|
|
|
// Filter by status
|
|
if (status) {
|
|
allPayments = allPayments.filter((p: any) => p.status === status);
|
|
}
|
|
|
|
// Filter for pending approval specifically
|
|
if (pendingApproval === 'true') {
|
|
allPayments = allPayments.filter((p: any) => p.status === 'pending_approval');
|
|
}
|
|
|
|
// Filter by provider
|
|
if (provider) {
|
|
allPayments = allPayments.filter((p: any) => p.provider === provider);
|
|
}
|
|
|
|
// Enrich with ticket and event data
|
|
let enrichedPayments = await Promise.all(
|
|
allPayments.map(async (payment: any) => {
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
let event: any = null;
|
|
if (ticket) {
|
|
event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
}
|
|
|
|
return {
|
|
...payment,
|
|
ticket: ticket ? {
|
|
id: ticket.id,
|
|
bookingId: ticket.bookingId,
|
|
attendeeFirstName: ticket.attendeeFirstName,
|
|
attendeeLastName: ticket.attendeeLastName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
attendeePhone: ticket.attendeePhone,
|
|
status: ticket.status,
|
|
} : null,
|
|
event: event ? {
|
|
id: event.id,
|
|
title: event.title,
|
|
startDatetime: event.startDatetime,
|
|
} : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
// Filter by event(s)
|
|
if (eventId) {
|
|
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
|
} else if (eventIds) {
|
|
const ids = eventIds.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
if (ids.length > 0) {
|
|
enrichedPayments = enrichedPayments.filter((p: any) => p.event && ids.includes(p.event.id));
|
|
}
|
|
}
|
|
|
|
return c.json({ payments: enrichedPayments });
|
|
});
|
|
|
|
// Get payments pending approval (admin dashboard view)
|
|
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const pendingPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).status, 'pending_approval'))
|
|
.orderBy(desc((payments as any).userMarkedPaidAt))
|
|
);
|
|
|
|
// Enrich with ticket and event data
|
|
const enrichedPayments = await Promise.all(
|
|
pendingPayments.map(async (payment: any) => {
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
let event: any = null;
|
|
if (ticket) {
|
|
event = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(events)
|
|
.where(eq((events as any).id, ticket.eventId))
|
|
);
|
|
}
|
|
|
|
return {
|
|
...payment,
|
|
ticket: ticket ? {
|
|
id: ticket.id,
|
|
bookingId: ticket.bookingId,
|
|
attendeeFirstName: ticket.attendeeFirstName,
|
|
attendeeLastName: ticket.attendeeLastName,
|
|
attendeeEmail: ticket.attendeeEmail,
|
|
attendeePhone: ticket.attendeePhone,
|
|
status: ticket.status,
|
|
} : null,
|
|
event: event ? {
|
|
id: event.id,
|
|
title: event.title,
|
|
startDatetime: event.startDatetime,
|
|
} : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
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');
|
|
|
|
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);
|
|
}
|
|
|
|
// Get associated ticket
|
|
const ticket = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
|
|
return c.json({ payment: { ...payment, ticket } });
|
|
});
|
|
|
|
// Update payment (admin) - for manual payment confirmation
|
|
paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updatePaymentSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const data = c.req.valid('json');
|
|
const user = (c as any).get('user');
|
|
|
|
const existing = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, id))
|
|
);
|
|
|
|
if (!existing) {
|
|
return c.json({ error: 'Payment not found' }, 404);
|
|
}
|
|
|
|
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;
|
|
updateData.paidByAdminId = user.id;
|
|
}
|
|
|
|
// If payment confirmed, handle multi-ticket booking
|
|
if (data.status === 'paid') {
|
|
// Get the ticket associated with this payment
|
|
const ticket = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).id, existing.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(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
|
}
|
|
|
|
// Update all payments and tickets in the booking
|
|
for (const t of ticketsToConfirm) {
|
|
await (db as any)
|
|
.update(payments)
|
|
.set(updateData)
|
|
.where(eq((payments as any).ticketId, (t as any).id));
|
|
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ status: 'confirmed' })
|
|
.where(eq((tickets as any).id, (t as any).id));
|
|
}
|
|
|
|
// Send confirmation emails asynchronously (don't block the response)
|
|
Promise.all([
|
|
emailService.sendBookingConfirmation(existing.ticketId),
|
|
emailService.sendPaymentReceipt(id),
|
|
]).catch(err => {
|
|
console.error('[Email] Failed to send confirmation emails:', err);
|
|
});
|
|
} else {
|
|
// For non-paid status updates, just update this payment
|
|
await (db as any)
|
|
.update(payments)
|
|
.set(updateData)
|
|
.where(eq((payments as any).id, id));
|
|
}
|
|
|
|
const updated = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated });
|
|
});
|
|
|
|
// Approve payment (admin) - specifically for pending_approval payments
|
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const { adminNote, sendEmail } = c.req.valid('json');
|
|
const user = (c as any).get('user');
|
|
|
|
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);
|
|
}
|
|
|
|
// Can approve pending or pending_approval payments
|
|
if (!['pending', 'pending_approval'].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)
|
|
.select()
|
|
.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(
|
|
(db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
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));
|
|
}
|
|
|
|
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
|
if (sendEmail !== false) {
|
|
Promise.all([
|
|
emailService.sendBookingConfirmation(payment.ticketId),
|
|
emailService.sendPaymentReceipt(id),
|
|
]).catch(err => {
|
|
console.error('[Email] Failed to send confirmation emails:', err);
|
|
});
|
|
} else {
|
|
console.log('[Payment] Skipping confirmation emails per admin request');
|
|
}
|
|
|
|
const updated = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated, message: 'Payment approved successfully' });
|
|
});
|
|
|
|
// Reject payment (admin)
|
|
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const { adminNote, sendEmail } = c.req.valid('json');
|
|
const user = (c as any).get('user');
|
|
|
|
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 (!['pending', 'pending_approval'].includes(payment.status)) {
|
|
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
// 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)) {
|
|
emailService.sendPaymentRejectionEmail(id).catch(err => {
|
|
console.error('[Email] Failed to send payment rejection email:', err);
|
|
});
|
|
} else if (sendEmail === false) {
|
|
console.log('[Payment] Skipping rejection email per admin request');
|
|
}
|
|
|
|
const updated = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
|
});
|
|
|
|
// Send payment reminder email
|
|
paymentsRouter.post('/:id/send-reminder', 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);
|
|
}
|
|
|
|
// Only allow sending reminders for pending payments
|
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
|
return c.json({ error: 'Payment reminder can only be sent for pending payments' }, 400);
|
|
}
|
|
|
|
try {
|
|
const result = await emailService.sendPaymentReminder(id);
|
|
|
|
if (result.success) {
|
|
const now = getNow();
|
|
|
|
// Record when reminder was sent
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({
|
|
reminderSentAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((payments as any).id, id));
|
|
|
|
return c.json({ message: 'Payment reminder sent successfully', reminderSentAt: now });
|
|
} else {
|
|
return c.json({ error: result.error || 'Failed to send payment reminder' }, 500);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('[Payment] Failed to send payment reminder:', err);
|
|
return c.json({ error: 'Failed to send payment reminder' }, 500);
|
|
}
|
|
});
|
|
|
|
// Update admin note
|
|
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const id = c.req.param('id');
|
|
const body = await c.req.json();
|
|
const { adminNote } = body;
|
|
|
|
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);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({
|
|
adminNote: adminNote || null,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((payments as any).id, id));
|
|
|
|
const updated = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated, message: 'Note updated' });
|
|
});
|
|
|
|
// Process refund (admin)
|
|
paymentsRouter.post('/:id/refund', requireAuth(['admin']), 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 !== 'paid') {
|
|
return c.json({ error: 'Can only refund paid payments' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
// 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) => {
|
|
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;
|