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>
This commit is contained in:
2026-08-06 09:08:53 +02:00
co-authored by Claude Sonnet 5
parent 70605923bf
commit 4bee1b24f7
7 changed files with 125 additions and 39 deletions
@@ -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;
+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";