first commit
This commit is contained in:
431
backend/src/routes/payments.ts
Normal file
431
backend/src/routes/payments.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, 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(),
|
||||
});
|
||||
|
||||
const rejectPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
// 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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.orderBy(desc((payments as any).createdAt))
|
||||
.all();
|
||||
|
||||
// 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 (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
let event = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
}
|
||||
|
||||
return {
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
.orderBy(desc((payments as any).userMarkedPaidAt))
|
||||
.all();
|
||||
|
||||
// Enrich with ticket and event data
|
||||
const enrichedPayments = await Promise.all(
|
||||
pendingPayments.map(async (payment: any) => {
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
let event = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
}
|
||||
|
||||
return {
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Get associated ticket
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).id, id));
|
||||
|
||||
// If payment confirmed, update ticket status and send emails
|
||||
if (data.status === 'paid') {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, existing.ticketId));
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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 } = c.req.valid('json');
|
||||
const user = (c as any).get('user');
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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();
|
||||
|
||||
// Update payment status to paid
|
||||
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).id, id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
Promise.all([
|
||||
emailService.sendBookingConfirmation(payment.ticketId),
|
||||
emailService.sendPaymentReceipt(id),
|
||||
]).catch(err => {
|
||||
console.error('[Email] Failed to send confirmation emails:', err);
|
||||
});
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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 } = c.req.valid('json');
|
||||
const user = (c as any).get('user');
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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));
|
||||
|
||||
// Note: We don't cancel the ticket automatically - admin can do that separately if needed
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
return c.json({ payment: updated, message: 'Payment rejected' });
|
||||
});
|
||||
|
||||
// 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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
|
||||
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 (db as any).select().from(payments).all();
|
||||
|
||||
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 + (p.amount || 0), 0),
|
||||
};
|
||||
|
||||
return c.json({ stats });
|
||||
});
|
||||
|
||||
export default paymentsRouter;
|
||||
Reference in New Issue
Block a user