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
+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);