Centralize seat capacity accounting and the payment provider registry.
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c9a600b6d6
commit
71c277045b
@@ -18,7 +18,7 @@ import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||
|
||||
function getTtlMs(): number {
|
||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
// Shared capacity-checked recovery for released bookings.
|
||||
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
||||
//
|
||||
// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
|
||||
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
|
||||
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
|
||||
// it paid / reopening a failed payment) must atomically re-check that the event still has
|
||||
// room before re-reserving the seat, exactly like the original booking-creation flow in
|
||||
// routes/tickets.ts.
|
||||
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||
// must atomically re-check that the event still has room, exactly like the original
|
||||
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
||||
// (e.g. reopening a failed payment) claims no seat and skips the check.
|
||||
//
|
||||
// Callers choose which ticket statuses are eligible to be re-reserved via
|
||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
|
||||
// tickets that already hold a seat — are left untouched and don't consume capacity.
|
||||
// Callers choose which ticket statuses are eligible to be flipped via
|
||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
||||
// left untouched. Tickets that already hold a seat cost no new capacity.
|
||||
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
|
||||
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
|
||||
|
||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||
|
||||
export class HoldCapacityError extends Error {
|
||||
constructor(public available: number) {
|
||||
@@ -26,6 +29,8 @@ interface ReserveOptions {
|
||||
extraPaymentFields?: Record<string, any>;
|
||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
||||
/** Admin override: reserve even when it puts the event over capacity. */
|
||||
skipCapacityCheck?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +39,8 @@ interface ReserveOptions {
|
||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||
* Capacity is asserted against the number of those tickets that don't already
|
||||
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
||||
* Throws HoldCapacityError if the event no longer has room.
|
||||
* Throws HoldCapacityError if the event no longer has room (unless the target
|
||||
* state holds no seat, or skipCapacityCheck is set).
|
||||
*/
|
||||
export async function reserveOnHoldBooking(
|
||||
eventId: string,
|
||||
@@ -46,6 +52,10 @@ export async function reserveOnHoldBooking(
|
||||
if (ticketIds.length === 0) return;
|
||||
|
||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
||||
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
|
||||
// consumes no capacity and needs no check.
|
||||
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
|
||||
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
@@ -66,7 +76,7 @@ export async function reserveOnHoldBooking(
|
||||
}
|
||||
|
||||
// `needed` is how many of these tickets don't currently hold a seat and so must
|
||||
// be found new capacity; tickets already in a seat-holding status cost nothing.
|
||||
// be found new capacity; tickets already in a seat-holding state cost nothing.
|
||||
const assertCapacity = (reserved: number, needed: number) => {
|
||||
if (needed <= 0) return;
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
@@ -80,23 +90,11 @@ export async function reserveOnHoldBooking(
|
||||
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
const countRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
.get();
|
||||
const neededRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
.get();
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
if (checkCapacity) {
|
||||
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
tx.update(tickets)
|
||||
.set({ status: targetTicketStatus })
|
||||
@@ -113,25 +111,11 @@ export async function reserveOnHoldBooking(
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
const countRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
);
|
||||
const neededRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
);
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
if (checkCapacity) {
|
||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
await tx.update(tickets)
|
||||
.set({ status: targetTicketStatus })
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// Auto-hold stale manual-payment bookings.
|
||||
// Auto-hold stale unsettled manual-payment bookings.
|
||||
//
|
||||
// This job releases the seat held by an abandoned manual-payment booking (bank
|
||||
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
|
||||
// which keep a seat reserved while awaiting a human:
|
||||
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
|
||||
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
|
||||
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
|
||||
// so this is their only seat-release path).
|
||||
// In either case the payment (and its ticket) is silently moved to 'on_hold', which
|
||||
// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in')
|
||||
// and so releases the seat back to the event. The user receives no notification — they
|
||||
// can recover via "I've paid" again, and an admin can reactivate or mark it paid
|
||||
// directly, both re-checking capacity.
|
||||
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||
// dead checkouts out of the admin's pending queues.
|
||||
//
|
||||
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
||||
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
||||
// UI surfaces aging claims instead of silently releasing them.
|
||||
//
|
||||
// The user receives no notification — they can recover via "I've paid", and an
|
||||
// admin can approve/reactivate directly; every recovery path re-checks capacity.
|
||||
|
||||
import { and, or, eq, lt, inArray } from 'drizzle-orm';
|
||||
import { and, eq, lt, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||
|
||||
function getThresholdMs(): number {
|
||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
||||
@@ -25,9 +26,9 @@ function getThresholdMs(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move stale awaiting-verification payments (and their tickets) to 'on_hold'.
|
||||
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
|
||||
* Returns the number of payments put on hold.
|
||||
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
||||
* is never swept. Returns the number of payments put on hold.
|
||||
*/
|
||||
export async function sweepStaleApprovals(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||
@@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
|
||||
})
|
||||
.from(payments)
|
||||
.where(and(
|
||||
or(
|
||||
eq((payments as any).status, 'pending_approval'),
|
||||
and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
|
||||
)
|
||||
),
|
||||
eq((payments as any).status, 'pending'),
|
||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).createdAt, cutoff)
|
||||
))
|
||||
);
|
||||
@@ -72,7 +68,7 @@ export async function sweepStaleApprovals(): Promise<number> {
|
||||
));
|
||||
}
|
||||
|
||||
console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`);
|
||||
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Payment providers that require a human to verify the money arrived.
|
||||
//
|
||||
// These methods (bank transfer / TPago / cash) are never auto-confirmed and —
|
||||
// crucially — are never auto-failed by the stale-booking cleanup: an admin settles
|
||||
// them by hand. Note this is broader than the set of methods that expose an online
|
||||
// "I've paid" step (bank transfer / TPago only); cash is settled at the door.
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Payment provider registry.
|
||||
//
|
||||
// Every provider is either:
|
||||
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
|
||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||
// card handled offline, cash at the door). These are never auto-confirmed
|
||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||
// TPago additionally expose an online "I've paid" step that moves the
|
||||
// payment to 'pending_approval'.
|
||||
//
|
||||
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
|
||||
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
|
||||
// kind — holds no seat, so an abandoned checkout can never block sales.
|
||||
|
||||
export type PaymentProviderKind = 'automatic' | 'manual';
|
||||
|
||||
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
|
||||
lightning: { kind: 'automatic' },
|
||||
tpago: { kind: 'manual' },
|
||||
bank_transfer: { kind: 'manual' },
|
||||
card: { kind: 'manual' },
|
||||
cash: { kind: 'manual' },
|
||||
};
|
||||
|
||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
||||
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
|
||||
);
|
||||
|
||||
export function isManualProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
|
||||
}
|
||||
|
||||
export function isAutomaticProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
|
||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
const adminRouter = new Hono();
|
||||
|
||||
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
|
||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const now = getNow();
|
||||
|
||||
// Get upcoming events
|
||||
const upcomingEvents = await dbAll(
|
||||
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||
// so the dashboard's capacity alerts reflect real availability.
|
||||
const upcomingEventsRaw = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
@@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(5)
|
||||
);
|
||||
|
||||
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of seatRows) {
|
||||
seatsByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
|
||||
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...event,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
|
||||
};
|
||||
});
|
||||
|
||||
// Get recent tickets
|
||||
const recentTickets = await dbAll(
|
||||
@@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
);
|
||||
|
||||
// 'pending' = checkout opened, nothing paid or claimed (informational);
|
||||
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
|
||||
const pendingPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
const awaitingApprovalPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
);
|
||||
|
||||
const onHoldPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
totalTickets: totalTickets?.count || 0,
|
||||
confirmedTickets: confirmedTickets?.count || 0,
|
||||
pendingPayments: pendingPayments?.count || 0,
|
||||
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||
onHoldPayments: onHoldPayments?.count || 0,
|
||||
totalRevenue,
|
||||
newContacts: newContacts?.count || 0,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
|
||||
|
||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
||||
|
||||
// Single grouped query for booked counts across all events (avoids N+1: previously
|
||||
// this ran one COUNT query per event).
|
||||
const countRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
const countByEvent = new Map<string, number>();
|
||||
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||
// capacity check enforces (lib/capacity.ts).
|
||||
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of countRows) {
|
||||
countByEvent.set(row.eventId, Number(row.count) || 0);
|
||||
countByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = countByEvent.get(event.id) || 0;
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise<string> {
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
// Helper function to get ticket count for an event
|
||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
return ticketCount?.count || 0;
|
||||
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||
return {
|
||||
paid: Number(row?.paidCount) || 0,
|
||||
claimed: Number(row?.claimedCount) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookedCount = await getEventTicketCount(event.id);
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
isFeatured: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({
|
||||
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({
|
||||
@@ -317,29 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
// 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 { 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);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -381,55 +383,35 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
}
|
||||
}
|
||||
|
||||
if (payment.status === 'on_hold' || payment.status === 'failed') {
|
||||
// The seat was released when this booking went on hold or failed - re-check
|
||||
// capacity before confirming it directly.
|
||||
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'],
|
||||
}
|
||||
);
|
||||
} 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);
|
||||
// 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 } } : {}),
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (adminNote) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ adminNote })
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
||||
}
|
||||
} else {
|
||||
// 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));
|
||||
);
|
||||
} 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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -128,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||
}
|
||||
|
||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
||||
// Check capacity against held seats (paid/checked-in tickets plus claimed
|
||||
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
|
||||
const existingTicketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
seatHolderCountQuery(db, data.eventId)
|
||||
);
|
||||
|
||||
|
||||
const confirmedCount = existingTicketCount?.count || 0;
|
||||
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
||||
|
||||
@@ -230,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
try {
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
const countRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
const countRow = seatHolderCountQuery(tx, data.eventId).get();
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
@@ -290,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
const countRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
|
||||
Reference in New Issue
Block a user