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>
518 lines
23 KiB
JavaScript
518 lines
23 KiB
JavaScript
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
|
||
};
|