- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
790 lines
25 KiB
TypeScript
790 lines
25 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, inArray } from 'drizzle-orm';
|
|
import { requireAuth } from '../lib/auth.js';
|
|
import { getNow } from '../lib/utils.js';
|
|
import emailService from '../lib/email.js';
|
|
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
|
|
|
const paymentsRouter = new Hono();
|
|
|
|
const updatePaymentSchema = z.object({
|
|
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
|
reference: z.string().optional(),
|
|
adminNote: z.string().optional(),
|
|
});
|
|
|
|
const approvePaymentSchema = z.object({
|
|
adminNote: z.string().optional(),
|
|
sendEmail: z.boolean().optional().default(true),
|
|
// Admin override: confirm the booking even when it puts the event over
|
|
// capacity. The UI asks for explicit confirmation before sending this.
|
|
allowOverCapacity: z.boolean().optional().default(false),
|
|
});
|
|
|
|
const rejectPaymentSchema = z.object({
|
|
adminNote: z.string().optional(),
|
|
sendEmail: z.boolean().optional().default(true),
|
|
});
|
|
|
|
const reopenPaymentSchema = 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');
|
|
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,
|
|
attendeeRuc: ticket.attendeeRuc,
|
|
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, onHoldRow, 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({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
|
|
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),
|
|
onHold: Number(onHoldRow?.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);
|
|
}
|
|
|
|
// Confirming a failed payment must go through /approve, which re-checks event
|
|
// capacity before re-reserving the (previously released) seat. Block the raw path.
|
|
if (data.status === 'paid' && existing.status === 'failed') {
|
|
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
|
|
}
|
|
|
|
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', paymentStatus: 'paid' })
|
|
.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, allowOverCapacity } = 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, pending_approval, on_hold, or failed payments.
|
|
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
|
// in error; its tickets are cancelled, so recovery re-checks capacity below.
|
|
// Bare 'pending' covers customers who paid but never clicked "I've paid".
|
|
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
|
}
|
|
|
|
// 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`);
|
|
}
|
|
|
|
// For a failed payment, only recover tickets whose own payment is also failed.
|
|
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
|
// resurrected or having its payment flipped to paid.
|
|
if (payment.status === 'failed') {
|
|
const bookingPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
|
.from(payments)
|
|
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
|
);
|
|
const failedTicketIds = new Set(
|
|
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
|
);
|
|
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
|
if (ticketsToConfirm.length === 0) {
|
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
|
}
|
|
}
|
|
|
|
// Confirm the booking through the shared capacity-checked reservation.
|
|
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
|
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
|
// When the event is full, the admin gets a structured over-capacity error and
|
|
// may retry with allowOverCapacity to knowingly overbook.
|
|
try {
|
|
await reserveOnHoldBooking(
|
|
ticket.eventId,
|
|
ticketsToConfirm.map((t: any) => t.id),
|
|
'confirmed',
|
|
'paid',
|
|
{
|
|
paidByAdminId: user.id,
|
|
fromTicketStatuses:
|
|
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
|
skipCapacityCheck: allowOverCapacity,
|
|
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
|
}
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof HoldCapacityError) {
|
|
return c.json({
|
|
error: 'Approving this payment puts the event over capacity.',
|
|
code: 'EVENT_OVER_CAPACITY',
|
|
availableSeats: err.available,
|
|
requestedSeats: ticketsToConfirm.length,
|
|
}, 409);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// 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', 'on_hold'].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' });
|
|
});
|
|
|
|
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
|
paymentsRouter.post('/:id/reactivate', 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);
|
|
}
|
|
|
|
if (payment.status !== 'on_hold') {
|
|
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
|
|
}
|
|
|
|
const ticket = await dbGet<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
let ticketsToReactivate: any[] = [ticket];
|
|
if (ticket.bookingId) {
|
|
ticketsToReactivate = await dbAll<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
}
|
|
|
|
try {
|
|
await reserveOnHoldBooking(
|
|
ticket.eventId,
|
|
ticketsToReactivate.map((t: any) => t.id),
|
|
'pending',
|
|
'pending_approval'
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof HoldCapacityError) {
|
|
return c.json({
|
|
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
|
}, 400);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const updated = await dbGet(
|
|
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
|
});
|
|
|
|
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
|
|
// For when a payment was failed in error (auto-fail or rejection) and should
|
|
// return to the normal pending flow (reminders, "I've paid", approve/reject).
|
|
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const { adminNote } = c.req.valid('json');
|
|
|
|
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 !== 'failed') {
|
|
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
|
}
|
|
|
|
const ticket = await dbGet<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
|
);
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
let ticketsToReopen: any[] = [ticket];
|
|
if (ticket.bookingId) {
|
|
ticketsToReopen = await dbAll<any>(
|
|
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
);
|
|
}
|
|
|
|
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
|
|
const bookingPayments = await dbAll<any>(
|
|
(db as any)
|
|
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
|
.from(payments)
|
|
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
|
|
);
|
|
const failedTicketIds = new Set(
|
|
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
|
);
|
|
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
|
|
if (ticketsToReopen.length === 0) {
|
|
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
|
}
|
|
|
|
const reopenIds = ticketsToReopen.map((t: any) => t.id);
|
|
|
|
try {
|
|
await reserveOnHoldBooking(
|
|
ticket.eventId,
|
|
reopenIds,
|
|
'pending',
|
|
'pending',
|
|
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof HoldCapacityError) {
|
|
return c.json({
|
|
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
|
}, 400);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
if (adminNote) {
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({ adminNote })
|
|
.where(inArray((payments as any).ticketId, reopenIds));
|
|
}
|
|
|
|
const updated = await dbGet(
|
|
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
);
|
|
|
|
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
|
|
});
|
|
|
|
// 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;
|