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:
@@ -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
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user