Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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.
|
||||
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')) return 'card';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// 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 } } }
|
||||
}),
|
||||
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;
|
||||
}
|
||||
|
||||
const nonRefundPayments = payments.filter(p => p.amount > 0);
|
||||
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
|
||||
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const paymentsByMethod = emptyByMethod();
|
||||
for (const p of nonRefundPayments) {
|
||||
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
||||
}
|
||||
const totalRevenue = payments.reduce((sum, p) => sum + 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
|
||||
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 };
|
||||
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
|
||||
salesByOptionMap[opt.id].quantitySold += ro.quantity;
|
||||
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity;
|
||||
}
|
||||
const salesByOption = Object.values(salesByOptionMap);
|
||||
|
||||
return {
|
||||
event,
|
||||
paymentsByMethod,
|
||||
totalRevenue,
|
||||
costs: costBreakdown,
|
||||
costsByMethod,
|
||||
untaggedCostsTotal,
|
||||
totalCosts,
|
||||
expectedCashByMethod,
|
||||
reconciled,
|
||||
effectiveGrossIncomeByMethod,
|
||||
effectiveTotalRevenue,
|
||||
netProfit,
|
||||
unallocatedDonations,
|
||||
unallocatedDonationsTotal,
|
||||
totalDonations,
|
||||
salesByOption,
|
||||
history
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHOD_BUCKETS,
|
||||
ALL_METHODS,
|
||||
ZAR_DENOMINATIONS,
|
||||
bucketForMethod,
|
||||
assertEventOpen,
|
||||
assertRegistrationEventOpen,
|
||||
computeEventFinancials
|
||||
};
|
||||
Reference in New Issue
Block a user