Files
Spanglish/frontend/src/app/admin/events/[id]/_finance/derive.ts
T
MichilisandClaude Opus 5.5 e203fb6c74 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>
2026-10-03 03:11:46 +00:00

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,
};
}