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>
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
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'),
|
|
};
|