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 = { 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>; 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 & { 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>; export type PartnerInput = Partial>; // ==================== 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(`${e(eventId)}/my-permissions`), get: (eventId: string) => fetchApi(`${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> }) => fetchApi<{ member: EventMember }>(`${e(eventId)}/members`, json('POST', data)), updateMember: (eventId: string, id: string, data: { rolePreset?: RolePreset; permissions?: Partial> }) => 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) => fetchApi<{ category: ExpenseCategory }>('/api/finance/settings/expense-categories', json('POST', data)), updateCategory: (id: string, data: Partial) => 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) => fetchApi<{ template: ExpenseTemplate }>('/api/finance/settings/expense-templates', json('POST', data)), updateTemplate: (id: string, data: Partial) => 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) => fetchApi<{ pack: ExpenseTemplatePack }>('/api/finance/settings/expense-template-packs', json('POST', data)), updatePack: (id: string, data: Partial) => 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(`/api/finance/overview${qs ? `?${qs}` : ''}`); }, myEvents: () => fetchApi<{ events: MyEvent[] }>('/api/dashboard/my-events'), };