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( (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( (db as any) .select() .from(tickets) .where(eq((tickets as any).id, payment.ticketId)) ); let event: any = null; if (ticket) { event = await dbGet( (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( (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( (db as any) .select() .from(tickets) .where(eq((tickets as any).id, payment.ticketId)) ); let event: any = null; if (ticket) { event = await dbGet( (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 by ID (admin) paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); const payment = await dbGet( (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( (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( (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( (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( (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( (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(); // 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)); // 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( (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( (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( (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(); // 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)); return c.json({ message: 'Refund processed successfully' }); }); // Payment webhook (for Stripe/MercadoPago) 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((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 }); }); export default paymentsRouter;