- Add option to approve/reject payments without sending notification emails (checkbox in review popup, default enabled) - Add payment reminder email template and send functionality - Track when reminder emails are sent (reminderSentAt field) - Display reminder sent timestamp in payment review popup - Make payment review popup scrollable for better UX - Add payment-reminder template to email system (available in admin emails)
573 lines
16 KiB
TypeScript
573 lines
16 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');
|
|
|
|
// 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
|
|
const 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,
|
|
};
|
|
})
|
|
);
|
|
|
|
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 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();
|
|
|
|
// 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<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();
|
|
|
|
// 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<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 });
|
|
});
|
|
|
|
export default paymentsRouter;
|