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:
co-authored by
Claude Opus 5.5
parent
b51c1360e2
commit
e203fb6c74
@@ -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'),
|
||||
};
|
||||
Reference in New Issue
Block a user