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
@@ -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