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>
190 lines
8.2 KiB
TypeScript
190 lines
8.2 KiB
TypeScript
// 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<EventFinance, 'status' | 'expenses' | 'partners'> & {
|
|
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<FinanceSummary, 'breakEven' | 'expenses' | 'counts'>): 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<ExpenseCategory, 'id' | 'nameEn' | 'nameEs' | 'archived'>[]): 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, string | number>) => 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, string | number> = {}): 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<FinanceSummary, 'revenue' | 'expenses' | 'profit' | 'split'>): 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<FinanceSummary, 'expenses' | 'revenue' | 'salesTimeline'>) {
|
|
return {
|
|
costs: summary.expenses.byCategory.some((c) => c.total > 0),
|
|
methods: summary.revenue.byMethod.some((m) => m.gross > 0),
|
|
timeline: summary.salesTimeline.length >= 2,
|
|
};
|
|
}
|