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:
Michilis
2026-07-26 04:58:40 +00:00
co-authored by Claude Fable 5
parent c9a600b6d6
commit 71c277045b
20 changed files with 570 additions and 290 deletions
+7 -34
View File
@@ -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');