Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,16 @@ function bucketForMethod(method) {
|
||||
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) {
|
||||
@@ -93,16 +103,30 @@ async function computeEventFinancials(eventId) {
|
||||
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);
|
||||
// Real inflows only — 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).
|
||||
const nonRefundPayments = payments.filter(p => p.amount > 0 && !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".
|
||||
const legsByDonationId = new Map();
|
||||
for (const p of payments) {
|
||||
if (p.originalPaymentId && !p.isDonation) {
|
||||
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + 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 nonRefundPayments) {
|
||||
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
||||
}
|
||||
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
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 => {
|
||||
@@ -195,12 +219,143 @@ async function computeEventFinancials(eventId) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
computeEventFinancials,
|
||||
computeAccountabilityByUser,
|
||||
computeEventCashActualFromPersonCounts,
|
||||
savePersonCashCount
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
|
||||
const ALLOWED_NOTIFICATION_PREFERENCES = ['email', 'whatsapp', 'both'];
|
||||
|
||||
// WhatsApp/Both requires a valid SA phone number — silently falls back to email otherwise,
|
||||
// since a user without a usable phone number can never receive WhatsApp notifications anyway.
|
||||
function resolveNotificationPreference(requested, phoneNumber, fallback) {
|
||||
if (requested === undefined) return fallback;
|
||||
let pref = ALLOWED_NOTIFICATION_PREFERENCES.includes(requested) ? requested : fallback;
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
|
||||
pref = 'email';
|
||||
}
|
||||
return pref;
|
||||
}
|
||||
|
||||
module.exports = { ALLOWED_NOTIFICATION_PREFERENCES, resolveNotificationPreference };
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
|
||||
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
|
||||
// every other placeholder is a plain synchronous string replace.
|
||||
async function replacePlaceholders(str, ctx = {}) {
|
||||
if (!str) return str;
|
||||
let out = String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
|
||||
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
|
||||
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
|
||||
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
|
||||
|
||||
if (/\{\{\s*payment\.link\s*\}\}/.test(out) && typeof ctx.paymentLinkResolver === 'function') {
|
||||
let link = '';
|
||||
try {
|
||||
link = (await ctx.paymentLinkResolver()) || '';
|
||||
} catch (e) {
|
||||
try { console.warn('[placeholders] payment.link resolution failed:', e?.message || e); } catch {}
|
||||
}
|
||||
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, link);
|
||||
} else {
|
||||
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, '');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { replacePlaceholders };
|
||||
@@ -105,6 +105,12 @@ function isSessionNotFound(e) {
|
||||
return msg.includes('session not found') || msg.includes('instance not found');
|
||||
}
|
||||
|
||||
/** Extracts the WAWP API's own error reason when present, instead of axios's generic
|
||||
* "Request failed with status code NNN" (which carries no information about what went wrong). */
|
||||
function wawpErrorMessage(e) {
|
||||
return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error';
|
||||
}
|
||||
|
||||
/**
|
||||
* If the WAWP API reports "Session not found", clear the stale instance ID
|
||||
* from the DB so the admin UI drops back to the Session Instance setup step.
|
||||
@@ -221,12 +227,20 @@ async function sendText(toPhone, message) {
|
||||
const chatId = toChatId(toPhone);
|
||||
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
|
||||
const { token, instanceId } = await getConfig();
|
||||
await axios.post(`${BASE}/send/text`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
message,
|
||||
});
|
||||
try {
|
||||
await axios.post(`${BASE}/send/text`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
message,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isSessionNotFound(e)) {
|
||||
try { await handleSessionNotFound(e); } catch {}
|
||||
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
|
||||
}
|
||||
throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,17 +272,26 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
||||
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
||||
|
||||
const { token, instanceId } = await getConfig();
|
||||
await axios.post(`${BASE}/send/pdf`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
file: {
|
||||
url: pdfUrl,
|
||||
filename: filename || 'tickets.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
},
|
||||
caption: caption || '',
|
||||
});
|
||||
try {
|
||||
await axios.post(`${BASE}/send/pdf`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
file: {
|
||||
url: pdfUrl,
|
||||
filename: filename || 'tickets.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
},
|
||||
caption: caption || '',
|
||||
});
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tempPath); } catch {}
|
||||
if (isSessionNotFound(e)) {
|
||||
try { await handleSessionNotFound(e); } catch {}
|
||||
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
|
||||
}
|
||||
throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
// Clean up after 5 minutes — WAWP will have fetched the file by then
|
||||
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
||||
|
||||
Reference in New Issue
Block a user