// Pure derivations for the Finance tab: where the event is in its money // lifecycle, what is left to do, how break-even reads, and a category guess // for a new expense. No React here so it can be unit tested. import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api'; export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized'; export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized']; export interface TodoItem { /** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */ key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded'; count?: number; tone: 'warn' | 'info'; } export interface Lifecycle { step: LifecycleStep; ended: boolean; /** Whole days since the event ended; null while it has not ended. */ daysSinceEnd: number | null; expenseCount: number; plannedCount: number; partnerCount: number; todo: TodoItem[]; } type LifecycleInput = Pick & { event: { startDatetime: string; endDatetime?: string | null }; viewer?: { fullSplit: boolean }; }; const DAY = 86_400_000; /** The moment an event counts as over: its end time, or its start when no end is set. */ export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number { return new Date(event.endDatetime || event.startDatetime).getTime(); } export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle { const end = eventEndTime(data.event); const ended = Number.isFinite(end) && now >= end; const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null; const expenseCount = data.expenses.length; const plannedCount = data.expenses.filter((e) => e.status === 'planned').length; const partnerCount = data.partners.length; const fullSplit = data.viewer?.fullSplit ?? true; const todo: TodoItem[] = []; let step: LifecycleStep; if (data.status !== 'open') { step = 'finalized'; const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length; if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' }); } else { if (!ended) step = expenseCount === 0 ? 'selling' : 'costs'; else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs'; if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' }); if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' }); if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' }); if (!ended) todo.push({ key: 'notEnded', tone: 'info' }); } return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo }; } // ==================== Break-even ==================== export type BreakEvenState = | { kind: 'noCosts' } | { kind: 'unreachable' } | { kind: 'reached'; at: number; sold: number } | { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number }; export function breakEvenState(summary: Pick): BreakEvenState { const be = summary.breakEven; const sold = summary.counts.ticketsSold; if (summary.expenses.total === 0) return { kind: 'noCosts' }; if (be.tickets === null) return { kind: 'unreachable' }; if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold }; const needed = be.tickets; return { kind: 'needed', remaining: be.remaining ?? needed - sold, sold, needed, pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100, }; } // ==================== Category suggestion ==================== /** Words in an expense description, and the words that identify the matching category by name. */ const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [ { words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'], category: ['venue', 'lugar'], }, { words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'], category: ['instructor', 'host', 'anfitrion'], }, { words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda', 'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'], category: ['food', 'drink', 'comida', 'bebida'], }, { words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'], category: ['staff', 'personal'], }, { words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'], category: ['marketing'], }, { words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers', 'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'], category: ['suppl', 'material'], }, ]; const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); /** Best-guess category id for a description, or null when nothing matches. */ export function suggestCategory(description: string, categories: Pick[]): string | null { const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean)); if (tokens.size === 0) return null; for (const hint of CATEGORY_HINTS) { if (!hint.words.some((w) => tokens.has(w))) continue; const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k))); if (match) return match.id; } return null; } // ==================== Copy helpers ==================== type T = (key: string, params?: Record) => string; /** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */ export function tCount(t: T, key: string, count: number, params: Record = {}): string { return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params }); } // ==================== Waterfall ==================== export type WaterfallKind = 'total' | 'in' | 'out'; export interface WaterfallStep { key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization'; kind: WaterfallKind; /** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */ amount: number; } /** * Gross revenue -> fees -> expenses -> other income -> partner payouts -> * what the organization keeps. Zero steps are left out; the steps always add * up to the final bar. Viewers without the full split end at the profit. */ export function buildWaterfall(summary: Pick): WaterfallStep[] { const r = summary.revenue; const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }]; const add = (key: WaterfallStep['key'], amount: number) => { if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount }); }; add('refunds', -r.refunds); add('fees', -r.fees); add('expenses', -summary.expenses.total); add('otherIncome', r.otherIncome); const withSplit = summary.split.organization !== null && summary.split.partners.length > 0; if (withSplit) { add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0)); steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! }); } else { steps.push({ key: 'profit', kind: 'total', amount: summary.profit }); } return steps; } /** Which detail charts have something to show (cumulative sales needs at least two days). */ export function detailChartsAvailable(summary: Pick) { return { costs: summary.expenses.byCategory.some((c) => c.total > 0), methods: summary.revenue.byMethod.some((m) => m.gross > 0), timeline: summary.salesTimeline.length >= 2, }; }