// Single source of truth for event seat accounting. // // A seat is held by a booking the moment the money is real or claimed to be: // - ticket 'confirmed' or 'checked_in' (paid), or // - ticket 'pending' whose payment is 'pending_approval' (customer clicked // "I've paid" and is waiting for admin verification). // // A bare 'pending' payment — an opened checkout that was never paid nor claimed, // on any provider — holds NO seat, so abandoned bookings can never block sales. // The accepted trade-off is a small oversell window when several people book the // last seats and all later pay/claim; admin approval is the backstop and may // knowingly approve over capacity (see routes/payments.ts). // // 'pending_approval' is a payment status (the ticket row stays 'pending'), so // every capacity count joins tickets to payments. There is exactly one payment // row per ticket (created together in routes/tickets.ts). // // Every place that counts seats — booking creation, public availability, // hold recovery, admin dashboards — must go through these builders so the // formula cannot diverge between surfaces. import { sql, and, eq, inArray } from 'drizzle-orm'; import { tickets, payments } from '../db/index.js'; // COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no // payment row must count as "not holding" — NULL would poison NOT ...). export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`; /** * Query: number of seats currently held for an event. * `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`; * async pg tx / plain db: await via dbGet). */ export function seatHolderCountQuery(executor: any, eventId: string) { return executor .select({ count: sql`count(distinct ${(tickets as any).id})` }) .from(tickets) .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) .where(and(eq((tickets as any).eventId, eventId), seatHoldingSql)); } /** * Query: how many of the given tickets do NOT currently hold a seat (and so * would need fresh capacity if promoted to a seat-holding state). */ export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) { return executor .select({ count: sql`count(distinct ${(tickets as any).id})` }) .from(tickets) .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) .where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`)); } /** * Query: per-event breakdown of paid vs claimed seats, grouped by event. * paidCount = confirmed + checked_in; claimedCount = pending_approval-held. * Pass `eventId` to restrict to one event (still returns a grouped row). */ export function eventSeatBreakdownQuery(executor: any, eventId?: string) { const query = executor .select({ eventId: (tickets as any).eventId, paidCount: sql`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`, claimedCount: sql`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`, }) .from(tickets) .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)); return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query) .groupBy((tickets as any).eventId); }