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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -222,7 +222,14 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
||||
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
|
||||
{(event.claimedCount || 0) > 0 && (
|
||||
<span className="block text-[11px] text-yellow-600">
|
||||
{event.claimedCount} pending approval
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event.status)}
|
||||
@@ -332,7 +339,12 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
|
||||
{(event.claimedCount || 0) > 0 && (
|
||||
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Link href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||
|
||||
@@ -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() {
|
||||
<Card className="p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||
<div className="space-y-3">
|
||||
{/* 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 (
|
||||
<Link
|
||||
key={event.id}
|
||||
@@ -142,7 +141,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
{/* Sold out events */}
|
||||
{data?.upcomingEvents
|
||||
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
||||
.filter(event => isEventSoldOut(event))
|
||||
.map(event => (
|
||||
<Link
|
||||
key={event.id}
|
||||
@@ -160,16 +159,30 @@ export default function AdminDashboardPage() {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{data && data.stats.pendingPayments > 0 && (
|
||||
<Link
|
||||
{/* Actionable: customer says they paid, needs verification */}
|
||||
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
||||
<Link
|
||||
href="/admin/payments"
|
||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm">Pending payments</span>
|
||||
<span className="text-sm">Payments awaiting verification</span>
|
||||
</div>
|
||||
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
|
||||
<span className="badge badge-warning">{data.stats.awaitingApprovalPayments}</span>
|
||||
</Link>
|
||||
)}
|
||||
{/* Informational: opened checkouts that never paid — hold no seats */}
|
||||
{data && data.stats.pendingPayments > 0 && (
|
||||
<Link
|
||||
href="/admin/payments"
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Unpaid started bookings</span>
|
||||
</div>
|
||||
<span className="badge badge-info">{data.stats.pendingPayments}</span>
|
||||
</Link>
|
||||
)}
|
||||
{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) && (
|
||||
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,7 +232,12 @@ export default function AdminDashboardPage() {
|
||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{event.bookedCount || 0}/{event.capacity}
|
||||
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
|
||||
{(event.claimedCount || 0) > 0 && (
|
||||
<span className="text-xs text-yellow-600 block text-right">
|
||||
{event.claimedCount} pending approval
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -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<PaymentWithDetails[]>([]);
|
||||
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
||||
// 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<PaymentWithDetails[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('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) ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-orange-50 text-orange-600">
|
||||
{locale === 'es' ? 'Manual' : 'Manual'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-50 text-blue-600">
|
||||
{locale === 'es' ? 'Automático' : 'Auto'}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
// 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() {
|
||||
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
||||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
|
||||
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
|
||||
{formatCurrency(totalUnclaimed, 'PYG')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -817,13 +894,18 @@ export default function AdminPaymentsPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
{getProviderIcon(payment.provider)}
|
||||
{getProviderLabel(payment.provider)}
|
||||
{getProviderKindBadge(payment.provider)}
|
||||
</span>
|
||||
{payment.userMarkedPaidAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||
</span>
|
||||
)}
|
||||
{payment.userMarkedPaidAt && (() => {
|
||||
const age = getAgeInfo(payment.userMarkedPaidAt);
|
||||
return (
|
||||
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||
{age && <span>({age.label})</span>}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{payment.payerName && (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">
|
||||
@@ -841,6 +923,55 @@ export default function AdminPaymentsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="mt-8">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-600 select-none">
|
||||
{locale === 'es'
|
||||
? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})`
|
||||
: `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`}
|
||||
<span className="block text-xs font-normal text-gray-400 mt-0.5">
|
||||
{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.'}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="space-y-3 mt-4">
|
||||
{visibleUnclaimedManualPayments.map((payment) => {
|
||||
const age = getAgeInfo(payment.createdAt);
|
||||
return (
|
||||
<Card key={payment.id} className="p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{getProviderIcon(payment.provider)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName}
|
||||
<span className="text-gray-400 font-normal"> · {formatCurrency(payment.amount, payment.currency)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{payment.event?.title}
|
||||
{' · '}{getProviderLabel(payment.provider)}
|
||||
{age && <span> · {age.label}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setSelectedPayment(payment)} size="sm" variant="outline" className="flex-shrink-0 min-h-[40px]">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1004,6 +1135,7 @@ export default function AdminPaymentsPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||
{getProviderKindBadge(payment.provider)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||||
@@ -1063,7 +1195,7 @@ export default function AdminPaymentsPage() {
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
|
||||
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)}</span>
|
||||
{bookingInfo.ticketCount > 1 && (
|
||||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,12 @@ export async function fetchApi<T>(
|
||||
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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user