From 71c277045b92d07e14fa1a21aa8e8ff5bfd8049f Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 04:58:40 +0000 Subject: [PATCH] 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 --- backend/src/lib/bookingCleanup.ts | 2 +- backend/src/lib/capacity.ts | 70 +++++++ backend/src/lib/holdRecovery.ts | 82 +++----- backend/src/lib/holdSweep.ts | 48 ++--- backend/src/lib/manualProviders.ts | 7 - backend/src/lib/paymentProviders.ts | 37 ++++ backend/src/routes/admin.ts | 34 +++- backend/src/routes/events.ts | 87 ++++---- backend/src/routes/payments.ts | 88 ++++---- backend/src/routes/tickets.ts | 41 +--- .../src/app/(public)/book/[eventId]/page.tsx | 32 ++- .../events/[id]/EventDetailClient.tsx | 9 +- .../src/app/(public)/events/[id]/page.tsx | 5 +- frontend/src/app/admin/events/page.tsx | 16 +- frontend/src/app/admin/page.tsx | 53 +++-- frontend/src/app/admin/payments/page.tsx | 192 +++++++++++++++--- frontend/src/lib/api/client.ts | 7 +- frontend/src/lib/api/payments.ts | 12 +- frontend/src/lib/api/types.ts | 8 +- frontend/src/lib/utils.ts | 30 +++ 20 files changed, 570 insertions(+), 290 deletions(-) create mode 100644 backend/src/lib/capacity.ts delete mode 100644 backend/src/lib/manualProviders.ts create mode 100644 backend/src/lib/paymentProviders.ts diff --git a/backend/src/lib/bookingCleanup.ts b/backend/src/lib/bookingCleanup.ts index 2e331f1..21907f4 100644 --- a/backend/src/lib/bookingCleanup.ts +++ b/backend/src/lib/bookingCleanup.ts @@ -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); diff --git a/backend/src/lib/capacity.ts b/backend/src/lib/capacity.ts new file mode 100644 index 0000000..5c3e69b --- /dev/null +++ b/backend/src/lib/capacity.ts @@ -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`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); +} diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index d80adb5..1983430 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -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; /** 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( (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`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`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( - tx - .select({ count: sql`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( - tx - .select({ count: sql`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(seatHolderCountQuery(tx, eventId)); + const neededRow = await dbGet(unseatedTicketCountQuery(tx, ticketIds)); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + } await tx.update(tickets) .set({ status: targetTicketStatus }) diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts index 912003c..41bd115 100644 --- a/backend/src/lib/holdSweep.ts +++ b/backend/src/lib/holdSweep.ts @@ -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 { const cutoff = toDbDate(new Date(Date.now() - getThresholdMs())); @@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise { }) .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 { )); } - 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; } diff --git a/backend/src/lib/manualProviders.ts b/backend/src/lib/manualProviders.ts deleted file mode 100644 index 5252755..0000000 --- a/backend/src/lib/manualProviders.ts +++ /dev/null @@ -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; diff --git a/backend/src/lib/paymentProviders.ts b/backend/src/lib/paymentProviders.ts new file mode 100644 index 0000000..83ad086 --- /dev/null +++ b/backend/src/lib/paymentProviders.ts @@ -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 = { + 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'; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c7a355f..609713f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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( (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(eventSeatBreakdownQuery(db)); + const seatsByEvent = new Map(); + 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( (db as any) .select({ count: sql`count(*)` }) @@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((payments as any).status, 'pending')) ); + const awaitingApprovalPayments = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'pending_approval')) + ); + const onHoldPayments = await dbGet( (db as any) .select({ count: sql`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, diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 07fe638..c032b84 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -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(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( - (db as any) - .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) - .from(tickets) - .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`) - .groupBy((tickets as any).eventId) - ); - const countByEvent = new Map(); + // 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(eventSeatBreakdownQuery(db)); + const countByEvent = new Map(); 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( - (db as any) - .select({ count: sql`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 { return settings?.timezone || 'America/Asuncion'; } -// Helper function to get ticket count for an event -async function getEventTicketCount(eventId: string): Promise { - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`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(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 { 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, }, }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 1914b73..692ad0d 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -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( (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( (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) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 1ff4610..4c04f92 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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( - (db as any) - .select({ count: sql`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`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( - tx - .select({ count: sql`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(seatHolderCountQuery(tx, data.eventId)); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index e51fff0..a159ed5 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils'; +import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { @@ -108,16 +108,15 @@ export default function BookingPage() { return; } - const bookedCount = eventRes.event.bookedCount ?? 0; - const capacity = eventRes.event.capacity ?? 0; - const soldOut = bookedCount >= capacity; - if (soldOut) { + // Server-authoritative availability — same formula the booking API + // enforces, so a sold-out event is caught here, not at submit time. + if (isEventSoldOut(eventRes.event)) { toast.error(t('events.details.soldOut')); router.push(`/events/${eventRes.event.slug}`); return; } - const spotsLeft = Math.max(0, capacity - bookedCount); + const spotsLeft = eventSpotsLeft(eventRes.event); setEvent(eventRes.event); // Cap quantity by available spots (never allow requesting more than spotsLeft) setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft))); @@ -366,6 +365,23 @@ export default function BookingPage() { } } catch (error: any) { toast.error(error.message || t('booking.form.errors.bookingFailed')); + // Capacity race on the last seats: refresh availability so the page + // reflects reality (sold-out block / lower quantity cap) instead of the + // stale counts loaded when the form was opened. + const message = String(error?.message || ''); + if (/sold out|seats available/i.test(message)) { + try { + const { event: freshEvent } = await eventsApi.getById(params.eventId as string); + setEvent(freshEvent); + const freshSpots = eventSpotsLeft(freshEvent); + if (freshSpots > 0) { + setTicketQuantity((q) => Math.min(q, freshSpots)); + setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1))); + } + } catch { + // Keep the stale event state if the refresh fails + } + } } finally { setSubmitting(false); } @@ -388,8 +404,8 @@ export default function BookingPage() { return null; } - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); // Paying step - waiting for Lightning payment (compact design) if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) { diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index fa959fa..ba0eb77 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail .catch(console.error); }, [eventId]); - // Spots left: never negative; sold out when confirmed >= capacity - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + // Server-authoritative availability (paid + claimed seats count; abandoned + // pending bookings don't) — matches the booking API's sold-out check exactly. + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft)); useEffect(() => { diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index fd1246c..cec0e03 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) { '@type': 'Offer', price: event.price, priceCurrency: event.currency, - availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0 + availability: + (typeof event.availableSeats === 'number' + ? event.availableSeats + : Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 54f547b..1bb1051 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -222,7 +222,14 @@ export default function AdminEventsPage() { {formatDate(event.startDatetime)} - {event.bookedCount || 0} / {event.capacity} + + {(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} + {(event.claimedCount || 0) > 0 && ( + + {event.claimedCount} pending approval + + )} +
{getStatusBadge(event.status)} @@ -332,7 +339,12 @@ export default function AdminEventsPage() {
-

{event.bookedCount || 0} / {event.capacity} spots

+

+ {(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots + {(event.claimedCount || 0) > 0 && ( + · {event.claimedCount} pending + )} +

e.stopPropagation()}> diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 3929aa2..58b8def 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -15,7 +15,7 @@ import { UserGroupIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; -import { parseDate } from '@/lib/utils'; +import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; export default function AdminDashboardPage() { const { t, locale } = useLanguage(); @@ -112,16 +112,15 @@ export default function AdminDashboardPage() {

Alerts

- {/* Low capacity warnings */} + {/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */} {data?.upcomingEvents .filter(event => { - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); - const percentFull = ((event.bookedCount || 0) / event.capacity) * 100; - return percentFull >= 80 && spotsLeft > 0; + const spotsLeft = eventSpotsLeft(event); + return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2; }) .map(event => { - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); - const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100); + const spotsLeft = eventSpotsLeft(event); + const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100); return ( Math.max(0, event.capacity - (event.bookedCount || 0)) === 0) + .filter(event => isEventSoldOut(event)) .map(event => ( ))} - {data && data.stats.pendingPayments > 0 && ( - 0 && ( +
- Pending payments + Payments awaiting verification
- {data.stats.pendingPayments} + {data.stats.awaitingApprovalPayments} + + )} + {/* Informational: opened checkouts that never paid — hold no seats */} + {data && data.stats.pendingPayments > 0 && ( + +
+ + Unpaid started bookings +
+ {data.stats.pendingPayments} )} {data && data.stats.newContacts > 0 && ( @@ -186,10 +199,11 @@ export default function AdminDashboardPage() { )} {/* No alerts */} - {data && - data.stats.pendingPayments === 0 && - data.stats.newContacts === 0 && - !data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && ( + {data && + data.stats.pendingPayments === 0 && + (data.stats.awaitingApprovalPayments ?? 0) === 0 && + data.stats.newContacts === 0 && + !data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (

No alerts at this time

)}
@@ -218,7 +232,12 @@ export default function AdminDashboardPage() {

{formatDate(event.startDatetime)}

- {event.bookedCount || 0}/{event.capacity} + {(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity} + {(event.claimedCount || 0) > 0 && ( + + {event.claimedCount} pending approval + + )} ))} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 40a08fb..1c4e9f7 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; +import { isManualProvider } from '@/lib/api/payments'; import { parseDate, formatRucDisplay } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -36,6 +37,9 @@ export default function AdminPaymentsPage() { const { t, locale } = useLanguage(); const [payments, setPayments] = useState([]); const [pendingApprovalPayments, setPendingApprovalPayments] = useState([]); + // Manual-gateway payments still in bare 'pending': the customer may have paid + // without clicking "I've paid" — approvable directly from the approval tab. + const [unclaimedManualPayments, setUnclaimedManualPayments] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('pending_approval'); @@ -69,17 +73,19 @@ export default function AdminPaymentsPage() { const loadData = async () => { try { setLoading(true); - const [pendingRes, allRes, eventsRes] = await Promise.all([ + const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([ paymentsApi.getPendingApproval(), - paymentsApi.getAll({ - status: statusFilter || undefined, + paymentsApi.getAll({ + status: statusFilter || undefined, provider: providerFilter || undefined, eventIds: eventFilter.length > 0 ? eventFilter : undefined, }), + paymentsApi.getAll({ status: 'pending' }), eventsApi.getAll(), ]); setPendingApprovalPayments(pendingRes.payments); setPayments(allRes.payments); + setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider))); setEvents(eventsRes.events); } catch (error) { toast.error('Failed to load payments'); @@ -88,15 +94,35 @@ export default function AdminPaymentsPage() { } }; + // Approve with over-capacity confirmation: the backend rejects an approval + // that would overbook the event unless the admin explicitly allows it. + const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => { + try { + await paymentsApi.approve(id, note, email); + } catch (error: any) { + if (error?.code !== 'EVENT_OVER_CAPACITY') throw error; + const seatsLeft = error?.data?.availableSeats ?? 0; + const requested = error?.data?.requestedSeats ?? 1; + const message = locale === 'es' + ? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?` + : `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`; + if (!confirm(message)) return false; + await paymentsApi.approve(id, note, email, true); + } + return true; + }; + const handleApprove = async (payment: PaymentWithDetails) => { setProcessing(true); try { - await paymentsApi.approve(payment.id, noteText, sendEmail); - toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); - setSelectedPayment(null); - setNoteText(''); - setSendEmail(true); - loadData(); + const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail); + if (approved) { + toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); + setSelectedPayment(null); + setNoteText(''); + setSendEmail(true); + loadData(); + } } catch (error: any) { toast.error(error.message || 'Failed to approve payment'); } finally { @@ -140,11 +166,13 @@ export default function AdminPaymentsPage() { const handleConfirmPayment = async (id: string) => { try { - await paymentsApi.approve(id); - toast.success('Payment confirmed'); - loadData(); - } catch (error) { - toast.error('Failed to confirm payment'); + const approved = await approveWithCapacityConfirm(id); + if (approved) { + toast.success('Payment confirmed'); + loadData(); + } + } catch (error: any) { + toast.error(error.message || 'Failed to confirm payment'); } }; @@ -298,6 +326,33 @@ export default function AdminPaymentsPage() { return labels[provider] || provider; }; + // Manual gateways need admin verification; automatic ones confirm themselves. + const getProviderKindBadge = (provider: string) => ( + isManualProvider(provider) ? ( + + {locale === 'es' ? 'Manual' : 'Manual'} + + ) : ( + + {locale === 'es' ? 'Automático' : 'Auto'} + + ) + ); + + // Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals. + const getAgeInfo = (dateStr?: string | null) => { + if (!dateStr) return null; + const ms = Date.now() - parseDate(dateStr).getTime(); + if (ms < 0) return null; + const hours = Math.floor(ms / (60 * 60 * 1000)); + const label = hours < 1 + ? (locale === 'es' ? 'hace <1 h' : '<1h ago') + : hours < 48 + ? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`) + : (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`); + return { hours, label, stale: hours >= 48 }; + }; + // Helper to get booking info for a payment (ticket count and total) const getBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { @@ -331,6 +386,22 @@ export default function AdminPaymentsPage() { }); })(); + // Manual payments never claimed by the customer — they may have paid and + // forgotten to press "I've paid", so they stay directly approvable here. + // Hidden once the event has ended (same rule as pending approvals above). + const visibleUnclaimedManualPayments = (() => { + const now = new Date(); + return unclaimedManualPayments.filter((payment) => { + const eventId = payment.event?.id; + const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined; + const endIso = fullEvent?.endDatetime + || fullEvent?.startDatetime + || payment.event?.startDatetime; + if (!endIso) return true; + return parseDate(endIso).getTime() >= now.getTime(); + }); + })(); + // Get booking info for pending approval payments const getPendingBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { @@ -348,9 +419,15 @@ export default function AdminPaymentsPage() { }; }; - // Calculate totals (sum all individual payment amounts) - const totalPending = payments - .filter(p => p.status === 'pending' || p.status === 'pending_approval') + // Calculate totals (sum all individual payment amounts). + // Claimed ('pending_approval') money is probably already in the account and + // just needs verification; bare 'pending' money may never arrive — keep the + // two apart so the totals don't overstate what's owed. + const totalAwaitingVerification = payments + .filter(p => p.status === 'pending_approval') + .reduce((sum, p) => sum + Number(p.amount), 0); + const totalUnclaimed = payments + .filter(p => p.status === 'pending') .reduce((sum, p) => sum + Number(p.amount), 0); const totalPaid = payments .filter(p => p.status === 'paid') @@ -370,9 +447,6 @@ export default function AdminPaymentsPage() { return count; }; - const pendingBookingsCount = getUniqueBookingsCount( - payments.filter(p => p.status === 'pending' || p.status === 'pending_approval') - ); const paidBookingsCount = getUniqueBookingsCount( payments.filter(p => p.status === 'paid') ); @@ -721,9 +795,12 @@ export default function AdminPaymentsPage() {
-

{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}

-

{formatCurrency(totalPending, 'PYG')}

-

{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}

+

{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}

+

{formatCurrency(totalAwaitingVerification, 'PYG')}

+

+ {locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '} + {formatCurrency(totalUnclaimed, 'PYG')} +

@@ -817,13 +894,18 @@ export default function AdminPaymentsPage() { {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderKindBadge(payment.provider)} - {payment.userMarkedPaidAt && ( - - - {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} - - )} + {payment.userMarkedPaidAt && (() => { + const age = getAgeInfo(payment.userMarkedPaidAt); + return ( + + + {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} + {age && ({age.label})} + + ); + })()} {payment.payerName && (

@@ -841,6 +923,55 @@ export default function AdminPaymentsPage() { })} )} + + {/* Manual payments the customer never confirmed — they may have paid + (bank transfer / TPago received) without pressing "I've paid". + Approving one claims a seat, so it goes through the same + over-capacity confirmation as any approval. */} + {visibleUnclaimedManualPayments.length > 0 && ( +

+ + {locale === 'es' + ? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})` + : `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`} + + {locale === 'es' + ? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.' + : 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'} + + +
+ {visibleUnclaimedManualPayments.map((payment) => { + const age = getAgeInfo(payment.createdAt); + return ( + +
+
+
+ {getProviderIcon(payment.provider)} +
+
+

+ {payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName} + · {formatCurrency(payment.amount, payment.currency)} +

+

+ {payment.event?.title} + {' · '}{getProviderLabel(payment.provider)} + {age && · {age.label}} +

+
+
+ +
+
+ ); + })} +
+
+ )} )} @@ -1004,6 +1135,7 @@ export default function AdminPaymentsPage() {
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderKindBadge(payment.provider)}
{getStatusBadge(payment.status)} @@ -1063,7 +1195,7 @@ export default function AdminPaymentsPage() {
{formatCurrency(bookingInfo.bookingTotal, payment.currency)} | - {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)} {bookingInfo.ticketCount > 1 && ( <>|{bookingInfo.ticketCount} tickets )} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 47737f5..5df222a 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -34,7 +34,12 @@ export async function fetchApi( const errorMessage = typeof errorData.error === 'string' ? errorData.error : (errorData.message || JSON.stringify(errorData) || 'Request failed'); - throw new Error(errorMessage); + const error = new Error(errorMessage); + // Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so + // callers can react beyond the message text. + (error as any).code = errorData.code; + (error as any).data = errorData; + throw error; } return res.json(); diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index b057238..2da9d9d 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -1,6 +1,14 @@ import { fetchApi } from './client'; import type { Payment, PaymentWithDetails } from './types'; +// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to +// verify the money arrived; automatic ones (lightning) confirm themselves. +export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash']; + +export function isManualProvider(provider: string): boolean { + return MANUAL_PAYMENT_PROVIDERS.includes(provider); +} + export const paymentsApi = { getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => { const query = new URLSearchParams(); @@ -21,10 +29,10 @@ export const paymentsApi = { body: JSON.stringify(data), }), - approve: (id: string, adminNote?: string, sendEmail: boolean = true) => + approve: (id: string, adminNote?: string, sendEmail: boolean = true, allowOverCapacity: boolean = false) => fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, { method: 'POST', - body: JSON.stringify({ adminNote, sendEmail }), + body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }), }), reject: (id: string, adminNote?: string, sendEmail: boolean = true) => diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 8d5c43a..4d91d77 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -18,8 +18,9 @@ export interface Event { bannerUrl?: string; externalBookingEnabled?: boolean; externalBookingUrl?: string; - bookedCount?: number; - availableSeats?: number; + bookedCount?: number; // paid seats (confirmed + checked_in) + claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats) + availableSeats?: number; // capacity - booked - claimed; the server-authoritative number isFeatured?: boolean; createdAt: string; updatedAt: string; @@ -221,7 +222,10 @@ export interface DashboardData { totalEvents: number; totalTickets: number; confirmedTickets: number; + /** Checkouts opened but never paid nor claimed — informational, holds no seat */ pendingPayments: number; + /** Customer says they paid; needs admin verification — actionable */ + awaitingApprovalPayments: number; totalRevenue: number; newContacts: number; totalSubscribers: number; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a037978..b91961e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -205,3 +205,33 @@ export function getTpagoLink( const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig; return config[key] || config.tpagoLink || null; } + +/** + * Spots left for an event, trusting the server's `availableSeats` (which uses + * the same seat-holding formula the booking API enforces: paid + claimed + * seats count, abandoned pending bookings don't). Falls back to deriving it + * from the counts for older API responses. + */ +export function eventSpotsLeft(event: { + capacity: number; + bookedCount?: number; + claimedCount?: number; + availableSeats?: number; +}): number { + if (typeof event.availableSeats === 'number') { + return Math.max(0, event.availableSeats); + } + return Math.max( + 0, + (event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0) + ); +} + +export function isEventSoldOut(event: { + capacity: number; + bookedCount?: number; + claimedCount?: number; + availableSeats?: number; +}): boolean { + return eventSpotsLeft(event) <= 0; +}