Files
hope-events/backend/src/utils/cashupUtils.js
T
joshuaandClaude Opus 5.5 7446388bfa Take cash deposit fees out of the counted cash
A bank charge on a cash deposit comes out of cash that was counted and
banked, so cash on hand is now the count less what reached the bank and
any bank-charge write-off. Before, a deposit fee showed as money "more
than expected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:17 +02:00

518 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const prisma = require('../config/db');
const METHOD_BUCKETS = ['cash', 'card', 'eft'];
const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
// Standard South African Rand note/coin denominations used for the cash count grid.
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
const WRITE_OFF_CATEGORIES = ['card_fees', 'bank_charges', 'cash_shortfall', 'other'];
const PENDING_OUTCOMES = ['received', 'written_off'];
function emptyByMethod(fill = 0) {
return { cash: fill, card: fill, eft: fill, other: fill };
}
const cents = (n) => Math.round(n * 100) / 100;
const sum = (rows) => rows.reduce((s, r) => s + r.amount, 0);
// A non-negative amount rounded to cents, or null when left blank.
function amountOrNull(value, label) {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or more`);
return cents(n);
}
/**
* Where one method's expected takings ended up, as entered on the cashup screen. Pure.
*
* actual = in bank + on hand + pending (money held or on its way); write-offs explain money
* that's gone (card fees, bank charges, a cash shortfall). Whatever is left —
* expected − actual − write-offs — is unaccounted for. Cash has no pending; when staff
* counted it (per-person counts), the count is all the cash there is, so whatever wasn't
* deposited (or taken by the bank as a deposit fee) is on hand.
*
* @param {object} p
* @param {string} p.method - 'cash' | 'card' | 'eft' | 'other'
* @param {number} p.expected - expected amount for the method (payments − costs paid from it)
* @param {number|null} [p.counted] - cash counted by staff, or null when nobody counted
* @param {object} [p.input] - { bankAmount, onHandAmount, pendingAmount, pendingReference, writeOffs: [{ category, amount, reason }] }
*/
function settleLine({ method, expected, counted = null, input = {} }) {
const bankAmount = amountOrNull(input.bankAmount, 'In bank');
let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null;
const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending');
const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : [])
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
.map(w => {
if (!WRITE_OFF_CATEGORIES.includes(w.category)) throw new Error('Choose what each write-off is for');
const amount = amountOrNull(w.amount, 'Write-off');
if (!(amount > 0)) throw new Error('A write-off must be more than R0');
return { category: w.category, amount, reason: String(w.reason || '').trim().slice(0, 200) || null };
});
const writeOffTotal = cents(sum(writeOffs));
if (method === 'cash' && counted !== null && counted !== undefined) {
// A deposit fee comes out of cash that was counted and banked, so the count is what
// reached the bank + the fee + what's still on hand.
const depositFees = cents(sum(writeOffs.filter(w => w.category === 'bank_charges')));
if ((bankAmount || 0) + depositFees > counted + 0.005) throw new Error('More cash was deposited than was counted');
onHandAmount = cents(counted - (bankAmount || 0) - depositFees);
}
const parts = [bankAmount, onHandAmount, pendingAmount];
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
return {
bankAmount,
onHandAmount,
pendingAmount,
pendingReference: pendingAmount ? (String(input.pendingReference || '').trim().slice(0, 200) || null) : null,
writeOffs,
writeOffTotal,
actual,
variance: actual === null ? null : cents(actual - expected),
unaccounted: entered ? cents(expected - actual - writeOffTotal) : null,
};
}
/**
* A closed cashup line with any later pending resolutions folded in. Pure.
* 'received' moves pending money into the bank (actual unchanged); 'written_off' turns it
* into a write-off (actual goes down). Lines from before settlement existed have only
* actualAmount, so their unaccounted is the old variance.
*/
function applyPendingResolutions(line) {
const resolutions = line.pendingResolutions || [];
const received = cents(sum(resolutions.filter(r => r.outcome === 'received')));
const lateWriteOffs = resolutions.filter(r => r.outcome === 'written_off');
const writtenOff = cents(sum(lateWriteOffs));
const pending = line.pendingAmount || 0;
const actual = line.actualAmount === null || line.actualAmount === undefined ? null : cents(line.actualAmount - writtenOff);
const writeOffTotal = cents((line.writeOffTotal || 0) + writtenOff);
const expected = line.expectedAmount || 0;
return {
bank: line.bankAmount == null && received === 0 ? null : cents((line.bankAmount || 0) + received),
onHand: line.onHandAmount ?? null,
pending: line.pendingAmount ?? null,
pendingReference: line.pendingReference || null,
pendingReceived: received,
pendingOutstanding: cents(Math.max(pending - received - writtenOff, 0)),
writeOffs: [
...(line.writeOffs || []).map(w => ({ id: w.id, category: w.category, amount: w.amount, reason: w.reason, createdAt: w.createdAt, afterClose: false })),
...lateWriteOffs.map(r => ({ id: r.id, category: r.category || 'other', amount: r.amount, reason: r.notes, createdAt: r.createdAt, afterClose: true })),
],
writeOffTotal,
actual,
variance: actual === null ? null : cents(actual - expected),
unaccounted: actual === null && writeOffTotal === 0 ? null : cents(expected - (actual || 0) - writeOffTotal),
};
}
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
function bucketForMethod(method) {
const m = String(method || '').toLowerCase();
if (m.includes('cash')) return 'cash';
if (m.includes('eft')) return 'eft';
if (m.includes('card') || m.includes('yoco') || m.includes('pay')) return 'card';
return 'other';
}
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Revenue/cash totals must count each real inflow exactly once, so legs are
// excluded everywhere money is summed — the money was already counted via the donation itself.
// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.)
function isDonationLeg(p) {
return !p.isDonation && !!p.originalPaymentId && p.amount > 0;
}
// Throws if the event is closed. Callers wrap this in their existing try/catch
// (res.statusCode is set before throwing, matching the rest of the controllers).
async function assertEventOpen(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, cashupStatus: true } });
if (!event) {
if (res) res.status(404);
throw new Error('Event not found');
}
if (event.cashupStatus === 'closed') {
if (res) res.status(400);
throw new Error('This event is closed. Reopen it (admin only) before making changes.');
}
return event;
}
// Same check, but resolves the event via a registrationId first (for payment/registration flows
// that receive a registrationId rather than an eventId directly).
async function assertRegistrationEventOpen(registrationId, res) {
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, select: { eventId: true } });
if (!registration) {
if (res) res.status(404);
throw new Error('Registration not found');
}
await assertEventOpen(registration.eventId, res);
return registration;
}
// Core computation shared by the cashup preview/close endpoints and the Cashup/Finance/Profit reports.
//
// Two figures must never be conflated: "revenue" (gross, profit-relevant) and "expected cash"
// (net of any costs paid out of a method's float, for physically verifying a drawer/float).
// Mixing them up would double-subtract method-tagged costs from profit.
async function computeEventFinancials(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, cashupStatus: true, cashupDraft: true, closedAt: true, closedById: true, reopenedAt: true, reopenedById: true }
});
if (!event) {
throw new Error('Event not found');
}
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] }
}),
prisma.eventCost.findMany({ where: { eventId }, include: { eventOption: { select: { id: true, name: true } } } }),
prisma.ticket.findMany({
where: { eventId },
include: { registrationOption: { select: { eventOptionId: true } } }
}),
prisma.registrationOption.findMany({
where: { registration: { eventId, status: 'paid' } },
include: { eventOption: { select: { id: true, name: true, price: true } }, tranches: true }
}),
prisma.eventCashup.findMany({
where: { eventId },
include: {
lines: {
include: {
denominations: true,
writeOffs: { orderBy: { createdAt: 'asc' } },
pendingResolutions: { include: { performedBy: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }
}
},
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
}),
// Coupon discounts on the same paid registrations salesByOption counts.
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
]);
// Ticket quantity sold per EventOption (used to price per-item costs)
const quantityByOption = {};
for (const t of tickets) {
const optId = t.registrationOption?.eventOptionId;
if (!optId) continue;
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
// Excludes donation-application legs, which would otherwise double-count money already
// counted once via the source donation (e.g. a R250 donation with R50 assigned to a
// registration must total R250 received, not R300). Refunds (negative amount) are kept in —
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
// the per-method breakdown while still being netted out of totalRevenue below.
const realPayments = payments.filter(p => !isDonationLeg(p));
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
// stays null forever. Its actual unallocated amount is its original amount minus every leg
// that already references it, not simply "every donation with no registrationId". A refund
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
// subtract a negative, adding the refund back on top).
const legsByDonationId = new Map();
for (const p of payments) {
if (p.originalPaymentId && !p.isDonation) {
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
}
}
const donationPayments = payments.filter(p => p.isDonation);
const unallocatedDonations = donationPayments.filter(p => (p.amount - (legsByDonationId.get(p.id) || 0)) > 0.000001);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + Math.max(p.amount - (legsByDonationId.get(p.id) || 0), 0), 0);
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of realPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
const total = c.costType === 'per_item'
? c.amount * (quantityByOption[c.eventOptionId] || 0)
: c.amount;
return { ...c, total };
});
const costsByMethod = emptyByMethod();
let untaggedCostsTotal = 0;
for (const c of costBreakdown) {
if (c.paidFromMethod && ALL_METHODS.includes(c.paidFromMethod)) {
costsByMethod[c.paidFromMethod] += c.total;
} else {
untaggedCostsTotal += c.total;
}
}
const totalCosts = untaggedCostsTotal + ALL_METHODS.reduce((s, m) => s + costsByMethod[m], 0);
// What should physically be on hand per method, after known payouts from that float
const expectedCashByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
// Most recent reconciliation on record (if any) — the source of "actual" truth
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
const reconciled = latestReconciled ? {
id: latestReconciled.id,
action: latestReconciled.action,
createdAt: latestReconciled.createdAt,
performedBy: latestReconciled.performedBy,
notes: latestReconciled.notes,
byMethod: (() => {
const out = {};
for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m);
const settled = line ? applyPendingResolutions(line) : null;
out[m] = {
lineId: line ? line.id : null,
expected: line ? line.expectedAmount : expectedCashByMethod[m],
actual: settled ? settled.actual : null,
variance: settled ? settled.variance : null,
notes: line ? line.notes : null,
denominations: line ? line.denominations : [],
bank: settled ? settled.bank : null,
onHand: settled ? settled.onHand : null,
pending: settled ? settled.pending : null,
pendingReference: settled ? settled.pendingReference : null,
pendingReceived: settled ? settled.pendingReceived : 0,
pendingOutstanding: settled ? settled.pendingOutstanding : 0,
pendingResolutions: line ? line.pendingResolutions : [],
writeOffs: settled ? settled.writeOffs : [],
writeOffTotal: settled ? settled.writeOffTotal : 0,
unaccounted: settled ? settled.unaccounted : null
};
}
return out;
})()
} : null;
// Money written off at (or after) the cashup: card fees, bank charges, shortfalls.
const writeOffsByMethod = emptyByMethod();
const writeOffsByCategory = Object.fromEntries(WRITE_OFF_CATEGORIES.map(c => [c, 0]));
let pendingOutstandingTotal = 0;
for (const m of ALL_METHODS) {
const r = reconciled?.byMethod?.[m];
if (!r) continue;
writeOffsByMethod[m] = r.writeOffTotal;
pendingOutstandingTotal += r.pendingOutstanding;
for (const w of r.writeOffs) writeOffsByCategory[w.category] = cents((writeOffsByCategory[w.category] || 0) + w.amount);
}
const totalWriteOffs = cents(ALL_METHODS.reduce((s, m) => s + writeOffsByMethod[m], 0));
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
// Tagged costs and write-offs are added back so income stays a *gross* figure — costs and
// write-offs each come off profit exactly once, below, never twice. (Unaccounted money
// isn't added back: it lowers income, like any variance.)
const effectiveGrossIncomeByMethod = emptyByMethod();
for (const m of ALL_METHODS) {
const actual = reconciled?.byMethod?.[m]?.actual;
const base = actual != null ? actual + writeOffsByMethod[m] : expectedCashByMethod[m];
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
}
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts - totalWriteOffs;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
// at the price it was actually bought at, not one blended/stale price for the whole line.
const { computeOptionLineTotal } = require('./pricing');
const salesByOptionMap = {};
for (const ro of salesRows) {
const opt = ro.eventOption;
if (!opt) continue;
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
salesByOptionMap[opt.id].quantitySold += ro.quantity;
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
}
const salesByOption = Object.values(salesByOptionMap);
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
return {
event,
paymentsByMethod,
totalRevenue,
costs: costBreakdown,
costsByMethod,
untaggedCostsTotal,
totalCosts,
expectedCashByMethod,
reconciled,
effectiveGrossIncomeByMethod,
effectiveTotalRevenue,
writeOffsByMethod,
writeOffsByCategory,
totalWriteOffs,
pendingOutstandingTotal: cents(pendingOutstandingTotal),
netProfit,
unallocatedDonations,
unallocatedDonationsTotal,
totalDonations,
salesByOption,
discountsTotal,
history
};
}
// Payment accountability, per staff member who recorded the payment, broken down by every
// method (not just cash) — lets a cashup reconcile not just the total float but who is
// responsible for which portion of it. Cash also folds in any actual physical count entered for
// that person (EventCashupPersonCount, entered any time, independent of the event-wide close) to
// show an actual-vs-expected variance per person — the event's cash actual is the sum of these
// per-person counts (see computeEventCashActualFromPersonCounts), not a separate manual entry.
// Card/EFT/Other have no physical "count" concept, so they're just recorded amounts.
async function computeAccountabilityByUser(eventId) {
const [payments, personCounts] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] },
include: { recordedBy: { select: { id: true, name: true, email: true } } }
}),
prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: {
user: { select: { id: true, name: true, email: true } },
enteredBy: { select: { id: true, name: true } },
denominations: true
}
})
]);
const emptyMethodTotals = () => ({ total: 0, count: 0 });
const emptyEntry = (userId, name, email) => ({
userId: userId || null,
name: name || 'Unknown / legacy',
email: email || null,
cash: { ...emptyMethodTotals(), actual: null, variance: null, denominations: [], enteredBy: null, countUpdatedAt: null, notes: null },
card: emptyMethodTotals(),
eft: emptyMethodTotals(),
other: emptyMethodTotals()
});
const byUser = new Map();
for (const p of payments) {
// A donation-application leg isn't new money — it's the same money already recorded once,
// as the donation. Counting it again here would double-attribute it to whoever did the
// assignment, on top of whoever originally recorded the donation.
if (isDonationLeg(p)) continue;
const method = bucketForMethod(p.method);
const key = p.recordedById || 'unknown';
const entry = byUser.get(key) || emptyEntry(p.recordedById, p.recordedBy?.name, p.recordedBy?.email);
entry[method].total += p.amount;
entry[method].count += 1;
byUser.set(key, entry);
}
for (const pc of personCounts) {
const key = pc.userId;
const entry = byUser.get(key) || emptyEntry(pc.userId, pc.user?.name, pc.user?.email);
const actual = pc.denominations.reduce((s, d) => s + d.value * d.count, 0);
entry.cash.actual = actual;
entry.cash.variance = actual - entry.cash.total;
entry.cash.denominations = pc.denominations.map(d => ({ value: d.value, count: d.count }));
entry.cash.enteredBy = pc.enteredBy ? { id: pc.enteredBy.id, name: pc.enteredBy.name } : null;
entry.cash.countUpdatedAt = pc.updatedAt;
entry.cash.notes = pc.notes || null;
byUser.set(key, entry);
}
return Array.from(byUser.values()).sort((a, b) => {
const totalA = a.cash.total + a.card.total + a.eft.total + a.other.total;
const totalB = b.cash.total + b.card.total + b.eft.total + b.other.total;
return totalB - totalA;
});
}
// The event's cash "actual" is the live sum of every staff member's entered physical count —
// there is no separate event-wide entry any more. Used both to display a live figure before
// close and to source the closed cashup's permanent Cash line.
async function computeEventCashActualFromPersonCounts(eventId) {
const personCounts = await prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: { denominations: true }
});
if (personCounts.length === 0) return { actual: null, denominations: [] };
const byValue = new Map();
let actual = 0;
for (const pc of personCounts) {
for (const d of pc.denominations) {
actual += d.value * d.count;
byValue.set(d.value, (byValue.get(d.value) || 0) + d.count);
}
}
const denominations = Array.from(byValue.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.value - a.value);
return { actual, denominations };
}
// Upsert one staff member's actual physical cash count for an event — optional, can be entered
// any time (not required to close the event), purely for per-person accountability.
async function savePersonCashCount(eventId, userId, { denominations, notes, enteredById }) {
const cleanDenoms = (Array.isArray(denominations) ? denominations : [])
.map(d => ({ value: Number(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0);
const existing = await prisma.eventCashupPersonCount.findUnique({
where: { eventId_userId: { eventId, userId } }
});
const record = existing
? await prisma.eventCashupPersonCount.update({
where: { id: existing.id },
data: {
notes: notes || null,
enteredById: enteredById || null,
denominations: { deleteMany: {}, create: cleanDenoms }
},
include: { denominations: true }
})
: await prisma.eventCashupPersonCount.create({
data: {
eventId,
userId,
notes: notes || null,
enteredById: enteredById || null,
denominations: { create: cleanDenoms }
},
include: { denominations: true }
});
return record;
}
module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
WRITE_OFF_CATEGORIES,
PENDING_OUTCOMES,
settleLine,
applyPendingResolutions,
bucketForMethod,
isDonationLeg,
assertEventOpen,
assertRegistrationEventOpen,
computeEventFinancials,
computeAccountabilityByUser,
computeEventCashActualFromPersonCounts,
savePersonCashCount
};