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
+310
View File
@@ -0,0 +1,310 @@
import { fetchApi, fetchBlob } from './client';
// ==================== Types ====================
export type EventPermission =
| 'view_overview' | 'check_in' | 'view_attendees_names' | 'view_attendees_pii' | 'email_attendees'
| 'view_payments' | 'view_finance' | 'edit_expenses' | 'edit_own_expenses_only' | 'view_full_split'
| 'edit_event' | 'manage_team';
export const EVENT_PERMISSIONS: EventPermission[] = [
'view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments',
'view_finance', 'edit_expenses', 'edit_own_expenses_only', 'view_full_split', 'edit_event', 'manage_team',
];
export type RolePreset = 'staff' | 'collaborator' | 'co_manager';
export const ROLE_PRESETS: RolePreset[] = ['staff', 'collaborator', 'co_manager'];
/** Mirrors backend/src/lib/eventPermissions.ts PRESET_PERMISSIONS. */
export const PRESET_PERMISSIONS: Record<RolePreset, EventPermission[]> = {
staff: ['view_overview', 'check_in', 'view_attendees_names'],
collaborator: ['view_overview', 'view_finance'],
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
};
export type CalcType = 'fixed' | 'per_ticket_sold' | 'per_checked_in' | 'percent_of_revenue' | 'minimum_spend';
export const CALC_TYPES: CalcType[] = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'];
export type ShareType = 'percent_profit' | 'percent_revenue' | 'fixed' | 'fixed_plus_percent_above_threshold';
export const SHARE_TYPES: ShareType[] = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'];
export type LossRule = 'proportional' | 'none' | 'capped';
export const LOSS_RULES: LossRule[] = ['proportional', 'none', 'capped'];
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
export interface MyEventPermissions {
eventId: string;
global: boolean;
role: string;
rolePreset: RolePreset | null;
permissions: EventPermission[];
canUnfinalize: boolean;
}
export interface ExpenseCategory {
id: string;
nameEn: string;
nameEs: string;
color: string;
sortOrder: number;
archived: boolean;
}
export interface EventExpense {
id: string;
eventId: string;
categoryId: string | null;
templateId: string | null;
description: string;
calcType: CalcType;
quantity: number;
unitAmount: number;
percentBp: number;
minimumAmount: number;
computedAmount: number;
isLocked: boolean;
status: 'planned' | 'paid';
/** 'other' = fronted by a partner the viewer may not see. */
paidByPartnerId: string | null;
receiptUrl: string | null;
expenseDate: string | null;
createdBy: string | null;
createdAt: string | null;
/** Live values from the calculation (present on GET /finance). */
amount?: number;
liveQuantity?: number;
auto?: boolean;
}
export interface OtherIncome {
id: string;
description: string;
amount: number;
createdAt: string | null;
}
export interface EventPartner {
id: string;
userId: string | null;
externalName: string | null;
name: string;
roleLabel: string | null;
shareType: ShareType;
percentBp: number;
fixedAmount: number;
thresholdAmount: number;
lossRule: LossRule;
lossCapAmount: number;
payoutStatus: 'pending' | 'paid';
payoutDate: string | null;
payoutMethod: string | null;
payoutNote: string | null;
}
export interface PartnerLine {
partnerId: string;
name: string;
shareType: ShareType;
basis: number;
share: number;
reimbursement: number;
payout: number;
}
export interface FinanceSummary {
counts: { ticketsSold: number; checkedIn: number; payments: number };
revenue: {
gross: number; presale: number; door: number; refunds: number; fees: number; otherIncome: number; sales: number; net: number;
byMethod: { method: string; count: number; gross: number; refunds: number; fees: number; net: number }[];
};
expenses: {
lines: { id: string; quantity: number; amount: number; auto: boolean }[];
total: number; planned: number; paid: number;
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
};
profit: number;
breakEven: { tickets: number | null; ticketPrice: number; remaining: number | null };
/** distributable / organization are null for viewers without view_full_split. */
split: { distributable: number | null; partners: PartnerLine[]; organization: number | null };
waterfall: { key: string; label?: string; amount: number }[];
salesTimeline: { date: string; tickets: number; revenue: number }[];
}
export interface EventFinance {
event: { id: string; title: string; titleEs?: string; startDatetime: string; endDatetime: string | null; location: string; series: string | null; price: number; currency: string; capacity: number };
status: FinanceStatus;
finalizedAt: string | null;
live: boolean;
computedAt: string;
summary: FinanceSummary;
expenses: EventExpense[];
otherIncome: OtherIncome[];
partners: EventPartner[];
categories: ExpenseCategory[];
viewer: {
fullSplit: boolean;
canEditExpenses: boolean;
canEditOwnExpenses: boolean;
canManageSplit: boolean;
canUnfinalize: boolean;
userId: string;
};
}
export interface ExpenseTemplate {
id: string;
name: string;
categoryId: string | null;
description: string | null;
calcType: CalcType;
amount: number;
percentBp: number;
minimumAmount: number;
archived: boolean;
}
export interface ExpenseTemplatePack {
id: string;
name: string;
description: string | null;
archived: boolean;
templateIds: string[];
}
export interface PaymentMethodFee {
method: string;
percentBp: number;
fixedAmount: number;
}
export interface EventMember {
id: string;
userId: string;
name: string | null;
email: string | null;
globalRole: string | null;
rolePreset: RolePreset;
permissions: Partial<Record<EventPermission, boolean>>;
effective: EventPermission[];
createdAt: string | null;
}
export interface FinanceAuditEntry {
id: string;
actorUserId: string | null;
actorName: string | null;
entityType: string;
entityId: string | null;
action: string;
before: any;
after: any;
createdAt: string | null;
}
export interface FinanceTotals { events: number; gross: number; fees: number; net: number; expenses: number; profit: number }
export type FinanceOverviewEvent = Omit<FinanceTotals, 'events'> & {
id: string; title: string; titleEs: string | null; startDatetime: string | null; endDatetime: string | null; series: string | null; location: string;
status: string; financeStatus: FinanceStatus; ticketsSold: number; organization: number | null;
};
export interface FinanceOverview {
totals: FinanceTotals;
events: FinanceOverviewEvent[];
/** Past events with open books, including ones with no money recorded yet; longest-waiting first. */
readyToClose: FinanceOverviewEvent[];
bySeries: (FinanceTotals & { series: string | null })[];
byVenue: (FinanceTotals & { venue: string })[];
byPartner: { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }[];
filters: { series: string[]; venues: string[]; partners: { key: string; name: string }[] };
}
export interface MyEvent {
event: { id: string; slug: string; title: string; titleEs?: string | null; startDatetime: string; location: string; status: string; bannerUrl?: string | null };
rolePreset: RolePreset;
permissions: EventPermission[];
}
export type ExpenseInput = Partial<Pick<EventExpense,
'description' | 'categoryId' | 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' |
'isLocked' | 'status' | 'paidByPartnerId' | 'receiptUrl' | 'expenseDate'>>;
export type PartnerInput = Partial<Pick<EventPartner,
'userId' | 'externalName' | 'roleLabel' | 'shareType' | 'percentBp' | 'fixedAmount' | 'thresholdAmount' | 'lossRule' | 'lossCapAmount'>>;
// ==================== Client ====================
const json = (method: string, body?: unknown): RequestInit => ({ method, body: body === undefined ? undefined : JSON.stringify(body) });
const e = (id: string) => `/api/events/${encodeURIComponent(id)}`;
/** Team members pass the event they are working on so the server can check their permission there. */
const scoped = (eventId?: string) => (eventId ? `?eventId=${encodeURIComponent(eventId)}` : '');
export const financeApi = {
myPermissions: (eventId: string) => fetchApi<MyEventPermissions>(`${e(eventId)}/my-permissions`),
get: (eventId: string) => fetchApi<EventFinance>(`${e(eventId)}/finance`),
finalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/finalize`, json('POST', {})),
unfinalize: (eventId: string) => fetchApi<{ status: FinanceStatus }>(`${e(eventId)}/finance/unfinalize`, json('POST', {})),
createExpense: (eventId: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses`, json('POST', data)),
updateExpense: (eventId: string, id: string, data: ExpenseInput) =>
fetchApi<{ expense: EventExpense }>(`${e(eventId)}/expenses/${id}`, json('PUT', data)),
deleteExpense: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/expenses/${id}`, json('DELETE')),
applyTemplate: (eventId: string, data: { templateId?: string; packId?: string }) =>
fetchApi<{ expenses: EventExpense[] }>(`${e(eventId)}/expenses/apply-template`, json('POST', data)),
createIncome: (eventId: string, data: { description: string; amount: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income`, json('POST', data)),
updateIncome: (eventId: string, id: string, data: { description?: string; amount?: number }) =>
fetchApi<{ income: OtherIncome }>(`${e(eventId)}/other-income/${id}`, json('PUT', data)),
deleteIncome: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/other-income/${id}`, json('DELETE')),
searchPartnerCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/partners/candidates?q=${encodeURIComponent(q)}`),
createPartner: (eventId: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners`, json('POST', data)),
updatePartner: (eventId: string, id: string, data: PartnerInput) =>
fetchApi<{ partner: EventPartner }>(`${e(eventId)}/partners/${id}`, json('PUT', data)),
deletePartner: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/partners/${id}`, json('DELETE')),
markPartnerPaid: (eventId: string, id: string, data: { paid: boolean; payoutDate?: string | null; payoutMethod?: string | null; payoutNote?: string | null }) =>
fetchApi<{ partner: EventPartner; status: FinanceStatus }>(`${e(eventId)}/partners/${id}/mark-paid`, json('POST', data)),
downloadStatement: (eventId: string, partnerId: string, locale: string) =>
fetchBlob(`${e(eventId)}/partners/${partnerId}/statement?locale=${locale}`, 'statement.pdf'),
auditLog: (eventId: string) => fetchApi<{ entries: FinanceAuditEntry[] }>(`${e(eventId)}/audit-log`),
getMembers: (eventId: string) => fetchApi<{ members: EventMember[] }>(`${e(eventId)}/members`),
searchMemberCandidates: (eventId: string, q: string) =>
fetchApi<{ users: { id: string; name: string; email: string; role: string }[] }>(`${e(eventId)}/members/candidates?q=${encodeURIComponent(q)}`),
addMember: (eventId: string, data: { userId: string; rolePreset: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members`, json('POST', data)),
updateMember: (eventId: string, id: string, data: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) =>
fetchApi<{ member: EventMember }>(`${e(eventId)}/members/${id}`, json('PUT', data)),
removeMember: (eventId: string, id: string) => fetchApi<{ message: string }>(`${e(eventId)}/members/${id}`, json('DELETE')),
// Settings (writes are admin only; reads also work for members via eventId)
getCategories: (eventId?: string) => fetchApi<{ categories: ExpenseCategory[] }>(`/api/finance/settings/expense-categories${scoped(eventId)}`),
createCategory: (data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>('/api/finance/settings/expense-categories', json('POST', data)),
updateCategory: (id: string, data: Partial<ExpenseCategory>) => fetchApi<{ category: ExpenseCategory }>(`/api/finance/settings/expense-categories/${id}`, json('PUT', data)),
deleteCategory: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-categories/${id}`, json('DELETE')),
getTemplates: (eventId?: string) => fetchApi<{ templates: ExpenseTemplate[] }>(`/api/finance/settings/expense-templates${scoped(eventId)}`),
createTemplate: (data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>('/api/finance/settings/expense-templates', json('POST', data)),
updateTemplate: (id: string, data: Partial<ExpenseTemplate>) => fetchApi<{ template: ExpenseTemplate }>(`/api/finance/settings/expense-templates/${id}`, json('PUT', data)),
deleteTemplate: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-templates/${id}`, json('DELETE')),
getPacks: (eventId?: string) => fetchApi<{ packs: ExpenseTemplatePack[] }>(`/api/finance/settings/expense-template-packs${scoped(eventId)}`),
createPack: (data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>('/api/finance/settings/expense-template-packs', json('POST', data)),
updatePack: (id: string, data: Partial<ExpenseTemplatePack>) => fetchApi<{ pack: ExpenseTemplatePack }>(`/api/finance/settings/expense-template-packs/${id}`, json('PUT', data)),
deletePack: (id: string) => fetchApi<{ message: string }>(`/api/finance/settings/expense-template-packs/${id}`, json('DELETE')),
getFees: () => fetchApi<{ fees: PaymentMethodFee[] }>('/api/finance/settings/payment-fees'),
updateFee: (method: string, data: { percentBp: number; fixedAmount: number }) =>
fetchApi<{ fee: PaymentMethodFee }>(`/api/finance/settings/payment-fees/${method}`, json('PUT', data)),
overview: (params: { from?: string; to?: string; series?: string; venue?: string; partner?: string } = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) q.set(k, v); });
const qs = q.toString();
return fetchApi<FinanceOverview>(`/api/finance/overview${qs ? `?${qs}` : ''}`);
},
myEvents: () => fetchApi<{ events: MyEvent[] }>('/api/dashboard/my-events'),
};