const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const { safeErrorMessage } = require('../utils/errorUtils'); 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 // @access Private/Supervisor const getEventCashup = async (req, res) => { try { const financials = await computeEventFinancials(req.params.eventId); res.json(financials); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @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 const saveEventCashupDraft = async (req, res) => { try { const { eventId } = req.params; const { lines, notes } = req.body; await assertEventOpen(eventId, res); await prisma.event.update({ where: { id: eventId }, data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } } }); res.json({ message: 'Draft saved' }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Close an event, optionally with a full per-method cashup // @route POST /api/cashups/event/:eventId/close // @access Private/Admin const closeEvent = async (req, res) => { try { const { eventId } = req.params; const { lines, notes } = req.body; await assertEventOpen(eventId, res); const financials = await computeEventFinancials(eventId); const isFullCashup = Array.isArray(lines) && lines.length > 0; 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 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 }; }); // 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) : null; const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0); const cashup = await prisma.eventCashup.create({ data: { id: uuidv4(), eventId, action: isFullCashup ? 'closed' : 'quick_closed', unallocatedDonationsTotal: financials.unallocatedDonationsTotal, totalCosts: financials.totalCosts, totalExpectedRevenue, totalActualRevenue, notes: notes || null, performedById: req.user.id, lines: { create: cashupLines } }, include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } } }); await prisma.event.update({ where: { id: eventId }, data: { cashupStatus: 'closed', cashupDraft: null, closedAt: new Date(), closedById: req.user.id } }); res.status(201).json(cashup); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Reopen a closed event (admin only) // @route POST /api/cashups/event/:eventId/reopen // @access Private/Admin const reopenEvent = async (req, res) => { try { const { eventId } = req.params; const { notes } = req.body; const event = await prisma.event.findUnique({ where: { id: eventId } }); if (!event) { res.status(404); throw new Error('Event not found'); } if (event.cashupStatus !== 'closed') { res.status(400); throw new Error('Event is not closed'); } const auditRow = await prisma.eventCashup.create({ data: { id: uuidv4(), eventId, action: 'reopened', notes: notes || null, performedById: req.user.id }, include: { performedBy: { select: { id: true, name: true, email: true } } } }); await prisma.event.update({ where: { id: eventId }, data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id } }); res.status(201).json(auditRow); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Flat audit log of every close/quick-close/reopen, optionally filtered // @route GET /api/cashups/audit // @access Private/Supervisor const getCashupAudit = async (req, res) => { try { const { eventId, from, to } = req.query; const where = {}; if (eventId) where.eventId = eventId; if (from || to) { where.createdAt = {}; if (from) where.createdAt.gte = new Date(from); if (to) where.createdAt.lte = new Date(to); } const rows = await prisma.eventCashup.findMany({ where, include: { event: { select: { id: true, title: true } }, performedBy: { select: { id: true, name: true, email: true } }, lines: { include: { denominations: true } } }, orderBy: { createdAt: 'desc' } }); res.json(rows); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; module.exports = { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit };