Files
hope-events/backend/src/utils/cashupUtils.js
T
joshuaandClaude Sonnet 5 f0f8d4c242 Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a
  separate price tranche instead of overwriting a single price/quantity
  on repeat purchases, so buying more tickets after a tier expires no
  longer re-prices tickets already bought at the old price. Stock-limit
  checks, total-due calculation, and the Finance report's revenue-by-
  option are all tranche-aware; pages that showed one blended price per
  line now render/total each tranche. Viewing a pending/partially-paid
  registration (dashboard, detail page, or an event's registration
  list) now refreshes stale pricing on the spot instead of only at
  payment time.
- Fixed the "(early bird)" dashboard label incorrectly firing on any
  line priced below the base option price (e.g. a plain cheaper
  variant) — it now checks the real applied-tier flag.
- Added contact-only events (e.g. baptism): no registration/payment
  flow, shown on the public site with a "Contact us" popup instead of
  a Register button. Configurable via the admin event wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:26:35 +02:00

369 lines
16 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];
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
};