Replace manualProviders.ts with a paymentProviders.ts registry (automatic vs manual settlement) and move all seat counting into capacity.ts as the single source of truth: only paid/checked-in tickets and pending_approval payments hold a seat, so abandoned checkouts never block sales. Admins can now knowingly approve a payment over capacity (allowOverCapacity), with the booking and admin UIs updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71 lines
3.5 KiB
TypeScript
71 lines
3.5 KiB
TypeScript
// 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<number>`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<number>`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<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
|
|
claimedCount: sql<number>`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);
|
|
}
|