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:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
+841 -7
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
ADD COLUMN "feeChannel" TEXT,
ADD COLUMN "feePayer" TEXT,
ADD COLUMN "feeRate" DOUBLE PRECISION;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feePayer";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT;
-- CreateIndex
CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById");
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId");
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "EventCashupPersonCount" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"enteredById" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPersonCountDenomination" (
"id" TEXT NOT NULL,
"countId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"count" INTEGER NOT NULL,
CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId");
-- CreateIndex
CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId");
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+39
View File
@@ -63,6 +63,7 @@ model User {
notificationPreference NotificationPreference @default(email)
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
tickets Ticket[]
ticketScans TicketUsage[]
passwordResets PasswordReset[]
@@ -74,6 +75,9 @@ model User {
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
}
model Event {
@@ -112,6 +116,7 @@ model Event {
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
@@ -215,6 +220,7 @@ model Payment {
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
@@ -223,6 +229,7 @@ model Payment {
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
@@ -230,9 +237,11 @@ model Payment {
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId])
}
model Ticket {
@@ -479,6 +488,36 @@ model EventCashupDenomination {
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
+4 -11
View File
@@ -29,14 +29,7 @@ function buildEventContext(event) {
return { eventTitle, eventStart, eventLink, eventLinkHtml };
}
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 || ''));
}
const { replacePlaceholders } = require('../utils/placeholders');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -166,9 +159,9 @@ const sendBroadcast = async (req, res) => {
eventLink: escapeHtml(eventCtx.eventLink),
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = replacePlaceholders(subject, ctxBase);
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
}));
+68 -16
View File
@@ -1,7 +1,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => {
}
};
// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/
// other) — cash also includes the live actual-vs-expected from per-person counts.
// @route GET /api/cashups/event/:eventId/cash-by-user
// @access Private/Supervisor
const getCashByRecordedUser = async (req, res) => {
try {
const [rows, cash] = await Promise.all([
computeAccountabilityByUser(req.params.eventId),
computeEventCashActualFromPersonCounts(req.params.eventId)
]);
res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Enter (or update) one staff member's actual physical cash count for an event —
// optional, can be done any time, purely for per-person accountability. Never blocks
// or is required for closing the event.
// @route PUT /api/cashups/event/:eventId/person-cash/:userId
// @access Private/Supervisor
const savePersonCash = async (req, res) => {
try {
const { eventId, userId } = req.params;
const { denominations, notes } = req.body;
const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!targetUser) {
res.status(404);
throw new Error('User not found');
}
const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id });
res.json(record);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Save in-progress reconciliation entries without closing the event
// @route PUT /api/cashups/event/:eventId/draft
// @access Private/Admin
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
const cashupLines = isFullCashup
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0)
: [];
const actual = denominations.length > 0
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
denominations: denominations.length > 0 ? { create: denominations } : undefined
notes: l.notes || null
};
})
: [];
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
}
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
module.exports = {
getEventCashup,
getCashByRecordedUser,
savePersonCash,
saveEventCashupDraft,
closeEvent,
reopenEvent,
+98 -66
View File
@@ -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 });
}
+69 -44
View File
@@ -157,6 +157,7 @@ const createPayment = async (req, res) => {
amount: requestedAmount,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -164,6 +165,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
event: true
}
});
@@ -175,6 +177,7 @@ const createPayment = async (req, res) => {
amount: applyAmount,
method,
userId,
recordedById: req.user.id,
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
@@ -182,6 +185,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: (registrationEventId || eventId) ? true : undefined
}
@@ -195,6 +199,7 @@ const createPayment = async (req, res) => {
amount: excess,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -212,6 +217,7 @@ const createPayment = async (req, res) => {
amount: parseFloat(amount),
method,
userId,
recordedById: req.user.id,
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
@@ -219,6 +225,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: registrationId ? { include: { event: true } } : undefined,
event: (registrationEventId || eventId) ? true : undefined
}
@@ -329,6 +336,7 @@ const getPayments = async (req, res) => {
const include = {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: {
event: true,
@@ -452,6 +460,13 @@ const getPaymentById = async (req, res) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: {
include: {
event: true
@@ -507,6 +522,13 @@ const getPaymentsByRegistration = async (req, res) => {
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
});
@@ -533,6 +555,7 @@ const getPaymentsByEvent = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: { user: { select: { id: true, name: true, email: true } } }
}
@@ -576,10 +599,18 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Check if payment is already assigned to a registration
if (payment.registrationId) {
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it.
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + leg.amount, 0);
const remainingDonation = payment.amount - alreadyUsed;
if (remainingDonation <= 0.000001) {
res.status(400);
throw new Error('This payment is already assigned to a registration');
throw new Error('This donation has already been fully allocated');
}
if (payment.eventId) {
@@ -628,16 +659,16 @@ const assignDonationToRegistration = async (req, res) => {
}
// How much of the donation to apply — defaults to today's behaviour (as much as the
// donation covers, capped at what's owed) but staff can specify a smaller amount and
// deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount);
// donation's remaining balance covers, capped at what's owed) but staff can specify a
// smaller amount and deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount);
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
res.status(400);
throw new Error('Allocation amount must be greater than zero');
}
if (allocateAmount > payment.amount) {
if (allocateAmount > remainingDonation) {
res.status(400);
throw new Error('Cannot allocate more than the donation amount');
throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`);
}
if (allocateAmount > remainingAmount) {
res.status(400);
@@ -647,39 +678,24 @@ const assignDonationToRegistration = async (req, res) => {
let updatedRegistration;
let generatedTickets = [];
let originalPaymentId = payment.id;
let splitPayment = null;
// Update the payment to be associated with the registration and adjust amount
await prisma.payment.update({
where: { id: payment.id },
// Create an immutable leg referencing the donation — the donation row itself is never
// touched, so its original amount and history stay intact and it can be assigned again
// later if this leg doesn't use it up.
const leg = await prisma.payment.create({
data: {
registrationId,
id: uuidv4(),
amount: allocateAmount,
isDonation: false
method: payment.method,
userId: payment.userId,
recordedById: req.user.id,
registrationId,
eventId: registration.eventId,
isDonation: false,
originalPaymentId: payment.id,
}
});
// If less than the full donation was allocated, the remainder stays as an unassigned
// donation (same donor, no notification — it's a bookkeeping split, not a new gift).
if (allocateAmount < payment.amount) {
const leftoverAmount = payment.amount - allocateAmount;
splitPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: leftoverAmount,
method: payment.method,
userId: payment.userId,
eventId: payment.eventId,
isDonation: true,
externalId: payment.externalId ? `${payment.externalId}-split` : null,
status: payment.status,
originalPaymentId: payment.id,
createdAt: payment.createdAt
}
});
}
if (allocateAmount >= remainingAmount) {
// Fully covers what's owed
updatedRegistration = await prisma.registration.update({
@@ -707,9 +723,9 @@ const assignDonationToRegistration = async (req, res) => {
}
// Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails),
// then tickets (guarantees order). The split/leftover payment is never notified.
// then tickets (guarantees order).
const { sendDonationAssignmentEmails } = require('../utils/notifications');
const _adPaymentId = payment?.id;
const _adPaymentId = leg?.id;
const _adShouldEmailTickets = generatedTickets.length > 0;
const _adUserId = registration?.userId;
const _adRegId = registrationId;
@@ -730,10 +746,8 @@ const assignDonationToRegistration = async (req, res) => {
originalPaymentId,
updatedRegistration,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
splitPayment: splitPayment ? {
...splitPayment,
originalPaymentId: payment.id
} : null
leg,
donationRemaining: remainingDonation - allocateAmount
};
res.status(200).json(result);
@@ -1151,6 +1165,7 @@ const createRefund = async (req, res) => {
amount: -Math.abs(amt),
method: method || 'refund',
userId,
recordedById: req.user.id,
registrationId: linkRegistrationId,
eventId: linkEventId,
isDonation: false,
@@ -1159,6 +1174,7 @@ const createRefund = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: true
}
@@ -1210,6 +1226,12 @@ const getPaymentStats = async (req, res) => {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
// Exclude donation-application legs — a leg re-labels part of an already-counted donation
// as applied to a registration, it isn't new money. Summing both would double-count it.
const excludeDonationLegs = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] }
};
const [totalToday, totalWeek, totalMonth] = await Promise.all([
prisma.payment.aggregate({
_sum: {
@@ -1218,7 +1240,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: startOfDay
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1228,7 +1251,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastWeek
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1238,7 +1262,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastMonth
}
},
...excludeDonationLegs
}
})
]);
@@ -380,7 +380,7 @@ const getRegistrations = async (req, res) => {
try {
const registrations = await prisma.registration.findMany({
include: {
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
@@ -472,7 +472,7 @@ const getRegistrationById = async (req, res) => {
phoneNumber: true
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
}
});
@@ -653,7 +653,7 @@ const getRegistrationsByEvent = async (req, res) => {
tickets: true,
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
},
orderBy: { createdAt: 'asc' }
+453 -229
View File
@@ -1,269 +1,464 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Utility: draw a table
function drawTable(doc, startX, startY, colWidths, rows, header) {
let y = startY;
doc.font('Helvetica-Bold');
if (header && header.length) {
let x = startX;
header.forEach((h, i) => {
const w = colWidths[i] || 80;
doc.rect(x, y, w, 20).stroke();
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
x += w;
});
y += 20;
}
doc.font('Helvetica');
rows.forEach((row) => {
let x = startX;
row.forEach((cell, i) => {
const w = colWidths[i] || 80;
const h = 18;
doc.rect(x, y, w, h).stroke();
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
x += w;
});
y += 18;
// New page if overflow
if (y > doc.page.height - 40) {
doc.addPage();
y = 20;
}
});
}
const { sendMail, emailWrapper } = require('../utils/email');
function a4Doc(orientation = 'portrait') {
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
}
// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical
// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the
// same report instead of a plain data dump.
const BRAND = '#4f46e5';
const BRAND_DARK = '#3730a3';
const BRAND_LIGHT = '#eef2ff';
const TEXT_DARK = '#111827';
const TEXT_MUTED = '#6b7280';
const BORDER = '#e5e7eb';
const TONE_COLORS = {
green: { bg: '#ecfdf5', accent: '#059669' },
blue: { bg: '#eff6ff', accent: '#2563eb' },
violet: { bg: '#f5f3ff', accent: '#7c3aed' },
amber: { bg: '#fffbeb', accent: '#d97706' },
rose: { bg: '#fff1f2', accent: '#e11d48' },
gray: { bg: '#f3f4f6', accent: '#4b5563' },
};
const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948'];
function isEmphasisRow(firstCell) {
const s = String(firstCell ?? '').trim();
return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s);
}
function drawHeader(doc, title, subtitle) {
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.page.margins.top;
const h = subtitle ? 46 : 32;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 });
if (subtitle) {
doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 });
}
doc.restore();
doc.y = y + h + 14;
}
function drawStats(doc, stats) {
if (!Array.isArray(stats) || stats.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110)));
const gap = 8;
const boxW = (pageWidth - gap * (perRow - 1)) / perRow;
const boxH = 34;
let rowY = doc.y;
stats.forEach((s, i) => {
const col = i % perRow;
if (col === 0 && i !== 0) rowY += boxH + gap;
const bx = doc.page.margins.left + col * (boxW + gap);
const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray;
doc.save();
doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg);
doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 });
doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 });
doc.restore();
});
doc.y = rowY + boxH + 16;
if (doc.y > doc.page.height - 60) doc.addPage();
}
function drawChart(doc, chart) {
if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
if (chart.title) {
doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y);
doc.moveDown(0.4);
}
const labelW = 110;
const valueW = 80;
const barAreaW = pageWidth - labelW - valueW - 16;
const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0)));
const rowH = 16;
chart.data.forEach((d, i) => {
const y = doc.y;
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 });
const trackX = x + labelW;
doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6');
const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW);
doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]);
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' });
doc.y = y + rowH;
if (doc.y > doc.page.height - 60) doc.addPage();
});
doc.moveDown(0.8);
}
function drawNote(doc, note) {
if (!note) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.y;
doc.font('Helvetica-Oblique').fontSize(8.5);
const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb');
doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 });
doc.restore();
doc.y = y + h + 12;
if (doc.y > doc.page.height - 60) doc.addPage();
}
// Draws a single branded table (header band, zebra rows, highlighted total rows) at the
// document's current y — shared by the main table body and any extraTables sections below it.
function drawTable(doc, table) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT);
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows — zebra striped, with a highlighted tint+bold for total/summary rows
const rows = table.rows;
rows.forEach((row) => {
const emphasis = isEmphasisRow(row[0]);
const rowY = doc.y;
const rowH = 18;
const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb');
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
doc.moveDown(1);
}
// Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a
// branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body
// (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it
// (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp).
function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) {
drawHeader(doc, title, subtitle);
drawStats(doc, stats);
drawChart(doc, chart);
drawNote(doc, note);
// Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's
// implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every
// text() call below passes an explicit x — relying on the implicit cursor after a table has
// rendered puts the next label at the table's right edge instead of the margin.
const leftX = doc.page.margins.left;
if (kind === 'table' && table && Array.isArray(table.rows)) {
drawTable(doc, table);
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke();
doc.fillColor(TEXT_DARK);
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y);
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
if (doc.y > doc.page.height - 100) doc.addPage();
doc.moveDown(0.6);
if (t.title) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y);
doc.moveDown(0.4);
}
drawTable(doc, t);
});
}
}
// Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf.
async function renderReportPdfToFile(payload) {
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
await new Promise((resolve, reject) => {
const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
drawReportPdf(doc, payload);
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
return filePath;
}
// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded
// title band, stat label/value rows, a chart rendered as a mini-table with a native Excel
// data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the
// web's bar chart), then the main table with a bold colored header row and highlighted
// total rows.
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
wb.creator = 'Hope Family Church Events';
wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
const colCount = Math.max(4, (table?.columns?.length || 0));
let r = 1;
ws.mergeCells(r, 1, r, colCount);
const titleCell = ws.getCell(r, 1);
titleCell.value = title || 'Report';
titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } };
titleCell.alignment = { vertical: 'middle' };
ws.getRow(r).height = 26;
r++;
if (subtitle) {
ws.mergeCells(r, 1, r, colCount);
const subCell = ws.getCell(r, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } };
r++;
}
r++;
if (Array.isArray(stats) && stats.length) {
stats.forEach(s => {
ws.getCell(r, 1).value = s.label;
ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 };
ws.getCell(r, 2).value = s.value;
ws.getCell(r, 2).font = { bold: true, size: 11 };
r++;
});
r++;
}
if (chart && Array.isArray(chart.data) && chart.data.length) {
if (chart.title) {
ws.getCell(r, 1).value = chart.title;
ws.getCell(r, 1).font = { bold: true, size: 10 };
r++;
}
const chartStartRow = r;
chart.data.forEach(d => {
ws.getCell(r, 1).value = d.label;
ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0;
r++;
});
ws.addConditionalFormatting({
ref: `B${chartStartRow}:B${r - 1}`,
rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }]
});
r++;
}
if (note) {
ws.mergeCells(r, 1, r, colCount);
ws.getCell(r, 1).value = note;
ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } };
ws.getCell(r, 1).alignment = { wrapText: true };
r += 2;
}
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = table.columns || [];
const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows);
ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }];
r = nextRow;
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
ws.getCell(r, 1).value = layered.header;
ws.getCell(r, 1).font = { bold: true, size: 12 };
r += 2;
}
layered.sections.forEach(section => {
ws.getCell(r, 1).value = section.title;
ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } };
r++;
(section.items || []).forEach(item => {
ws.getCell(r, 1).value = `${item}`;
r++;
});
r++;
});
ws.getColumn(1).width = 90;
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
r++;
if (t.title) {
ws.getCell(r, 1).value = t.title;
ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } };
r++;
}
const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows);
r = nextRow;
});
}
return wb;
}
// Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given
// sheet row — shared by the main table and any extraTables sections. Returns the next free row
// and the data's start row (for optional freeze-pane use by the caller).
function writeTableRows(ws, startRow, columns, rows) {
let r = startRow;
const headerRow = ws.getRow(r);
columns.forEach((c, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = c;
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } };
});
headerRow.commit();
r++;
const dataStartRow = r;
rows.forEach((row, idx) => {
const excelRow = ws.getRow(r);
const emphasis = isEmphasisRow(row[0]);
row.forEach((val, i) => {
const cell = excelRow.getCell(i + 1);
cell.value = val === '' ? null : val;
if (emphasis) {
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
} else if (idx % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
}
});
excelRow.commit();
r++;
});
columns.forEach((c, i) => {
let maxLen = String(c || '').length;
rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); });
const col = ws.getColumn(i + 1);
col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2)));
});
return { nextRow: r, dataStartRow };
}
// POST /api/reports/pdf
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
try {
const { title, kind, table, layered, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {};
res.setHeader('Content-Type', 'application/pdf');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
doc.pipe(res);
// Title
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column to mimic site tables
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Draw header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Zebra rows
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
// Cell text
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
// Vertical separators similar to table borders
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
// Right border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
// Bottom border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
// Bullet dot
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/excel
// body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report.
const generateExcel = async (req, res) => {
try {
const payload = req.body || {};
const wb = await buildReportWorkbook(payload);
const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/email
// body: { title, kind, table?, layered?, subject?, body? }
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
if (!user || !user.email) {
res.status(400);
throw new Error('User email not available');
}
// Ensure temp dir
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
// Build PDF to file
await new Promise((resolve, reject) => {
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows zebra
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
// Send email using nodemailer (same config as tickets)
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});
await transporter.sendMail({
from: process.env.EMAIL_FROM,
// Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env
// fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter
// ignores that configuration entirely and fails wherever SMTP is only set up via the DB.
const bodyText = body || 'Please find your report attached.';
await sendMail({
to: user.email,
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
text: body || 'Please find your report attached.',
text: bodyText,
html: emailWrapper(
`<p style="margin:0 0 16px 0;color:#374151">${bodyText}</p>` +
`<p style="margin:0;color:#374151">Your report <strong>${title || 'Report'}</strong> is attached as a PDF.</p>`,
{ preheader: title || 'Report PDF' }
),
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
});
// Clean
try { fs.unlinkSync(filePath); } catch {}
res.json({ message: `Report emailed to ${user.email}` });
@@ -272,4 +467,33 @@ const emailPdf = async (req, res) => {
}
};
module.exports = { generatePdf, emailPdf };
// POST /api/reports/whatsapp
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? }
// Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf).
const whatsappPdf = async (req, res) => {
let filePath = null;
try {
const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
const { isValidZAPhone } = require('../utils/whatsapp');
if (!user || !isValidZAPhone(user.phoneNumber)) {
res.status(400);
throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.');
}
filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
const { sendPdf } = require('../utils/whatsapp');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
res.json({ message: `Report sent to your WhatsApp` });
} catch (e) {
res.status(400).json({ message: e.message });
} finally {
if (filePath) { try { fs.unlinkSync(filePath); } catch {} }
}
};
module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf };
+11 -11
View File
@@ -354,21 +354,15 @@ const updateUserProfile = async (req, res) => {
}
// Normalize phone
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let newPref = user.notificationPreference;
if (notificationPreference !== undefined) {
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
newPref = 'email';
}
}
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Update user data
const updatedUser = await prisma.user.update({
@@ -554,7 +548,11 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Prepare data update, allow admin to set a new password
const data = {
@@ -562,7 +560,8 @@ const updateUser = async (req, res) => {
email: email || user.email,
role: role || user.role,
isActive: isActive !== undefined ? isActive : user.isActive,
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
};
@@ -579,6 +578,7 @@ const updateUser = async (req, res) => {
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => {
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
},
@@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: true
@@ -138,6 +138,7 @@ const reconcileYocoTransaction = async (req, res) => {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
+3 -1
View File
@@ -1,10 +1,12 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
+7 -1
View File
@@ -1,12 +1,18 @@
const express = require('express');
const router = express.Router();
const { generatePdf, emailPdf } = require('../controllers/reportController');
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
const { protect } = require('../middleware/authMiddleware');
// Generate and download PDF
router.post('/pdf', protect, generatePdf);
// Generate and download styled Excel (.xlsx)
router.post('/excel', protect, generateExcel);
// Email PDF to current user
router.post('/email', protect, emailPdf);
// Send PDF to current user's own WhatsApp
router.post('/whatsapp', protect, whatsappPdf);
module.exports = router;
+161 -6
View File
@@ -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 };
+30
View File
@@ -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 };
+40 -17
View File
@@ -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);