UserCampusScope relation and audit action; scopeEvent/scopeList/unscopedOnly/ campusIndependent/scopeBroadcast markers; tests/campusScopeRoutes.test.js (still failing: events, payments, registrations, scan locks, tickets left). Can't delete a campus that has scoped staff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
13 KiB
JavaScript
332 lines
13 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { ALL_METHODS, WRITE_OFF_CATEGORIES, PENDING_OUTCOMES, settleLine, applyPendingResolutions, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
|
|
|
|
// What the cashup screen sends per method: where the money ended up and what was written off.
|
|
function cleanDraftLine(l) {
|
|
return {
|
|
method: l.method,
|
|
bankAmount: l.bankAmount ?? null,
|
|
onHandAmount: l.onHandAmount ?? null,
|
|
pendingAmount: l.pendingAmount ?? null,
|
|
pendingReference: l.pendingReference || null,
|
|
writeOffs: (Array.isArray(l.writeOffs) ? l.writeOffs : []).map(w => ({ category: w.category, amount: w.amount ?? null, reason: w.reason || null })),
|
|
notes: l.notes || null
|
|
};
|
|
}
|
|
|
|
// @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 : []).filter(l => l && ALL_METHODS.includes(l.method)).map(cleanDraftLine), 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;
|
|
|
|
// Each method says where its expected money ended up (see settleLine). Cash is never a
|
|
// single event-wide figure: when staff counted it, the count is the cash there is, sourced
|
|
// here from the per-person counts rather than from whatever the frontend sent.
|
|
let cashupLines = [];
|
|
if (isFullCashup) {
|
|
const { actual: cashCounted, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
|
|
cashupLines = ALL_METHODS.map(method => {
|
|
const input = lines.find(l => l && l.method === method) || {};
|
|
const expected = financials.expectedCashByMethod[method] || 0;
|
|
const settled = settleLine({ method, expected, counted: method === 'cash' ? cashCounted : null, input });
|
|
return {
|
|
id: uuidv4(),
|
|
method,
|
|
expectedAmount: expected,
|
|
actualAmount: settled.actual,
|
|
variance: settled.variance,
|
|
notes: input.notes || null,
|
|
bankAmount: settled.bankAmount,
|
|
onHandAmount: settled.onHandAmount,
|
|
pendingAmount: settled.pendingAmount,
|
|
pendingReference: settled.pendingReference,
|
|
writeOffTotal: settled.writeOffTotal,
|
|
writeOffs: settled.writeOffs.length > 0 ? { create: settled.writeOffs } : undefined,
|
|
denominations: method === 'cash' && cashDenominations.length > 0 ? { create: cashDenominations } : undefined
|
|
};
|
|
});
|
|
}
|
|
|
|
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, writeOffs: 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 } } }
|
|
});
|
|
|
|
// Start the next cashup from the last one, with pending money that has since been
|
|
// received or written off folded in, so nothing has to be typed in again.
|
|
const lastClose = await prisma.eventCashup.findFirst({
|
|
where: { eventId, action: { in: ['closed', 'quick_closed'] } },
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { lines: { include: { writeOffs: true, pendingResolutions: true } } }
|
|
});
|
|
const cashupDraft = lastClose && lastClose.lines.length > 0 ? {
|
|
lines: lastClose.lines.map(l => {
|
|
const settled = applyPendingResolutions(l);
|
|
return cleanDraftLine({
|
|
method: l.method,
|
|
bankAmount: settled.bank,
|
|
onHandAmount: settled.onHand,
|
|
pendingAmount: settled.pendingOutstanding || null,
|
|
pendingReference: settled.pendingOutstanding ? settled.pendingReference : null,
|
|
writeOffs: settled.writeOffs,
|
|
notes: l.notes
|
|
});
|
|
}),
|
|
notes: null,
|
|
savedAt: new Date().toISOString()
|
|
} : undefined;
|
|
|
|
await prisma.event.update({
|
|
where: { id: eventId },
|
|
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id, ...(cashupDraft ? { cashupDraft } : {}) }
|
|
});
|
|
|
|
res.status(201).json(auditRow);
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc Settle money that was pending when the event was closed (on the card report, or a
|
|
// POP we hadn't seen in the bank yet): it has now reached the bank, or it never will and
|
|
// is written off. Allowed while the event is closed — no reopen needed.
|
|
// @route POST /api/cashups/lines/:lineId/pending
|
|
// @access Private/Admin
|
|
const resolvePending = async (req, res) => {
|
|
try {
|
|
const { lineId } = req.params;
|
|
const { outcome, amount, category, notes } = req.body;
|
|
|
|
if (!PENDING_OUTCOMES.includes(outcome)) {
|
|
res.status(400);
|
|
throw new Error('Say whether the money was received or written off');
|
|
}
|
|
const value = Math.round(Number(amount) * 100) / 100;
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
res.status(400);
|
|
throw new Error('Enter an amount more than R0');
|
|
}
|
|
|
|
const resolution = await prisma.$transaction(async (tx) => {
|
|
// Lock the line so two people settling at once can't settle more than is pending.
|
|
await tx.$executeRaw`SELECT 1 FROM "EventCashupLine" WHERE id = ${lineId} FOR UPDATE`;
|
|
const line = await tx.eventCashupLine.findUnique({
|
|
where: { id: lineId },
|
|
include: { cashup: { select: { id: true, eventId: true } }, pendingResolutions: true }
|
|
});
|
|
if (!line) {
|
|
res.status(404);
|
|
throw new Error('Cashup line not found');
|
|
}
|
|
const latest = await tx.eventCashup.findFirst({ where: { eventId: line.cashup.eventId }, orderBy: { createdAt: 'desc' }, select: { id: true } });
|
|
if (!latest || latest.id !== line.cashupId) {
|
|
res.status(400);
|
|
throw new Error('Only the current cashup of a closed event can be updated');
|
|
}
|
|
const { pendingOutstanding } = applyPendingResolutions(line);
|
|
if (value > pendingOutstanding + 0.005) {
|
|
res.status(400);
|
|
throw new Error(`Only R${pendingOutstanding.toFixed(2)} is still pending`);
|
|
}
|
|
return tx.eventCashupPendingResolution.create({
|
|
data: {
|
|
lineId,
|
|
outcome,
|
|
amount: value,
|
|
category: outcome === 'written_off' ? (WRITE_OFF_CATEGORIES.includes(category) ? category : 'other') : null,
|
|
notes: notes ? String(notes).trim().slice(0, 200) : null,
|
|
performedById: req.user.id
|
|
},
|
|
include: { performedBy: { select: { id: true, name: true } } }
|
|
});
|
|
});
|
|
|
|
res.status(201).json(resolution);
|
|
} 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;
|
|
// Campus-scoped supervisors only see cashups they could do: their campuses' events.
|
|
const scope = await require('../utils/campusScope').scopeFor(req);
|
|
|
|
const where = {};
|
|
if (eventId) where.eventId = eventId;
|
|
if (scope) where.event = require('../utils/campusScope').manageWhere(scope);
|
|
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,
|
|
resolvePending,
|
|
getCashupAudit
|
|
};
|