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 { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getTtlMs(): number {
|
function getTtlMs(): number {
|
||||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
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
|
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||||
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
|
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||||
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
|
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||||
// it paid / reopening a failed payment) must atomically re-check that the event still has
|
// must atomically re-check that the event still has room, exactly like the original
|
||||||
// room before re-reserving the seat, exactly like the original booking-creation flow in
|
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
||||||
// routes/tickets.ts.
|
// (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
|
// Callers choose which ticket statuses are eligible to be flipped via
|
||||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
|
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
||||||
// tickets that already hold a seat — are left untouched and don't consume capacity.
|
// 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 { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||||
|
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||||
|
|
||||||
export class HoldCapacityError extends Error {
|
export class HoldCapacityError extends Error {
|
||||||
constructor(public available: number) {
|
constructor(public available: number) {
|
||||||
@@ -26,6 +29,8 @@ interface ReserveOptions {
|
|||||||
extraPaymentFields?: Record<string, any>;
|
extraPaymentFields?: Record<string, any>;
|
||||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
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.
|
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||||
* Capacity is asserted against the number of those tickets that don't already
|
* 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.
|
* 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(
|
export async function reserveOnHoldBooking(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@@ -46,6 +52,10 @@ export async function reserveOnHoldBooking(
|
|||||||
if (ticketIds.length === 0) return;
|
if (ticketIds.length === 0) return;
|
||||||
|
|
||||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
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>(
|
const event = await dbGet<any>(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
(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
|
// `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) => {
|
const assertCapacity = (reserved: number, needed: number) => {
|
||||||
if (needed <= 0) return;
|
if (needed <= 0) return;
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
@@ -80,23 +90,11 @@ export async function reserveOnHoldBooking(
|
|||||||
|
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
if (checkCapacity) {
|
||||||
.select({ count: sql<number>`count(*)` })
|
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||||
.from(tickets)
|
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||||
.where(and(
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
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));
|
|
||||||
|
|
||||||
tx.update(tickets)
|
tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.set({ status: targetTicketStatus })
|
||||||
@@ -113,25 +111,11 @@ export async function reserveOnHoldBooking(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
if (checkCapacity) {
|
||||||
tx
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||||
.from(tickets)
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
.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));
|
|
||||||
|
|
||||||
await tx.update(tickets)
|
await tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.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
|
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||||
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
|
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||||
// which keep a seat reserved while awaiting a human:
|
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||||
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
|
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||||
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
|
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||||
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
|
// dead checkouts out of the admin's pending queues.
|
||||||
// so this is their only seat-release path).
|
//
|
||||||
// In either case the payment (and its ticket) is silently moved to 'on_hold', which
|
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
||||||
// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in')
|
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
||||||
// and so releases the seat back to the event. The user receives no notification — they
|
// UI surfaces aging claims instead of silently releasing them.
|
||||||
// can recover via "I've paid" again, and an admin can reactivate or mark it paid
|
//
|
||||||
// directly, both re-checking capacity.
|
// 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 { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getThresholdMs(): number {
|
function getThresholdMs(): number {
|
||||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
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'.
|
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||||
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
|
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
||||||
* Returns the number of payments put on hold.
|
* is never swept. Returns the number of payments put on hold.
|
||||||
*/
|
*/
|
||||||
export async function sweepStaleApprovals(): Promise<number> {
|
export async function sweepStaleApprovals(): Promise<number> {
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||||
@@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
|
|||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(and(
|
.where(and(
|
||||||
or(
|
eq((payments as any).status, 'pending'),
|
||||||
eq((payments as any).status, 'pending_approval'),
|
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||||
and(
|
|
||||||
eq((payments as any).status, 'pending'),
|
|
||||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
|
|
||||||
)
|
|
||||||
),
|
|
||||||
lt((payments as any).createdAt, cutoff)
|
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;
|
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 { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const adminRouter = new Hono();
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
|
|||||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Get upcoming events
|
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||||
const upcomingEvents = await dbAll(
|
// so the dashboard's capacity alerts reflect real availability.
|
||||||
|
const upcomingEventsRaw = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
@@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.orderBy((events as any).startDatetime)
|
.orderBy((events as any).startDatetime)
|
||||||
.limit(5)
|
.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
|
// Get recent tickets
|
||||||
const recentTickets = await dbAll(
|
const recentTickets = await dbAll(
|
||||||
@@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((tickets as any).status, 'confirmed'))
|
.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>(
|
const pendingPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((payments as any).status, 'pending'))
|
.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>(
|
const onHoldPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
totalTickets: totalTickets?.count || 0,
|
totalTickets: totalTickets?.count || 0,
|
||||||
confirmedTickets: confirmedTickets?.count || 0,
|
confirmedTickets: confirmedTickets?.count || 0,
|
||||||
pendingPayments: pendingPayments?.count || 0,
|
pendingPayments: pendingPayments?.count || 0,
|
||||||
|
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||||
onHoldPayments: onHoldPayments?.count || 0,
|
onHoldPayments: onHoldPayments?.count || 0,
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
newContacts: newContacts?.count || 0,
|
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 { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
|
|||||||
|
|
||||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
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
|
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||||
// this ran one COUNT query per event).
|
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||||
const countRows = await dbAll<any>(
|
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||||
(db as any)
|
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
// capacity check enforces (lib/capacity.ts).
|
||||||
.from(tickets)
|
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||||
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||||
.groupBy((tickets as any).eventId)
|
|
||||||
);
|
|
||||||
const countByEvent = new Map<string, number>();
|
|
||||||
for (const row of countRows) {
|
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 eventsWithCounts = result.map((event: any) => {
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
const bookedCount = countByEvent.get(event.id) || 0;
|
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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 normalized = normalizeEvent(event);
|
||||||
const bookedCount = ticketCount?.count || 0;
|
const counts = await getEventSeatCounts(event.id);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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';
|
return settings?.timezone || 'America/Asuncion';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get ticket count for an event
|
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||||
const ticketCount = await dbGet<any>(
|
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||||
(db as any)
|
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
return {
|
||||||
.from(tickets)
|
paid: Number(row?.paidCount) || 0,
|
||||||
.where(
|
claimed: Number(row?.claimedCount) || 0,
|
||||||
and(
|
};
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return ticketCount?.count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||||
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = await getEventTicketCount(event.id);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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 we have a valid featured event, return it
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||||
const normalized = normalizeEvent(featuredEvent);
|
const normalized = normalizeEvent(featuredEvent);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({
|
|||||||
const approvePaymentSchema = z.object({
|
const approvePaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
sendEmail: z.boolean().optional().default(true),
|
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({
|
const rejectPaymentSchema = z.object({
|
||||||
@@ -317,29 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
// Approve payment (admin) - specifically for pending_approval payments
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
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 user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
return c.json({ error: 'Payment not found' }, 404);
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
// '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.
|
// 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)) {
|
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
||||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
// Get the ticket associated with this payment
|
// Get the ticket associated with this payment
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -381,55 +383,35 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payment.status === 'on_hold' || payment.status === 'failed') {
|
// Confirm the booking through the shared capacity-checked reservation.
|
||||||
// The seat was released when this booking went on hold or failed - re-check
|
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
||||||
// capacity before confirming it directly.
|
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
||||||
try {
|
// When the event is full, the admin gets a structured over-capacity error and
|
||||||
await reserveOnHoldBooking(
|
// may retry with allowOverCapacity to knowingly overbook.
|
||||||
ticket.eventId,
|
try {
|
||||||
ticketsToConfirm.map((t: any) => t.id),
|
await reserveOnHoldBooking(
|
||||||
'confirmed',
|
ticket.eventId,
|
||||||
'paid',
|
ticketsToConfirm.map((t: any) => t.id),
|
||||||
{
|
'confirmed',
|
||||||
paidByAdminId: user.id,
|
'paid',
|
||||||
fromTicketStatuses:
|
{
|
||||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'],
|
paidByAdminId: user.id,
|
||||||
}
|
fromTicketStatuses:
|
||||||
);
|
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
||||||
} catch (err) {
|
skipCapacityCheck: allowOverCapacity,
|
||||||
if (err instanceof HoldCapacityError) {
|
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
||||||
return c.json({
|
|
||||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
|
||||||
}, 400);
|
|
||||||
}
|
}
|
||||||
throw err;
|
);
|
||||||
}
|
} catch (err) {
|
||||||
if (adminNote) {
|
if (err instanceof HoldCapacityError) {
|
||||||
await (db as any)
|
return c.json({
|
||||||
.update(payments)
|
error: 'Approving this payment puts the event over capacity.',
|
||||||
.set({ adminNote })
|
code: 'EVENT_OVER_CAPACITY',
|
||||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
availableSeats: err.available,
|
||||||
}
|
requestedSeats: ticketsToConfirm.length,
|
||||||
} else {
|
}, 409);
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
// 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 emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||||
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const ticketsRouter = new Hono();
|
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);
|
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
// Check capacity against held seats (paid/checked-in tickets plus claimed
|
||||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
|
||||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
|
||||||
const existingTicketCount = await dbGet<any>(
|
const existingTicketCount = await dbGet<any>(
|
||||||
(db as any)
|
seatHolderCountQuery(db, data.eventId)
|
||||||
.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 confirmedCount = existingTicketCount?.count || 0;
|
const confirmedCount = existingTicketCount?.count || 0;
|
||||||
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
||||||
|
|
||||||
@@ -230,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
try {
|
try {
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
const countRow = seatHolderCountQuery(tx, data.eventId).get();
|
||||||
.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 reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
@@ -290,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
|
||||||
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 reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
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 { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -108,16 +108,15 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = eventRes.event.bookedCount ?? 0;
|
// Server-authoritative availability — same formula the booking API
|
||||||
const capacity = eventRes.event.capacity ?? 0;
|
// enforces, so a sold-out event is caught here, not at submit time.
|
||||||
const soldOut = bookedCount >= capacity;
|
if (isEventSoldOut(eventRes.event)) {
|
||||||
if (soldOut) {
|
|
||||||
toast.error(t('events.details.soldOut'));
|
toast.error(t('events.details.soldOut'));
|
||||||
router.push(`/events/${eventRes.event.slug}`);
|
router.push(`/events/${eventRes.event.slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, capacity - bookedCount);
|
const spotsLeft = eventSpotsLeft(eventRes.event);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||||
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||||
@@ -366,6 +365,23 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -388,8 +404,8 @@ export default function BookingPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const isSoldOut = isEventSoldOut(event);
|
||||||
|
|
||||||
// Paying step - waiting for Lightning payment (compact design)
|
// Paying step - waiting for Lightning payment (compact design)
|
||||||
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// Spots left: never negative; sold out when confirmed >= capacity
|
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
|
const isSoldOut = isEventSoldOut(event);
|
||||||
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
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/InStock'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/SoldOut',
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}/events/${event.slug}`,
|
||||||
|
|||||||
@@ -222,7 +222,14 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getStatusBadge(event.status)}
|
{getStatusBadge(event.status)}
|
||||||
@@ -332,7 +339,12 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<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()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<Link href={`/admin/events/${event.id}`}
|
<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">
|
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,
|
UserGroupIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
@@ -112,16 +112,15 @@ export default function AdminDashboardPage() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Low capacity warnings */}
|
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => {
|
.filter(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
|
||||||
return percentFull >= 80 && spotsLeft > 0;
|
|
||||||
})
|
})
|
||||||
.map(event => {
|
.map(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -142,7 +141,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Sold out events */}
|
{/* Sold out events */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
.filter(event => isEventSoldOut(event))
|
||||||
.map(event => (
|
.map(event => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -160,16 +159,30 @@ export default function AdminDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{data && data.stats.pendingPayments > 0 && (
|
{/* Actionable: customer says they paid, needs verification */}
|
||||||
<Link
|
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
||||||
|
<Link
|
||||||
href="/admin/payments"
|
href="/admin/payments"
|
||||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
<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>
|
</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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data && data.stats.newContacts > 0 && (
|
{data && data.stats.newContacts > 0 && (
|
||||||
@@ -186,10 +199,11 @@ export default function AdminDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No alerts */}
|
{/* No alerts */}
|
||||||
{data &&
|
{data &&
|
||||||
data.stats.pendingPayments === 0 &&
|
data.stats.pendingPayments === 0 &&
|
||||||
data.stats.newContacts === 0 &&
|
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
|
||||||
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
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>
|
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +232,12 @@ export default function AdminDashboardPage() {
|
|||||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-600">
|
<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>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
|
import { isManualProvider } from '@/lib/api/payments';
|
||||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -36,6 +37,9 @@ export default function AdminPaymentsPage() {
|
|||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
const [pendingApprovalPayments, setPendingApprovalPayments] = 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 [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
@@ -69,17 +73,19 @@ export default function AdminPaymentsPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [pendingRes, allRes, eventsRes] = await Promise.all([
|
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
|
||||||
paymentsApi.getPendingApproval(),
|
paymentsApi.getPendingApproval(),
|
||||||
paymentsApi.getAll({
|
paymentsApi.getAll({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
provider: providerFilter || undefined,
|
provider: providerFilter || undefined,
|
||||||
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
||||||
}),
|
}),
|
||||||
|
paymentsApi.getAll({ status: 'pending' }),
|
||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
setPendingApprovalPayments(pendingRes.payments);
|
setPendingApprovalPayments(pendingRes.payments);
|
||||||
setPayments(allRes.payments);
|
setPayments(allRes.payments);
|
||||||
|
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load payments');
|
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) => {
|
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
|
||||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
if (approved) {
|
||||||
setSelectedPayment(null);
|
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||||
setNoteText('');
|
setSelectedPayment(null);
|
||||||
setSendEmail(true);
|
setNoteText('');
|
||||||
loadData();
|
setSendEmail(true);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to approve payment');
|
toast.error(error.message || 'Failed to approve payment');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,11 +166,13 @@ export default function AdminPaymentsPage() {
|
|||||||
|
|
||||||
const handleConfirmPayment = async (id: string) => {
|
const handleConfirmPayment = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(id);
|
const approved = await approveWithCapacityConfirm(id);
|
||||||
toast.success('Payment confirmed');
|
if (approved) {
|
||||||
loadData();
|
toast.success('Payment confirmed');
|
||||||
} catch (error) {
|
loadData();
|
||||||
toast.error('Failed to confirm payment');
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to confirm payment');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,6 +326,33 @@ export default function AdminPaymentsPage() {
|
|||||||
return labels[provider] || provider;
|
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)
|
// Helper to get booking info for a payment (ticket count and total)
|
||||||
const getBookingInfo = (payment: PaymentWithDetails) => {
|
const getBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
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
|
// Get booking info for pending approval payments
|
||||||
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -348,9 +419,15 @@ export default function AdminPaymentsPage() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate totals (sum all individual payment amounts)
|
// Calculate totals (sum all individual payment amounts).
|
||||||
const totalPending = payments
|
// Claimed ('pending_approval') money is probably already in the account and
|
||||||
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
// 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);
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
const totalPaid = payments
|
const totalPaid = payments
|
||||||
.filter(p => p.status === 'paid')
|
.filter(p => p.status === 'paid')
|
||||||
@@ -370,9 +447,6 @@ export default function AdminPaymentsPage() {
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingBookingsCount = getUniqueBookingsCount(
|
|
||||||
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
|
||||||
);
|
|
||||||
const paidBookingsCount = getUniqueBookingsCount(
|
const paidBookingsCount = getUniqueBookingsCount(
|
||||||
payments.filter(p => p.status === 'paid')
|
payments.filter(p => p.status === 'paid')
|
||||||
);
|
);
|
||||||
@@ -721,9 +795,12 @@ export default function AdminPaymentsPage() {
|
|||||||
<ClockIcon className="w-5 h-5 text-gray-600" />
|
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
|
||||||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
|
||||||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
|
||||||
|
{formatCurrency(totalUnclaimed, 'PYG')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -817,13 +894,18 @@ export default function AdminPaymentsPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{getProviderIcon(payment.provider)}
|
{getProviderIcon(payment.provider)}
|
||||||
{getProviderLabel(payment.provider)}
|
{getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</span>
|
</span>
|
||||||
{payment.userMarkedPaidAt && (
|
{payment.userMarkedPaidAt && (() => {
|
||||||
<span className="flex items-center gap-1">
|
const age = getAgeInfo(payment.userMarkedPaidAt);
|
||||||
<ClockIcon className="w-3 h-3" />
|
return (
|
||||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
|
||||||
</span>
|
<ClockIcon className="w-3 h-3" />
|
||||||
)}
|
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||||
|
{age && <span>({age.label})</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{payment.payerName && (
|
{payment.payerName && (
|
||||||
<p className="text-xs text-amber-600 mt-1 font-medium">
|
<p className="text-xs text-amber-600 mt-1 font-medium">
|
||||||
@@ -841,6 +923,55 @@ export default function AdminPaymentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</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">
|
<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="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||||
<span className="text-gray-300">|</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 && (
|
{bookingInfo.ticketCount > 1 && (
|
||||||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
<><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'
|
const errorMessage = typeof errorData.error === 'string'
|
||||||
? errorData.error
|
? errorData.error
|
||||||
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
: (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();
|
return res.json();
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { fetchApi } from './client';
|
import { fetchApi } from './client';
|
||||||
import type { Payment, PaymentWithDetails } from './types';
|
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 = {
|
export const paymentsApi = {
|
||||||
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -21,10 +29,10 @@ export const paymentsApi = {
|
|||||||
body: JSON.stringify(data),
|
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`, {
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ adminNote, sendEmail }),
|
body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export interface Event {
|
|||||||
bannerUrl?: string;
|
bannerUrl?: string;
|
||||||
externalBookingEnabled?: boolean;
|
externalBookingEnabled?: boolean;
|
||||||
externalBookingUrl?: string;
|
externalBookingUrl?: string;
|
||||||
bookedCount?: number;
|
bookedCount?: number; // paid seats (confirmed + checked_in)
|
||||||
availableSeats?: number;
|
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
|
||||||
|
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
|
||||||
isFeatured?: boolean;
|
isFeatured?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -221,7 +222,10 @@ export interface DashboardData {
|
|||||||
totalEvents: number;
|
totalEvents: number;
|
||||||
totalTickets: number;
|
totalTickets: number;
|
||||||
confirmedTickets: number;
|
confirmedTickets: number;
|
||||||
|
/** Checkouts opened but never paid nor claimed — informational, holds no seat */
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
|
/** Customer says they paid; needs admin verification — actionable */
|
||||||
|
awaitingApprovalPayments: number;
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
newContacts: number;
|
newContacts: number;
|
||||||
totalSubscribers: number;
|
totalSubscribers: number;
|
||||||
|
|||||||
@@ -205,3 +205,33 @@ export function getTpagoLink(
|
|||||||
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
|
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
|
||||||
return config[key] || config.tpagoLink || null;
|
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