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:
@@ -1226,6 +1226,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
const n = Number(amt || 0);
|
||||
@@ -1234,17 +1235,6 @@ const emailEventAttendees = async (req, res) => {
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return 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 || '');
|
||||
}
|
||||
|
||||
// Build per-recipient registration aggregates for this event
|
||||
const regsByEmail = new Map();
|
||||
@@ -1255,42 +1245,66 @@ const emailEventAttendees = async (req, res) => {
|
||||
regsByEmail.get(em).push(r);
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// Special handling for tickets template: trigger ticket emails containing attachments
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}, to
|
||||
// avoid an unnecessary Yoco API call per recipient otherwise.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
return async () => {
|
||||
const reg = (regs || []).find(r => {
|
||||
const due = computeRegistrationTotalDue(r, new Date());
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return result.redirectUrl;
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
||||
// Large recipient lists used to block the request until every email was sent — now the
|
||||
// caller gets an instant response and failures are just logged server-side.
|
||||
if (template === 'tickets') {
|
||||
const { emailTickets } = require('./ticketController');
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByEmail.get(rcpt.email) || [];
|
||||
// Send tickets for each registration that belongs to this recipient for this event
|
||||
for (const reg of regs) {
|
||||
// Use the controller helper as in other parts of the code
|
||||
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
}
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTitle = event?.title || 'the event';
|
||||
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
||||
// Build event and promo links for placeholders
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
||||
const promoTitle = promoEvent?.title || '';
|
||||
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
|
||||
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
|
||||
|
||||
// Send individually with personalization
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
||||
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByEmail.get(rcpt.email) || [];
|
||||
// Sum outstanding balance across this user's registrations for the event
|
||||
let totalDue = 0; let totalPaid = 0;
|
||||
@@ -1300,6 +1314,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
totalDue += due; totalPaid += paid;
|
||||
}
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const paymentLinkResolver = makePaymentLinkResolver(regs);
|
||||
// Build context for placeholder replacement
|
||||
const ctxBase = {
|
||||
name: rcpt.name || '',
|
||||
@@ -1310,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
promoLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver,
|
||||
};
|
||||
const ctxHtml = {
|
||||
...ctxBase,
|
||||
@@ -1347,18 +1363,18 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
// Always perform placeholder replacement on whatever we have
|
||||
const finalSubject = replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -1445,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
|
||||
const { sendText } = require('../utils/whatsapp');
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
const n = Number(amt || 0);
|
||||
@@ -1453,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return 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.eventLink || '')
|
||||
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
|
||||
}
|
||||
|
||||
const regsByPhone = new Map();
|
||||
for (const r of registrations) {
|
||||
@@ -1476,29 +1484,52 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||
|
||||
let sent = 0;
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
return async () => {
|
||||
const reg = (regs || []).find(r => {
|
||||
const due = computeRegistrationTotalDue(r, new Date());
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return result.redirectUrl;
|
||||
};
|
||||
}
|
||||
|
||||
// Special handling for tickets template: send ticket PDFs via WhatsApp
|
||||
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
||||
if (template === 'tickets') {
|
||||
const { emailTickets } = require('./ticketController');
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||
for (const reg of regs) {
|
||||
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
}
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
||||
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||
let totalDue = 0; let totalPaid = 0;
|
||||
for (const r of regs) {
|
||||
@@ -1514,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
eventLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver: makePaymentLinkResolver(regs),
|
||||
};
|
||||
|
||||
let msg = message;
|
||||
@@ -1523,15 +1555,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
||||
}
|
||||
|
||||
const finalMessage = replacePlaceholders(msg || '', ctx);
|
||||
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user