Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page

Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
+68 -16
View File
@@ -1,7 +1,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => {
}
};
// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/
// other) — cash also includes the live actual-vs-expected from per-person counts.
// @route GET /api/cashups/event/:eventId/cash-by-user
// @access Private/Supervisor
const getCashByRecordedUser = async (req, res) => {
try {
const [rows, cash] = await Promise.all([
computeAccountabilityByUser(req.params.eventId),
computeEventCashActualFromPersonCounts(req.params.eventId)
]);
res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Enter (or update) one staff member's actual physical cash count for an event —
// optional, can be done any time, purely for per-person accountability. Never blocks
// or is required for closing the event.
// @route PUT /api/cashups/event/:eventId/person-cash/:userId
// @access Private/Supervisor
const savePersonCash = async (req, res) => {
try {
const { eventId, userId } = req.params;
const { denominations, notes } = req.body;
const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!targetUser) {
res.status(404);
throw new Error('User not found');
}
const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id });
res.json(record);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Save in-progress reconciliation entries without closing the event
// @route PUT /api/cashups/event/:eventId/draft
// @access Private/Admin
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
const cashupLines = isFullCashup
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0)
: [];
const actual = denominations.length > 0
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
denominations: denominations.length > 0 ? { create: denominations } : undefined
notes: l.notes || null
};
})
: [];
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
}
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
module.exports = {
getEventCashup,
getCashByRecordedUser,
savePersonCash,
saveEventCashupDraft,
closeEvent,
reopenEvent,