Add per-event finance, event team permissions and public door sales state.

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>
This commit is contained in:
Michilis
2026-10-03 03:11:46 +00:00
co-authored by Claude Opus 5.5
parent b51c1360e2
commit e203fb6c74
80 changed files with 11326 additions and 971 deletions
+61 -36
View File
@@ -4,12 +4,15 @@ import { z } from 'zod';
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
import { resolvePresaleClosure } from '../lib/presale.js';
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
import { publicSalesFields } from '../lib/salesState.js';
import { loadDoorMethods } from '../lib/doorPayments.js';
interface UserContext {
id: string;
@@ -50,6 +53,26 @@ function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?:
return normalized;
}
// Seat counts plus the public sales state for a normalized event. `raw` is the
// DB row: doorPrice is resolved from its walk-in price, and only in the `door`
// state (see lib/salesState.ts).
function withSeatsAndSales(
raw: any,
normalized: any,
settings: any,
counts: { paid: number; claimed: number },
nowMs: number = Date.now()
) {
const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed);
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats,
...publicSalesFields(raw, settings, availableSeats, nowMs),
};
}
// Load every slug currently in use (canonical event slugs + historical aliases),
// optionally excluding a given event's own canonical slug + aliases.
async function getAllSlugsInUse(excludeEventId?: string): Promise<string[]> {
@@ -166,6 +189,8 @@ const baseEventSchema = z.object({
// Accept price as number or string (handles "45000" and "41,44" formats)
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
walkInPrice: walkInPriceSchema,
// Groups recurring events for the finance overview ("" clears it)
series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(),
currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
@@ -290,7 +315,11 @@ eventsRouter.get('/', async (c) => {
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
// so availableSeats subtracts them together — the same formula the booking-creation
// capacity check enforces (lib/capacity.ts).
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
// Scoped to the returned events so a page of 25 does not scan every ticket.
const eventIds = result.map((event: any) => event.id);
const countRows = eventIds.length > 0
? await dbAll<any>(eventSeatBreakdownQuery(db, eventIds))
: [];
const countByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of countRows) {
countByEvent.set(row.eventId, {
@@ -300,16 +329,16 @@ eventsRouter.get('/', async (c) => {
}
const siteSettingsRow = await getSiteSettingsRow();
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event, siteSettingsRow, { includeWalkInPrice });
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
});
const nowMs = Date.now();
const eventsWithCounts = result.map((event: any) =>
withSeatsAndSales(
event,
normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }),
siteSettingsRow,
countByEvent.get(event.id) || { paid: 0, claimed: 0 },
nowMs,
)
);
return paginated
? c.json({ events: eventsWithCounts, total, page, pageSize })
@@ -335,17 +364,21 @@ eventsRouter.get('/:id', async (c) => {
}
}
const normalized = normalizeEvent(event, await getSiteSettingsRow(), {
const settings = await getSiteSettingsRow();
const normalized = normalizeEvent(event, settings, {
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
});
const counts = await getEventSeatCounts(event.id);
const publicEvent = withSeatsAndSales(event, normalized, settings, counts);
// Door tenders (never the comp "guest" one) for the page's "pay at the door" line.
const doorPaymentMethods = publicEvent.salesState === 'door'
? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest')
: undefined;
// serverTime lets the page schedule its refresh at presaleClosesAt even when
// the visitor's clock is off.
return c.json({
event: {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
},
event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) },
serverTime: new Date().toISOString(),
});
});
@@ -393,13 +426,8 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
}
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event, await getSiteSettingsRow());
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
const settings = await getSiteSettingsRow();
return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts);
}
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
@@ -462,13 +490,9 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it
if (featuredEvent) {
const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent, settings);
return c.json({
event: {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts),
isFeatured: true,
},
});
@@ -523,7 +547,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
});
// Update event (admin/organizer only)
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), zValidator('json', updateEventSchema, validationHook), async (c) => {
const id = c.req.param('id');
const data = c.req.valid('json');
@@ -669,17 +693,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
});
// Get event attendees (admin/organizer only)
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => {
const id = c.req.param('id');
const attendees = await dbAll(
const attendees = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, id))
);
return c.json({ attendees });
return c.json({ attendees: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) });
});
// Duplicate event (admin/organizer only)
@@ -723,6 +747,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
externalBookingUrl: existing.externalBookingUrl,
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
series: existing.series ?? null,
createdAt: now,
updatedAt: now,
};
@@ -733,7 +758,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
});
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
@@ -754,7 +779,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async
});
// Remove a slug alias from an event (admin/organizer only)
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');