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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user