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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user