Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74fec3a5c | ||
|
|
4bee1b24f7 |
@@ -7,6 +7,21 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.4.1] - 2026-08-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -365,8 +365,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
|
|||||||
const CARD_ALIASES = ['apple_pay', 'google_pay'];
|
const CARD_ALIASES = ['apple_pay', 'google_pay'];
|
||||||
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
|
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();
|
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 (USER_FACING_METHODS.includes(m)) return m;
|
||||||
if (CARD_ALIASES.includes(m)) return 'card';
|
if (CARD_ALIASES.includes(m)) return 'card';
|
||||||
return 'other';
|
return 'other';
|
||||||
@@ -394,18 +402,21 @@ const getUserPayments = async (req, res) => {
|
|||||||
if (req.query.method) {
|
if (req.query.method) {
|
||||||
const requested = String(req.query.method).toLowerCase();
|
const requested = String(req.query.method).toLowerCase();
|
||||||
if (requested === 'card') {
|
if (requested === 'card') {
|
||||||
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
|
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
|
||||||
// settlement as a card payment, no separate float to reconcile.
|
// refunds — same settlement as a card payment, no separate float to reconcile.
|
||||||
where.AND = [{
|
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') {
|
} 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 = {
|
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)) {
|
} 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
|
// Donations are never mutated once created — their remaining balance is the original
|
||||||
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
|
// 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({
|
const existingLegs = await prisma.payment.findMany({
|
||||||
where: { originalPaymentId: payment.id, isDonation: false }
|
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;
|
const remainingDonation = payment.amount - alreadyUsed;
|
||||||
|
|
||||||
if (remainingDonation <= 0.000001) {
|
if (remainingDonation <= 0.000001) {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { registrationId, eventId } = req.body || {};
|
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
|
||||||
|
|
||||||
const ytx = await Yoco.findUnique({ where: { id } });
|
const ytx = await Yoco.findUnique({ where: { id } });
|
||||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
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;
|
let resolvedUserId = null;
|
||||||
if (registration?.userId) {
|
if (registration?.userId) {
|
||||||
resolvedUserId = 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);
|
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
||||||
try {
|
try {
|
||||||
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
||||||
|
|||||||
@@ -103,18 +103,23 @@ async function computeEventFinancials(eventId) {
|
|||||||
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real inflows only — excludes donation-application legs, which would otherwise double-count
|
// Excludes donation-application legs, which would otherwise double-count money already
|
||||||
// money already counted once via the source donation (e.g. a R250 donation with R50 assigned
|
// counted once via the source donation (e.g. a R250 donation with R50 assigned to a
|
||||||
// to a registration must total R250 received, not R300).
|
// registration must total R250 received, not R300). Refunds (negative amount) are kept in —
|
||||||
const nonRefundPayments = payments.filter(p => p.amount > 0 && !isDonationLeg(p));
|
// 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
|
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
|
||||||
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
|
// 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
|
// 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();
|
const legsByDonationId = new Map();
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
if (p.originalPaymentId && !p.isDonation) {
|
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);
|
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 totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
const paymentsByMethod = emptyByMethod();
|
const paymentsByMethod = emptyByMethod();
|
||||||
for (const p of nonRefundPayments) {
|
for (const p of realPayments) {
|
||||||
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
||||||
}
|
}
|
||||||
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
|
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ function isDonationLeg(p: any): boolean {
|
|||||||
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
|
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 }: {
|
function RegistrationOptions({ regs, regOutstanding }: {
|
||||||
regs: any[];
|
regs: any[];
|
||||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||||
@@ -514,7 +526,7 @@ function PaymentsContent() {
|
|||||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authToken: token,
|
authToken: token,
|
||||||
body: { eventId }
|
body: { eventId, userId: userId || undefined }
|
||||||
});
|
});
|
||||||
setInfo('Reconciled as donation');
|
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>}
|
{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>}
|
{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`}>
|
<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')} />
|
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
|
||||||
Payment
|
Payment
|
||||||
@@ -651,7 +663,11 @@ function PaymentsContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid sm:grid-cols-3 gap-3 items-end">
|
<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>
|
<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 }))}>
|
<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>
|
<option value="">Select event…</option>
|
||||||
@@ -879,7 +895,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
const [paymentId, setPaymentId] = useState<string>("");
|
const [paymentId, setPaymentId] = useState<string>("");
|
||||||
const [registrationId, setRegistrationId] = useState<string>("");
|
const [registrationId, setRegistrationId] = useState<string>("");
|
||||||
const [amount, setAmount] = useState<string>("");
|
const [amount, setAmount] = useState<string>("");
|
||||||
const [method, setMethod] = useState<string>('refund');
|
const [method, setMethod] = useState<string>("");
|
||||||
const [reason, setReason] = useState<string>('');
|
const [reason, setReason] = useState<string>('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -897,10 +913,14 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
}, [userId, target]);
|
}, [userId, target]);
|
||||||
|
|
||||||
useEffect(() => {
|
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') {
|
if (target === 'payment') {
|
||||||
const p = payments.find(pp => String(pp.id) === String(paymentId));
|
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]);
|
}, [paymentId, target, payments]);
|
||||||
|
|
||||||
@@ -914,6 +934,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
if (!userId) { setErr('Select a user'); return; }
|
if (!userId) { setErr('Select a user'); return; }
|
||||||
if (target === 'payment' && !paymentId) { setErr('Select a payment to refund'); 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 (target === 'registration' && !registrationId) { setErr('Select a registration to refund against'); return; }
|
||||||
|
if (!method) { setErr('Select a refund method'); return; }
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await apiFetch('/api/payments/refund', {
|
await apiFetch('/api/payments/refund', {
|
||||||
@@ -928,7 +949,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
reason: reason || undefined
|
reason: reason || undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason("");
|
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||||
await onDone();
|
await onDone();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message || 'Failed to create refund');
|
setErr(e?.message || 'Failed to create refund');
|
||||||
@@ -981,11 +1002,13 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
<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)}>
|
<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="cash-refund">Cash refund</option>
|
||||||
<option value="eft-refund">EFT refund</option>
|
|
||||||
<option value="card-refund">Card refund</option>
|
<option value="card-refund">Card refund</option>
|
||||||
|
<option value="eft-refund">EFT refund</option>
|
||||||
|
<option value="voucher-refund">Voucher refund</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
|
<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"
|
// A donation is never mutated once assigned — each assignment creates a separate "leg"
|
||||||
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
|
// 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
|
// 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 legsById = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
payments.forEach((p: any) => {
|
payments.forEach((p: any) => {
|
||||||
if (p.originalPaymentId && !p.isDonation) {
|
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;
|
return m;
|
||||||
|
|||||||
@@ -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) => {
|
const getPaymentMethod = (p: any) => {
|
||||||
try {
|
const m = String(p?.method || "").toLowerCase();
|
||||||
if (p?.externalId) return "Yoco Portal";
|
if (m.includes("cash")) return "Cash";
|
||||||
} catch {}
|
if (m.includes("eft")) return "EFT";
|
||||||
return p?.method || "Unknown";
|
if (m.includes("card") || m.includes("yoco") || m.includes("pay") || p?.externalId) return "Card";
|
||||||
|
return "Other";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derived rows for payments (apply date filter)
|
// Derived rows for payments (apply date filter)
|
||||||
@@ -474,9 +479,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
|||||||
const evTitle = ev?.title || evId;
|
const evTitle = ev?.title || evId;
|
||||||
const evPayments = paymentsByEvent[evId] || [];
|
const evPayments = paymentsByEvent[evId] || [];
|
||||||
evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => {
|
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
|
const used = evPayments
|
||||||
.filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id)
|
.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({
|
rows.push({
|
||||||
eventId: evId,
|
eventId: evId,
|
||||||
eventTitle: evTitle,
|
eventTitle: evTitle,
|
||||||
@@ -858,9 +866,11 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (report === "donations") {
|
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) =>
|
const usedFor = (evId: string, donationId: string) =>
|
||||||
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
|
(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)[][] = [];
|
const rows: (string|number)[][] = [];
|
||||||
let grandTotal = 0, grandUsed = 0;
|
let grandTotal = 0, grandUsed = 0;
|
||||||
Object.keys(paymentsByEvent).forEach(evId => {
|
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)
|
// sum of every leg (isDonation:false, originalPaymentId -> the donation)
|
||||||
// referencing it, which — since a donation and its legs share the event
|
// 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
|
// 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) =>
|
const usedFor = (evId: string, donationId: string) =>
|
||||||
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
|
(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;
|
let grandTotal = 0, grandUsed = 0, grandCount = 0;
|
||||||
const perEvent = Object.keys(paymentsByEvent).map(evId => {
|
const perEvent = Object.keys(paymentsByEvent).map(evId => {
|
||||||
const ev = filteredEvents.find(e => e.id === evId);
|
const ev = filteredEvents.find(e => e.id === evId);
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ const METHOD_LABELS: Record<string, string> = {
|
|||||||
other: "Other",
|
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 {
|
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 (USER_FACING_METHODS.includes(m)) return m;
|
||||||
if (CARD_ALIASES.includes(m)) return "card";
|
if (CARD_ALIASES.includes(m)) return "card";
|
||||||
return "other";
|
return "other";
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user