Compare commits

..
Author SHA1 Message Date
joshuaandClaude Sonnet 5 d74fec3a5c Bump version to 1.4.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:13:04 +02:00
joshuaandClaude Sonnet 5 4bee1b24f7 Fix supervisor payments mobile tabs, reconcile donor, report method buckets, and refund/donation accounting
- Supervisor Payments: mode tabs now wrap on mobile instead of overflowing off-screen.
- Reconciling a Yoco transaction as a donation now lets staff pick who it's from.
- Reports payment-method breakdown now buckets into Cash/Card/EFT/Other everywhere,
  folding Apple Pay, Google Pay, and Yoco-portal payments into Card.
- Refunds now net against their original method's bucket (Cashup/Finance/Profit reports,
  My Payments filtering/display) instead of vanishing or falling into "Other".
- Refund form's method dropdown mirrors the real payment methods and auto-fills from the
  payment being refunded, replacing an ambiguous generic "Refund" option.
- Fixed donation remaining/unallocated balance inflating instead of shrinking when a
  donation is refunded (assign-donation endpoint, cashup reports, donations reports,
  and the Assign Donation panel all summed refund legs with the wrong sign).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:08:53 +02:00
joshuaandClaude Sonnet 5 70605923bf Bump version to 1.4.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:00:44 +02:00
joshuaandClaude Sonnet 5 424141f216 Fix Reports mobile layout overflow and desktop date range overflow
Report popup buttons and the page's search/Back controls overflowed
off-screen on mobile instead of wrapping, and the sidebar's custom
date range inputs spilled outside the filter box on desktop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:57:09 +02:00
joshuaandClaude Sonnet 5 d6ea4c37d7 Sync root package.json version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:49:03 +02:00
joshua ebf521e64e Merge branch 'feature/reports-and-cashup-overhaul' (1.4.0) 2026-08-04 15:46:55 +02:00
12 changed files with 176 additions and 64 deletions
+23
View File
@@ -7,6 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.4.2] - 2026-08-06
### Fixed
- Supervisor Payments page: the mode tabs (Payment/Refund/Donations/Reconcile/Payment Link) now wrap onto multiple lines on mobile instead of overflowing off-screen and becoming unreachable.
- Reports: the payments report and its "by method" breakdown/chart now bucket into the same 4 categories used everywhere else (Cash, Card, EFT, Other) instead of showing raw method strings — Apple Pay, Google Pay, and Yoco checkout-portal payments now fold into "Card".
- Cashup/Finance/Profit reports: refunds were being dropped entirely from the per-method breakdown (`paymentsByMethod`) instead of netting against the method they were refunded against, so e.g. a card refund silently vanished instead of reducing the "Card" total — the per-method figures now correctly sum back to total revenue.
- Refund method now nets against the correct bucket everywhere a payment's method is normalized for display/filtering (My Payments page, reports) — `card-refund` etc. was falling through to "Other" instead of being recognized as a refund of its base method.
- Supervisor Payments page: the Refund form's method dropdown now mirrors the actual payment methods (Cash/Card/EFT/Voucher refund) instead of offering an ambiguous generic "Refund" option that couldn't be attributed to any method bucket; it now auto-fills from the original payment's method when refunding a specific payment.
- Donations: refunding a donation (fully or partially) was inflating its remaining/unallocated balance by the refunded amount instead of reducing it, since the refund's negative amount was subtracted straight into the balance (subtracting a negative adds). Could let staff over-allocate a donation that had actually shrunk. Fixed in the donation-assignment leg totals used by the assign-donation endpoint, the Cashup/Finance/Profit reports' unallocated-donations figure, the Donations and Master Orders reports' Used/Unused breakdown, and the "Assign donation" panel on the Supervisor Payments page.
### Added
- Supervisor Payments page: reconciling a Yoco transaction as a donation now lets staff optionally pick who the donation is from, instead of it always being attributed to whatever the checkout metadata (or the reconciling staff member) happened to resolve to.
## [1.4.1] - 2026-08-05
### Fixed
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
## [1.4.0] - 2026-08-04
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.4.0",
"version": "1.4.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+23 -9
View File
@@ -365,8 +365,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
// against the same bucket its original payment counted under. Strip that suffix before
// bucketing so a card refund still displays/filters as "card", not "other".
function stripRefundSuffix(method) {
const m = String(method || '').toLowerCase();
return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m;
}
function normalizeUserMethod(method) {
const m = stripRefundSuffix(method);
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
@@ -394,18 +402,21 @@ const getUserPayments = async (req, res) => {
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
// refunds — same settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
// "Other" covers every method (and its refund variant) that isn't one of the
// recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
where.AND = [{
OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
}
}
@@ -601,11 +612,14 @@ const assignDonationToRegistration = async (req, res) => {
// 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.
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + leg.amount, 0);
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
if (remainingDonation <= 0.000001) {
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
}
const { id } = req.params;
const { registrationId, eventId } = req.body || {};
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
@@ -118,7 +118,15 @@ const reconcileYocoTransaction = async (req, res) => {
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (ytx?.raw?.payload?.metadata?.userId) {
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
+12 -7
View File
@@ -103,18 +103,23 @@ async function computeEventFinancials(eventId) {
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
// 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));
// 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). Refunds (negative amount) are kept in —
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
// the per-method breakdown while still being netted out of totalRevenue below.
const realPayments = payments.filter(p => !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".
// that already references it, not simply "every donation with no registrationId". A refund
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
// subtract a negative, adding the refund back on top).
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);
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
}
}
const donationPayments = payments.filter(p => p.isDonation);
@@ -123,7 +128,7 @@ async function computeEventFinancials(eventId) {
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
for (const p of realPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.4.0",
"version": "1.4.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -16,6 +16,18 @@ function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
// Refund methods must mirror the real payment methods (cash/card/eft/voucher) so a refund
// nets against the same bucket its original payment counted under in reports — a card payment
// refunded as "cash-refund" would wrongly drain the cash float and leave card overstated.
function refundMethodForOriginal(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
if (m.includes("cash")) return "cash-refund";
if (m.includes("eft")) return "eft-refund";
if (m.includes("voucher")) return "voucher-refund";
if (m.includes("card") || m.includes("yoco") || m.includes("pay")) return "card-refund";
return "";
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -514,7 +526,7 @@ function PaymentsContent() {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { eventId }
body: { eventId, userId: userId || undefined }
});
setInfo('Reconciled as donation');
}
@@ -547,7 +559,7 @@ function PaymentsContent() {
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="mb-4 flex items-center gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
Payment
@@ -651,7 +663,11 @@ function PaymentsContent() {
</div>
) : (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div className="sm:col-span-2">
<div>
<label className="block text-xs text-gray-600 mb-1">From (donor, optional)</label>
<UserSearchField allUsers={allUsers} value={reconcileForm.userId} onChange={uid => setReconcileForm(prev => ({ ...prev, userId: uid }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
<option value="">Select event</option>
@@ -879,7 +895,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
const [paymentId, setPaymentId] = useState<string>("");
const [registrationId, setRegistrationId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [method, setMethod] = useState<string>('refund');
const [method, setMethod] = useState<string>("");
const [reason, setReason] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -897,10 +913,14 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
}, [userId, target]);
useEffect(() => {
// If a payment selected, default amount to that payment's amount
// If a payment selected, default amount and refund method to that payment's own —
// still editable, but staff shouldn't have to remember to change it manually.
if (target === 'payment') {
const p = payments.find(pp => String(pp.id) === String(paymentId));
if (p) setAmount(String(Math.abs(p.amount || 0)));
if (p) {
setAmount(String(Math.abs(p.amount || 0)));
setMethod(refundMethodForOriginal(p.method));
}
}
}, [paymentId, target, payments]);
@@ -914,6 +934,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
if (!userId) { setErr('Select a user'); return; }
if (target === 'payment' && !paymentId) { setErr('Select a payment to refund'); return; }
if (target === 'registration' && !registrationId) { setErr('Select a registration to refund against'); return; }
if (!method) { setErr('Select a refund method'); return; }
try {
setSubmitting(true);
await apiFetch('/api/payments/refund', {
@@ -928,7 +949,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
reason: reason || undefined
}
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason("");
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to create refund');
@@ -981,11 +1002,13 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="refund">Refund</option>
<option value="">Select method</option>
<option value="cash-refund">Cash refund</option>
<option value="eft-refund">EFT refund</option>
<option value="card-refund">Card refund</option>
<option value="eft-refund">EFT refund</option>
<option value="voucher-refund">Voucher refund</option>
</select>
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded this is what nets against that method's total in reports.</div>
</div>
</div>
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
@@ -1041,12 +1064,15 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
// 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.
// (and its registrationId stays null forever) until fully used up. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces the
// remaining balance instead of inflating it (a raw signed sum would subtract a negative,
// adding the refund back on top of what's left to allocate).
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));
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
}
});
return m;
@@ -22,24 +22,34 @@ export default function ReportViewerModal({
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="absolute inset-0 flex items-start justify-center p-4 overflow-auto">
<div className="w-full max-w-6xl bg-white rounded-xl shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-indigo-600" />
<div className="border-b">
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-indigo-600" />
</div>
)}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
</div>
)}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{actions}
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
<div className="hidden sm:flex items-center gap-2 flex-wrap shrink-0 ml-auto">
{actions}
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<button className="sm:hidden p-2 rounded-lg hover:bg-gray-100 shrink-0" onClick={onClose} aria-label="Close">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{actions && (
<div className="flex sm:hidden items-center gap-2 flex-wrap px-5 pb-4">
{actions}
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
@@ -67,23 +67,23 @@ export default function ReportsShell({
return (
<div>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="relative flex-1 min-w-0 sm:flex-none">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
className="w-full sm:w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
placeholder="Search reports…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{onBack && (
<button type="button" className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
<ArrowLeft className="w-4 h-4" /> Back
</button>
)}
@@ -128,9 +128,15 @@ export default function ReportsShell({
<option value="custom">Custom</option>
</select>
{datePreset === "custom" && (
<div className="flex gap-2">
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
<div className="space-y-2">
<div>
<div className="text-[11px] text-gray-500 mb-0.5">From</div>
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
</div>
<div>
<div className="text-[11px] text-gray-500 mb-0.5">To</div>
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
</div>
)}
</div>
+21 -9
View File
@@ -336,12 +336,17 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
};
// Payment method label mapping
// Payment method label mapping — bucket into the same 4 categories used by the finance/cashup
// reports (cash, card, eft, other). Card-network wallets (Apple Pay, Google Pay) and Yoco
// checkout-portal payments settle exactly like a card — no separate float to reconcile — so
// they fold into "Card" rather than showing as their own categories. Mirrors bucketForMethod
// in backend/src/utils/cashupUtils.js.
const getPaymentMethod = (p: any) => {
try {
if (p?.externalId) return "Yoco Portal";
} catch {}
return p?.method || "Unknown";
const m = String(p?.method || "").toLowerCase();
if (m.includes("cash")) return "Cash";
if (m.includes("eft")) return "EFT";
if (m.includes("card") || m.includes("yoco") || m.includes("pay") || p?.externalId) return "Card";
return "Other";
};
// Derived rows for payments (apply date filter)
@@ -474,9 +479,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const evTitle = ev?.title || evId;
const evPayments = paymentsByEvent[evId] || [];
evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => {
// A refund of the donation itself also creates a leg (originalPaymentId -> donation),
// with a negative amount — Math.abs() so it reduces "used" (money no longer available)
// instead of a raw signed sum subtracting a negative and inflating "unused" below.
const used = evPayments
.filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id)
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
rows.push({
eventId: evId,
eventTitle: evTitle,
@@ -858,9 +866,11 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
};
}
if (report === "donations") {
// Math.abs(): a refund of the donation itself also creates a leg (originalPaymentId ->
// donation) with a negative amount, which must reduce "used" rather than inflate "unused".
const usedFor = (evId: string, donationId: string) =>
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
const rows: (string|number)[][] = [];
let grandTotal = 0, grandUsed = 0;
Object.keys(paymentsByEvent).forEach(evId => {
@@ -1531,10 +1541,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// sum of every leg (isDonation:false, originalPaymentId -> the donation)
// referencing it, which — since a donation and its legs share the event
// it was logged against in the common case — are already present in the
// same per-event payments list.
// same per-event payments list. Math.abs(): a refund of the donation itself
// also creates such a leg, with a negative amount, which must reduce "used"
// rather than inflate "unused" via a raw signed sum.
const usedFor = (evId: string, donationId: string) =>
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
let grandTotal = 0, grandUsed = 0, grandCount = 0;
const perEvent = Object.keys(paymentsByEvent).map(evId => {
const ev = filteredEvents.find(e => e.id === evId);
+9 -1
View File
@@ -13,8 +13,16 @@ const METHOD_LABELS: Record<string, string> = {
other: "Other",
};
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
// against the same bucket its original payment counted under. Strip that suffix before
// bucketing so a card refund still displays as "Card", not "Other" — the negative amount
// already signals it's a refund.
function stripRefundSuffix(method: string): string {
return method.endsWith("-refund") ? method.slice(0, -"-refund".length) : method;
}
export function normalizePaymentMethod(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
const m = stripRefundSuffix(String(method || "").toLowerCase());
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return "card";
return "other";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.3.2",
"version": "1.4.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",