Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
3.7 KiB
TypeScript
75 lines
3.7 KiB
TypeScript
// 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), or an
|
|
* array of ids to restrict to those events (e.g. one page of a listing). Callers
|
|
* must skip the query for an empty array.
|
|
*/
|
|
export function eventSeatBreakdownQuery(executor: any, eventId?: string | 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));
|
|
const scoped = Array.isArray(eventId)
|
|
? query.where(inArray((tickets as any).eventId, eventId))
|
|
: eventId ? query.where(eq((tickets as any).eventId, eventId)) : query;
|
|
return scoped.groupBy((tickets as any).eventId);
|
|
}
|