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]; function emptyByMethod(fill = 0) { return { cash: fill, card: fill, eft: fill, other: fill }; } // 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] = 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 } }, performedBy: { select: { id: true, name: true, email: true } } }, orderBy: { createdAt: 'desc' } }) ]); // 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); out[m] = { expected: line ? line.expectedAmount : expectedCashByMethod[m], actual: line && line.actualAmount != null ? line.actualAmount : null, variance: line && line.variance != null ? line.variance : null, notes: line ? line.notes : null, denominations: line ? line.denominations : [] }; } return out; })() } : null; // Reconciled value is truth; system (expected) value is the fallback when nothing was counted. // Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure — // costs get subtracted from profit exactly once, via totalCosts, never twice. const effectiveGrossIncomeByMethod = emptyByMethod(); for (const m of ALL_METHODS) { const actual = reconciled?.byMethod?.[m]?.actual; const base = actual != null ? actual : expectedCashByMethod[m]; effectiveGrossIncomeByMethod[m] = base + costsByMethod[m]; } const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const netProfit = effectiveTotalRevenue - totalCosts; // 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); return { event, paymentsByMethod, totalRevenue, costs: costBreakdown, costsByMethod, untaggedCostsTotal, totalCosts, expectedCashByMethod, reconciled, effectiveGrossIncomeByMethod, effectiveTotalRevenue, netProfit, unallocatedDonations, unallocatedDonationsTotal, totalDonations, salesByOption, 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, bucketForMethod, isDonationLeg, assertEventOpen, assertRegistrationEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount };