// Global finance: expense categories, templates, template packs, payment // method fees (Site Settings → Expense Templates) and the cross-event overview. // Mounted at /api/finance. // // Writes are admin only. The read endpoints for categories/templates/packs are // also open to members who can add expenses on the event given as ?eventId=, // so the "Apply template" pickers work for them. import { Hono, type Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { and, eq, gte, inArray, lte } from 'drizzle-orm'; import { db, dbAll, dbGet, events, expenseCategories, expenseTemplates, expenseTemplatePacks, expenseTemplatePackItems, eventExpenses, paymentMethodFees, } from '../db/index.js'; import { requireAuth, type AuthUser } from '../lib/auth.js'; import { requireEventPermission, eventFromQuery } from '../lib/eventPermissions.js'; import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js'; import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js'; import { financeAuditOp } from '../lib/finance/audit.js'; import { getEventFinance, iso, pyg, bool, loadFeeRules } from '../lib/finance/load.js'; import { CALC_TYPES } from '../lib/finance/calculate.js'; const financeGlobalRouter = new Hono(); const validationHook = (result: any, c: any) => { if (!result.success) { const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', '); return c.json({ error: errors }, 400); } }; const money = z.number().int().min(0).max(2_000_000_000); const bp = z.number().int().min(0).max(10000); const currentUser = (c: Context) => (c as any).get('user') as AuthUser; const ADMIN = requireAuth(['admin']); const TEMPLATE_READERS = requireEventPermission(['edit_expenses', 'edit_own_expenses_only'], { eventId: eventFromQuery() }); // ==================== Categories ==================== const serializeCategory = (r: any) => ({ id: r.id, nameEn: r.nameEn, nameEs: r.nameEs, color: r.color, sortOrder: pyg(r.sortOrder), archived: bool(r.archived), createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt), }); const categorySchema = z.object({ nameEn: z.string().trim().min(1).max(100), nameEs: z.string().trim().min(1).max(100), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6B7280'), sortOrder: z.number().int().min(0).max(10000).default(0), archived: z.boolean().default(false), }); financeGlobalRouter.get('/settings/expense-categories', TEMPLATE_READERS, async (c) => { const rows = await dbAll((db as any).select().from(expenseCategories)); return c.json({ categories: rows.map(serializeCategory).sort((a, b) => a.sortOrder - b.sortOrder) }); }); financeGlobalRouter.post('/settings/expense-categories', ADMIN, zValidator('json', categorySchema, validationHook), async (c) => { const data = c.req.valid('json'); const now = getNow(); const values = { id: generateId(), ...data, archived: toDbBool(data.archived), createdAt: now, updatedAt: now }; await runOps([ insertOp(expenseCategories, values), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: values.id, action: 'create', after: serializeCategory(values) }), ]); return c.json({ category: serializeCategory(values) }, 201); }); financeGlobalRouter.put('/settings/expense-categories/:id', ADMIN, zValidator('json', categorySchema.partial(), validationHook), async (c) => { const existing = await dbGet((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, c.req.param('id')))); if (!existing) return c.json({ error: 'Category not found' }, 404); const data = c.req.valid('json'); const updates: Record = { ...data, updatedAt: getNow() }; if (data.archived !== undefined) updates.archived = toDbBool(data.archived); const after = serializeCategory({ ...existing, ...updates }); await runOps([ updateOp(expenseCategories, updates, eq((expenseCategories as any).id, existing.id)), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: existing.id, action: 'update', before: serializeCategory(existing), after }), ]); return c.json({ category: after }); }); financeGlobalRouter.delete('/settings/expense-categories/:id', ADMIN, async (c) => { const id = c.req.param('id')!; const existing = await dbGet((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, id))); if (!existing) return c.json({ error: 'Category not found' }, 404); const [usedByExpense, usedByTemplate] = await Promise.all([ dbGet((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))), dbGet((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(eq((expenseTemplates as any).categoryId, id))), ]); if (usedByExpense || usedByTemplate) { return c.json({ error: 'This category is in use. Archive it instead.', code: 'IN_USE' }, 409); } await runOps([ deleteOp(expenseCategories, eq((expenseCategories as any).id, id)), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: id, action: 'delete', before: serializeCategory(existing) }), ]); return c.json({ message: 'Category deleted' }); }); // ==================== Templates ==================== const serializeTemplate = (r: any) => ({ id: r.id, name: r.name, categoryId: r.categoryId ?? null, description: r.description ?? null, calcType: r.calcType, amount: pyg(r.amount), percentBp: pyg(r.percentBp), minimumAmount: pyg(r.minimumAmount), archived: bool(r.archived), createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt), }); const templateSchema = z.object({ name: z.string().trim().min(1).max(200), categoryId: z.string().nullable().optional(), description: z.string().trim().max(1000).nullable().optional(), calcType: z.enum(CALC_TYPES), amount: money.default(0), percentBp: bp.default(0), minimumAmount: money.default(0), archived: z.boolean().default(false), }); async function validCategory(categoryId: string | null | undefined) { if (!categoryId) return true; return !!(await dbGet((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId)))); } financeGlobalRouter.get('/settings/expense-templates', TEMPLATE_READERS, async (c) => { const rows = await dbAll((db as any).select().from(expenseTemplates)); return c.json({ templates: rows.map(serializeTemplate).sort((a, b) => a.name.localeCompare(b.name)) }); }); financeGlobalRouter.post('/settings/expense-templates', ADMIN, zValidator('json', templateSchema, validationHook), async (c) => { const data = c.req.valid('json'); if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400); const now = getNow(); const values = { id: generateId(), ...data, categoryId: data.categoryId || null, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now, }; await runOps([ insertOp(expenseTemplates, values), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: values.id, action: 'create', after: serializeTemplate(values) }), ]); return c.json({ template: serializeTemplate(values) }, 201); }); financeGlobalRouter.put('/settings/expense-templates/:id', ADMIN, zValidator('json', templateSchema.partial(), validationHook), async (c) => { const existing = await dbGet((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, c.req.param('id')))); if (!existing) return c.json({ error: 'Template not found' }, 404); const data = c.req.valid('json'); if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400); const updates: Record = { ...data, updatedAt: getNow() }; if (data.archived !== undefined) updates.archived = toDbBool(data.archived); if (data.categoryId === '') updates.categoryId = null; const after = serializeTemplate({ ...existing, ...updates }); await runOps([ updateOp(expenseTemplates, updates, eq((expenseTemplates as any).id, existing.id)), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: existing.id, action: 'update', before: serializeTemplate(existing), after }), ]); return c.json({ template: after }); }); financeGlobalRouter.delete('/settings/expense-templates/:id', ADMIN, async (c) => { const id = c.req.param('id')!; const existing = await dbGet((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, id))); if (!existing) return c.json({ error: 'Template not found' }, 404); const [usedByExpense, usedByPack] = await Promise.all([ dbGet((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))), dbGet((db as any).select({ id: (expenseTemplatePackItems as any).id }).from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).templateId, id))), ]); if (usedByExpense || usedByPack) { return c.json({ error: 'This template has been used or is in a pack. Archive it instead.', code: 'IN_USE' }, 409); } await runOps([ deleteOp(expenseTemplates, eq((expenseTemplates as any).id, id)), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: id, action: 'delete', before: serializeTemplate(existing) }), ]); return c.json({ message: 'Template deleted' }); }); // ==================== Template packs ==================== const packSchema = z.object({ name: z.string().trim().min(1).max(200), description: z.string().trim().max(1000).nullable().optional(), archived: z.boolean().default(false), templateIds: z.array(z.string()).max(50).default([]), }); async function loadPacks() { const [packs, items] = await Promise.all([ dbAll((db as any).select().from(expenseTemplatePacks)), dbAll((db as any).select().from(expenseTemplatePackItems)), ]); return packs .map((p: any) => ({ id: p.id, name: p.name, description: p.description ?? null, archived: bool(p.archived), templateIds: items.filter((i: any) => i.packId === p.id).sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId), createdAt: iso(p.createdAt), updatedAt: iso(p.updatedAt), })) .sort((a, b) => a.name.localeCompare(b.name)); } async function validTemplates(ids: string[]) { if (ids.length === 0) return true; const rows = await dbAll((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(inArray((expenseTemplates as any).id, ids))); return rows.length === new Set(ids).size; } const itemOps = (packId: string, templateIds: string[]): TxOp[] => [...new Set(templateIds)].map((templateId, i) => insertOp(expenseTemplatePackItems, { id: generateId(), packId, templateId, sortOrder: i })); financeGlobalRouter.get('/settings/expense-template-packs', TEMPLATE_READERS, async (c) => { return c.json({ packs: await loadPacks() }); }); financeGlobalRouter.post('/settings/expense-template-packs', ADMIN, zValidator('json', packSchema, validationHook), async (c) => { const data = c.req.valid('json'); if (!(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400); const now = getNow(); const values = { id: generateId(), name: data.name, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now }; await runOps([ insertOp(expenseTemplatePacks, values), ...itemOps(values.id, data.templateIds), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: values.id, action: 'create', after: { ...data } }), ]); const pack = (await loadPacks()).find((p) => p.id === values.id); return c.json({ pack }, 201); }); financeGlobalRouter.put('/settings/expense-template-packs/:id', ADMIN, zValidator('json', packSchema.partial(), validationHook), async (c) => { const id = c.req.param('id'); const before = (await loadPacks()).find((p) => p.id === id); if (!before) return c.json({ error: 'Pack not found' }, 404); const data = c.req.valid('json'); if (data.templateIds && !(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400); const updates: Record = { updatedAt: getNow() }; if (data.name !== undefined) updates.name = data.name; if (data.description !== undefined) updates.description = data.description || null; if (data.archived !== undefined) updates.archived = toDbBool(data.archived); const ops: TxOp[] = [updateOp(expenseTemplatePacks, updates, eq((expenseTemplatePacks as any).id, id))]; if (data.templateIds) { ops.push(deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id))); ops.push(...itemOps(id, data.templateIds)); } ops.push(financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'update', before, after: { ...before, ...data } })); await runOps(ops); const pack = (await loadPacks()).find((p) => p.id === id); return c.json({ pack }); }); financeGlobalRouter.delete('/settings/expense-template-packs/:id', ADMIN, async (c) => { const id = c.req.param('id')!; const before = (await loadPacks()).find((p) => p.id === id); if (!before) return c.json({ error: 'Pack not found' }, 404); await runOps([ deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)), deleteOp(expenseTemplatePacks, eq((expenseTemplatePacks as any).id, id)), financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'delete', before }), ]); return c.json({ message: 'Pack deleted' }); }); // ==================== Payment method fees ==================== financeGlobalRouter.get('/settings/payment-fees', ADMIN, async (c) => { return c.json({ fees: await loadFeeRules() }); }); const feeSchema = z.object({ percentBp: bp, fixedAmount: money }); const FEE_METHODS = ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos', 'bancard'] as const; financeGlobalRouter.put('/settings/payment-fees/:method', ADMIN, zValidator('json', feeSchema, validationHook), async (c) => { const method = c.req.param('method'); if (!(FEE_METHODS as readonly string[]).includes(method)) return c.json({ error: 'Unknown payment method' }, 400); const data = c.req.valid('json'); const existing = await dbGet((db as any).select().from(paymentMethodFees).where(eq((paymentMethodFees as any).method, method))); const user = currentUser(c); const values = { percentBp: data.percentBp, fixedAmount: data.fixedAmount, updatedAt: getNow(), updatedBy: user.id }; await runOps([ existing ? updateOp(paymentMethodFees, values, eq((paymentMethodFees as any).method, method)) : insertOp(paymentMethodFees, { method, ...values }), financeAuditOp({ eventId: null, actorUserId: user.id, entityType: 'payment_fee', entityId: method, action: existing ? 'update' : 'create', before: existing ? { percentBp: pyg(existing.percentBp), fixedAmount: pyg(existing.fixedAmount) } : null, after: data, }), ]); return c.json({ fee: { method, ...data } }); }); // ==================== Cross-event overview ==================== type Totals = { events: number; gross: number; fees: number; net: number; expenses: number; profit: number }; const emptyTotals = (): Totals => ({ events: 0, gross: 0, fees: 0, net: 0, expenses: 0, profit: 0 }); const addTo = (t: Totals, row: Omit) => { t.events += 1; t.gross += row.gross; t.fees += row.fees; t.net += row.net; t.expenses += row.expenses; t.profit += row.profit; }; /** * Profit per event, per series, per venue and payouts per partner. Finalized * events use their snapshot. `readyToClose` lists past events whose books are * still open (with or without any money recorded), longest-waiting first. Filters: from / to (ISO dates on the event start), * series, venue (exact location text), partner (user id or external name). */ financeGlobalRouter.get('/overview', ADMIN, async (c) => { const isDate = (v?: string) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined); const from = isDate(c.req.query('from')); const to = isDate(c.req.query('to')); const seriesFilter = c.req.query('series'); const venueFilter = c.req.query('venue'); const partnerFilter = c.req.query('partner'); const conditions: any[] = []; if (from) conditions.push(gte((events as any).startDatetime, toDbDate(from))); if (to) conditions.push(lte((events as any).startDatetime, toDbDate(`${to}T23:59:59.999Z`))); const allEvents = await dbAll( (db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined) ); const rows: any[] = []; const seriesOptions = new Set(); const venueOptions = new Set(); const partnerOptions = new Map(); const bySeries = new Map(); const byVenue = new Map(); const byPartner = new Map(); const totals = emptyTotals(); // Past events whose books are still open, including ones with no money in // or out yet (those are left out of `events` and the totals). const readyToClose: any[] = []; const now = Date.now(); for (const ev of allEvents) { if (ev.status === 'draft') continue; const fin = await getEventFinance(ev.id, ev); if (!fin) continue; const r = fin.result; const hasMoney = !(r.revenue.gross === 0 && r.expenses.total === 0 && r.revenue.otherIncome === 0 && fin.partners.length === 0); const ended = new Date(ev.endDatetime || ev.startDatetime).getTime() <= now; const ready = fin.state.status === 'open' && ended; // Nothing to report for events with no money in or out, unless they still need closing. if (!hasMoney && !ready) continue; const series = ev.series || null; const venue = (ev.location || '').trim(); const partnerKeys = fin.partners.map((p) => p.userId || `name:${p.name}`); if (hasMoney) { if (series) seriesOptions.add(series); if (venue) venueOptions.add(venue); fin.partners.forEach((p, i) => partnerOptions.set(partnerKeys[i], p.name)); } if (seriesFilter && (seriesFilter === '__none__' ? series !== null : series !== seriesFilter)) continue; if (venueFilter && venue !== venueFilter) continue; if (partnerFilter && !partnerKeys.includes(partnerFilter)) continue; const row = { gross: r.revenue.gross, fees: r.revenue.fees, net: r.revenue.net, expenses: r.expenses.total, profit: r.profit }; const eventRow = { id: ev.id, title: ev.title, titleEs: ev.titleEs ?? null, startDatetime: iso(ev.startDatetime), endDatetime: ev.endDatetime ? iso(ev.endDatetime) : null, series, location: venue, status: ev.status, financeStatus: fin.state.status, ticketsSold: r.counts.ticketsSold, ...row, organization: r.split.organization, }; if (ready) readyToClose.push(eventRow); if (!hasMoney) continue; rows.push(eventRow); addTo(totals, row); const sKey = series || ''; if (!bySeries.has(sKey)) bySeries.set(sKey, emptyTotals()); addTo(bySeries.get(sKey)!, row); if (!byVenue.has(venue)) byVenue.set(venue, emptyTotals()); addTo(byVenue.get(venue)!, row); fin.partners.forEach((p, i) => { const key = partnerKeys[i]; const line = r.split.partners.find((x) => x.partnerId === p.id); const agg = byPartner.get(key) || { key, name: p.name, userId: p.userId, events: 0, share: 0, reimbursement: 0, payout: 0, paid: 0, pending: 0 }; agg.events += 1; agg.share += line?.share ?? 0; agg.reimbursement += line?.reimbursement ?? 0; agg.payout += line?.payout ?? 0; if (p.payoutStatus === 'paid') agg.paid += line?.payout ?? 0; else agg.pending += line?.payout ?? 0; byPartner.set(key, agg); }); } rows.sort((a, b) => (b.startDatetime || '').localeCompare(a.startDatetime || '')); // Longest-waiting first. readyToClose.sort((a, b) => (a.startDatetime || '').localeCompare(b.startDatetime || '')); const sortByProfit = (xs: T[]) => xs.sort((a, b) => b.profit - a.profit); return c.json({ totals, events: rows, readyToClose, bySeries: sortByProfit([...bySeries.entries()].map(([series, t]) => ({ series: series || null, ...t }))), byVenue: sortByProfit([...byVenue.entries()].map(([venue, t]) => ({ venue, ...t }))), byPartner: [...byPartner.values()].sort((a, b) => b.payout - a.payout), filters: { series: [...seriesOptions].sort(), venues: [...venueOptions].sort(), partners: [...partnerOptions.entries()].map(([key, name]) => ({ key, name })).sort((a, b) => a.name.localeCompare(b.name)), }, }); }); export default financeGlobalRouter;