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:
@@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
|
||||
// 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. Money stats/lists must count each real inflow exactly once, so legs are
|
||||
// excluded — the money was already counted via the original donation row.
|
||||
function isDonationLeg(p: any): boolean {
|
||||
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
|
||||
}
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
regs: any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
@@ -439,7 +448,8 @@ function PaymentsContent() {
|
||||
// Stats
|
||||
const todayTotals = useMemo(() => {
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime());
|
||||
// Exclude donation-application legs — that money was already counted once, as the donation.
|
||||
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
|
||||
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
|
||||
const donations = today.filter(p => p.isDonation).length;
|
||||
return { revenue, donations, count: today.length };
|
||||
@@ -805,7 +815,7 @@ function PaymentsContent() {
|
||||
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
|
||||
{payments.slice(0, 25).map(p => {
|
||||
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
|
||||
const amt = p.amount || 0;
|
||||
const isRefund = amt < 0;
|
||||
return (
|
||||
@@ -818,6 +828,9 @@ function PaymentsContent() {
|
||||
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
|
||||
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
|
||||
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
|
||||
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
|
||||
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
[registrations, registrationId]
|
||||
);
|
||||
|
||||
// Donations "relevant to that event" — unassigned donations logged against the same event
|
||||
// as the chosen registration.
|
||||
// A donation is never mutated once assigned — each assignment creates a separate "leg"
|
||||
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
|
||||
// balance is its original amount minus every leg that references it, so it stays offerable
|
||||
// (and its registrationId stays null forever) until fully used up.
|
||||
const legsById = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
payments.forEach((p: any) => {
|
||||
if (p.originalPaymentId && !p.isDonation) {
|
||||
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + (p.amount || 0));
|
||||
}
|
||||
});
|
||||
return m;
|
||||
}, [payments]);
|
||||
|
||||
// Donations "relevant to that event" — donations logged against the same event as the
|
||||
// chosen registration that still have a remaining, unused balance.
|
||||
const donationsForEvent = useMemo(() => {
|
||||
if (!selectedRegistration) return [] as any[];
|
||||
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
|
||||
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
|
||||
}, [payments, selectedRegistration]);
|
||||
return payments.filter((p: any) => {
|
||||
if (!p.isDonation || p.registrationId) return false;
|
||||
if (String(p.eventId) !== String(eventId)) return false;
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
return remaining > 0.000001;
|
||||
});
|
||||
}, [payments, selectedRegistration, legsById]);
|
||||
|
||||
const selectedDonation = useMemo(
|
||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||
[payments, paymentId]
|
||||
);
|
||||
|
||||
const donationRemaining = selectedDonation
|
||||
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
|
||||
: 0;
|
||||
|
||||
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0;
|
||||
|
||||
// The most that can be allocated: never more than the donation itself, never more than
|
||||
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
|
||||
// The most that can be allocated: never more than the donation's remaining balance, never
|
||||
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
|
||||
const maxAllocatable = useMemo(() => {
|
||||
if (!selectedDonation) return 0;
|
||||
return Math.min(selectedDonation.amount || 0, outstanding);
|
||||
}, [selectedDonation, outstanding]);
|
||||
return Math.min(donationRemaining, outstanding);
|
||||
}, [selectedDonation, donationRemaining, outstanding]);
|
||||
|
||||
// Default to "apply as much as needed" whenever a new donation is picked — the common
|
||||
// case needs no typing, but the field stays editable for a deliberate partial allocation.
|
||||
@@ -1055,7 +1091,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
}, [paymentId]);
|
||||
|
||||
const amountNum = parseFloat(amountStr || "0");
|
||||
const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0;
|
||||
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
|
||||
|
||||
const assign = async () => {
|
||||
if (!token) return;
|
||||
@@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
>
|
||||
<option value="">Select donation…</option>
|
||||
{donationsForEvent.map((p: any) => {
|
||||
const label = `R ${(p.amount || 0).toFixed(2)} — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
|
||||
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||
})}
|
||||
</select>
|
||||
{registrationId && donationsForEvent.length === 0 && (
|
||||
<div className="text-xs text-gray-500">No unassigned donations for this event.</div>
|
||||
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
|
||||
)}
|
||||
|
||||
{selectedDonation && (
|
||||
@@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
onChange={e => setAmountStr(e.target.value)}
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
|
||||
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.</>}
|
||||
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}.
|
||||
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user