Newer hono drops path-param typing on routes with a single untyped
middleware, so c.req.param('id') widens to string | undefined. Assert
the always-present :id param in the affected finance handlers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
21 KiB
TypeScript
408 lines
21 KiB
TypeScript
// 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<any>((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<any>((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<string, any> = { ...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<any>((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<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))),
|
|
dbGet<any>((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<any>((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<any>((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<any>((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<string, any> = { ...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<any>((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<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))),
|
|
dbGet<any>((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<any>((db as any).select().from(expenseTemplatePacks)),
|
|
dbAll<any>((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<any>((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<string, any> = { 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<any>((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<Totals, 'events'>) => {
|
|
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<any>(
|
|
(db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined)
|
|
);
|
|
|
|
const rows: any[] = [];
|
|
const seriesOptions = new Set<string>();
|
|
const venueOptions = new Set<string>();
|
|
const partnerOptions = new Map<string, string>();
|
|
const bySeries = new Map<string, Totals>();
|
|
const byVenue = new Map<string, Totals>();
|
|
const byPartner = new Map<string, { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }>();
|
|
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 = <T extends { profit: number }>(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;
|