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
@@ -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