"use client"; import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { scoreUser } from "@/lib/fuzzyMatch"; import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react"; // A donation is never mutated once created — assigning it to a registration creates a separate // "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. // That leg is not new money: it just re-labels part of an already-counted donation as applied // to a registration. Money stats/lists must count each real inflow exactly once, so legs are // excluded — the money was already counted via the original donation row. 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; }) { return ( <> {regs.map((r: any) => { const out = regOutstanding[r.id]?.outstanding ?? 0; const label = `${r.event?.title || r.eventId || 'Event'} — ${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`; return ( ); })} ); } function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: { allUsers: any[]; value: string; onChange: (userId: string) => void; placeholder?: string; disabled?: boolean; }) { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const ref = useRef(null); // Sync display name when value changes externally (e.g. pre-fill from metadata) useEffect(() => { const u = allUsers.find(u => String(u.id) === String(value)); setQuery(u?.name || ""); }, [value, allUsers]); const matches = useMemo(() => { if (query.trim().length < 2) return []; return allUsers .map(u => ({ u, score: scoreUser(u, query) })) .filter(x => x.score >= 0.45) .sort((a, b) => b.score - a.score) .slice(0, 8) .map(x => x.u); }, [query, allUsers]); useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); return (
{ setQuery(e.target.value); setOpen(true); if (!e.target.value) onChange(""); }} onFocus={() => { if (query.length >= 2) setOpen(true); }} /> {value && ( )} {open && query.trim().length >= 2 && (
{matches.length > 0 ? ( <>
{matches.length} result{matches.length !== 1 ? "s" : ""}
{matches.map(u => ( ))} ) : (
No matching users found.
)}
)}
); } function PaymentsContent() { const { user, loading, token } = useAuth(); const router = useRouter(); const search = useSearchParams(); const canView = useMemo(() => { const role = user?.role; return role === "admin" || role === "supervisor"; }, [user]); useEffect(() => { if (loading) return; if (!user) router.replace("/login"); }, [user, loading, router]); const [payments, setPayments] = useState([]); const [loadingList, setLoadingList] = useState(false); const [error, setError] = useDismissingState(null); const [info, setInfo] = useDismissingState(null); const [registrations, setRegistrations] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); const [regOutstanding, setRegOutstanding] = useState>({}); const loadPayments = async () => { if (!token) return; try { setLoadingList(true); const list = await fetchAllPayments(token); setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())); } catch (e: any) { setError(e?.message || "Failed to load payments"); } finally { setLoadingList(false); } }; useEffect(() => { loadPayments(); }, [token]); // Compute effective unit price for a registration option. // Uses priceSnapshot when available (authoritative backend price, variant-aware). // Falls back to deadline-only early-bird calculation for legacy rows without a snapshot. const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => { if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) { return Number(opt.priceSnapshot); } const eo = opt.eventOption; const variantId: string | null = opt.variantId || null; const base = (opt.variant?.price !== null && opt.variant?.price !== undefined) ? Number(opt.variant.price) : Number(eo?.price || 0); const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : []; const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId); if (tiers.length === 0) return base; const t = atTime ? new Date(atTime) : new Date(); const ref = referenceTime ? new Date(referenceTime) : t; const applicable = tiers .map((x: any) => ({ ...x, deadline: new Date(x.deadline) })) .filter((x: any) => (ref < x.deadline) && (t < x.deadline)) .sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price); if (applicable.length === 0) return base; const price = Number(applicable[0].price); return (price >= 0) ? price : base; }; // Load registrations for dropdowns. // GET /api/registrations already embeds each registration's `payments`, so outstanding // balances are computed from that in one pass — no per-registration follow-up requests. const loadRegistrations = async () => { if (!token) return; try { setLoadingRegs(true); const regs = await apiFetch("/api/registrations", { authToken: token }); const list = Array.isArray(regs) ? regs : []; const now = new Date(); const map: Record = {}; for (const r of list) { // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans // multiple early-bird prices) — fall back to the old client-side estimate otherwise. const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0); map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; } setRegOutstanding(map); // Sort by createdAt desc const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); setRegistrations(sorted); } catch (e) { // ignore } finally { setLoadingRegs(false); } }; useEffect(() => { loadRegistrations(); }, [token]); // Load all users for Refund section useEffect(() => { (async () => { if (!token) return; try { setLoadingUsers(true); const users = await fetchAllUsers(token); setAllUsers(users); } catch (e) { // ignore; fallback to derived users from registrations } finally { setLoadingUsers(false); } })(); }, [token]); // Create payment form const [amount, setAmount] = useState(""); const [method, setMethod] = useState("cash"); const [registrationId, setRegistrationId] = useState(""); const [isDonation, setIsDonation] = useState(false); const [eventId, setEventId] = useState(""); const [events, setEvents] = useState([]); const [submitting, setSubmitting] = useState(false); // Backdate field const [paidAtLocal, setPaidAtLocal] = useState(""); // Users for Refund section (load all users with supervisor rights) const [allUsers, setAllUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); // New: user selection states for split dropdowns const [selectedUserId, setSelectedUserId] = useState(""); // Derived lists for users and outstanding registrations const outstandingRegs = useMemo(() => { return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0)); }, [registrations, regOutstanding]); // Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations) const usersList = useMemo(() => { if (allUsers && allUsers.length > 0) { return [...allUsers] //.filter(u => u?.isActive !== false) .map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) })) .sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any)); } const map = new Map(); for (const r of outstandingRegs) { const uid = r.user?.id || r.userId; if (!uid) continue; const name = r.user?.name || r.user?.email || String(r.userId || uid); if (!map.has(String(uid))) { map.set(String(uid), String(name)); } } return Array.from(map.entries()) .map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any)); }, [allUsers, outstandingRegs]); const regsForSelectedUser = useMemo(() => { return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId))); }, [outstandingRegs, selectedUserId]); // Payment link tab state const [linkUserId, setLinkUserId] = useState(""); const [linkRegistrationId, setLinkRegistrationId] = useState(""); const [linkAmount, setLinkAmount] = useState(""); const [linkGenerating, setLinkGenerating] = useState(false); const [linkResult, setLinkResult] = useState<{ redirectUrl: string; amount: number; registrationId: string } | null>(null); const [linkSending, setLinkSending] = useState<"email" | "whatsapp" | null>(null); const [linkCopied, setLinkCopied] = useState(false); const regsForLinkUser = useMemo(() => { return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId))); }, [outstandingRegs, linkUserId]); useEffect(() => { if (!linkRegistrationId) return; const out = regOutstanding[linkRegistrationId]?.outstanding ?? 0; setLinkAmount(out > 0 ? out.toFixed(2) : ""); setLinkResult(null); }, [linkRegistrationId, regOutstanding]); const generatePaymentLink = async () => { if (!token || !linkRegistrationId) return; setError(null); setInfo(null); setLinkCopied(false); const amt = parseFloat(linkAmount || "0"); if (!amt || amt <= 0) { setError("Enter a valid amount"); return; } try { setLinkGenerating(true); const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/yoco-checkout", { method: "POST", authToken: token, body: { registrationId: linkRegistrationId, amount: amt, successUrl: window.location.origin + "/payment/success", cancelUrl: window.location.origin + "/payment/cancel", failureUrl: window.location.origin + "/payment/failure", }, }); if (res.priceUpdated) { setError(res.message || "Pricing has changed for this registration; please re-check the amount."); return; } if (!res.redirectUrl) { setError("Failed to generate link"); return; } setLinkResult({ redirectUrl: res.redirectUrl, amount: res.amount ?? amt, registrationId: linkRegistrationId }); setInfo("Payment link generated"); } catch (e: any) { setError(e?.message || "Failed to generate payment link"); } finally { setLinkGenerating(false); } }; const copyPaymentLink = async () => { if (!linkResult) return; try { await navigator.clipboard.writeText(linkResult.redirectUrl); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); } catch { setError("Could not copy link — copy it manually"); } }; const sendPaymentLink = async (channel: "email" | "whatsapp") => { if (!token || !linkResult) return; setError(null); setInfo(null); try { setLinkSending(channel); await apiFetch("/api/payments/yoco-checkout/send", { method: "POST", authToken: token, body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel }, }); setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`); } catch (e: any) { setError(e?.message || `Failed to send link via ${channel}`); } finally { setLinkSending(null); } }; useEffect(() => { const regQ = search?.get("registrationId"); if (regQ) setRegistrationId(regQ); }, [search]); // If a registration is preselected via query, infer and set its user useEffect(() => { if (!registrationId) return; const reg = registrations.find((r: any) => String(r.id) === String(registrationId)); const uid = reg?.user?.id || reg?.userId; if (uid) setSelectedUserId(String(uid)); }, [registrationId, registrations]); const [eventsIncludePast, setEventsIncludePast] = useState(false); const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false); useEffect(() => { (async () => { try { if (!token) return; const params = new URLSearchParams(); if (eventsIncludePast) params.set("includePast", "true"); if (eventsIncludeInactive) params.set("includeInactive", "true"); const qs = params.toString() ? `?${params.toString()}` : ""; const evs = await apiFetch(`/api/events/all${qs}`, { authToken: token }); setEvents(evs || []); } catch {} })(); }, [token, eventsIncludePast, eventsIncludeInactive]); useEffect(() => { // Keep inputs sensible when toggling donation if (isDonation) { setRegistrationId(""); } else { setEventId(""); } }, [isDonation]); const createPayment = async () => { if (!token) return; setError(null); setInfo(null); const amt = parseFloat(amount || "0"); if (!amt || amt <= 0) { setError("Enter a valid amount"); return; } if (!registrationId && !isDonation) { setError("Registration required unless this is a donation"); return; } if (isDonation && !eventId && !registrationId) { setError("Select an event for donations"); return; } try { setSubmitting(true); const res = await apiFetch("/api/payments", { method: "POST", authToken: token, body: { amount: amt, method, userId: selectedUserId || undefined, registrationId: registrationId || undefined, eventId: eventId || undefined, isDonation: !!isDonation, paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined, } }); setInfo(`Payment created (R ${amt.toFixed(2)})`); setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal(""); await Promise.all([loadPayments(), loadRegistrations()]); } catch (e: any) { setError(e?.message || "Failed to create payment"); } finally { setSubmitting(false); } }; // Stats const todayTotals = useMemo(() => { const start = new Date(); start.setHours(0,0,0,0); // Exclude donation-application legs — that money was already counted once, as the donation. const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p)); const revenue = today.reduce((s,p)=> s + (p.amount||0), 0); const donations = today.filter(p => p.isDonation).length; return { revenue, donations, count: today.length }; }, [payments]); const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment"); // Yoco unreconciled list and actions const [yocoLoading, setYocoLoading] = useState(false); const [yocoTxs, setYocoTxs] = useState([]); const loadYocoUnreconciled = async () => { if (!token) return; try { setYocoLoading(true); const res = await apiFetch("/api/yoco-transactions/unreconciled", { authToken: token }); const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []); setYocoTxs(items); } catch (e) { // ignore silently, shown on demand via error state if needed } finally { setYocoLoading(false); } }; useEffect(() => { loadYocoUnreconciled(); }, [token]); // Inline reconcile form state per-row const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false }); const openReconcileToRegistration = (tx: any) => { const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : ''; setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false }); }; const openReconcileAsDonation = (tx: any) => { const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : ''; setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false }); }; const regsForUser = (uid: string) => registrations .filter((r: any) => String(r.user?.id || r.userId) === String(uid)) .filter((r: any) => { const out = regOutstanding[r.id]?.outstanding ?? 0; return out > 0.000001; // show only registrations with an outstanding balance }); const submitReconcile = async () => { if (!token) return; const { txId, type, userId, registrationId, eventId } = reconcileForm; if (!txId || !type) return; try { setReconcileForm(prev => ({ ...prev, submitting: true })); if (type === 'reg') { if (!registrationId) { setError('Please select a registration'); return; } await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, { method: 'POST', authToken: token, body: { registrationId } }); setInfo('Reconciled to registration'); } else { if (!eventId) { setError('Please select an event'); return; } await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, { method: 'POST', authToken: token, body: { eventId, userId: userId || undefined } }); setInfo('Reconciled as donation'); } setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false }); await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]); } catch (e: any) { setError(e?.message || 'Failed to reconcile'); } finally { setReconcileForm(prev => ({ ...prev, submitting: false })); } }; const cancelReconcile = () => setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false }); return (

Payments

{!canView && (
You need supervisor or admin access to use this page.
)} {error &&
{error}
} {info &&
{info}
}
{/* Yoco reconciliation panel */} {mode === 'reconcile' && (
Unreconciled Yoco Payments
{yocoTxs.length === 0 ? (
No unreconciled Yoco transactions.
) : (
{yocoTxs.map(tx => ( {reconcileForm.txId === tx.id && ( )} ))}
Created External ID Amount Checkout Method Actions
{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'} {tx.externalId} R {(Number(tx.amount || 0)/100).toFixed(2)} {tx.checkoutId || '-'} {tx.methodType || '-'}
{reconcileForm.type === 'reg' ? (
setReconcileForm(prev => ({ ...prev, userId: uid, registrationId: '' }))} />
) : (
setReconcileForm(prev => ({ ...prev, userId: uid }))} />
)}
)}
)} {mode === 'payment' && (
Create payment
setAmount(e.target.value)} />
{ setSelectedUserId(uid); setRegistrationId(""); }} />
{loadingRegs &&
Loading registrations…
}
setIsDonation(e.target.checked)} />
{user?.role === "admin" && ( )}
setPaidAtLocal(e.target.value)} max={new Date().toISOString().slice(0,16)} />
Leave blank to use current time
)} {mode === 'refund' && ( <> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))} regOutstanding={regOutstanding} onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }} /> {loadingUsers &&
Loading users…
} )} {mode === 'donation' && ( <> { await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }} /> {loadingUsers &&
Loading users…
} { await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }} /> )} {mode === 'link' && (
Create payment link
{ setLinkUserId(uid); setLinkRegistrationId(""); setLinkResult(null); }} />
setLinkAmount(e.target.value)} disabled={!linkRegistrationId} />
Defaults to the outstanding balance; minimum R15 for a partial payment.
{linkResult && (
Link for R {linkResult.amount.toFixed(2)}:
{linkResult.redirectUrl}
Generating a new link for this registration will replace this one — the old link will no longer be honored.
)}
)}
Recent payments
{loadingList && Loading…}
    {payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => { const amt = p.amount || 0; const isRefund = amt < 0; return (
  • {isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? (donation) : null} {isRefund ? (refund) : null}
    {new Date(p.createdAt).toLocaleString()}
    Method: {p.method || 'payment'}
    {(p.registration?.user?.name || p.user?.name) &&
    Name: {p.registration?.user?.name || p.user?.name}
    } {p.registrationId &&
    Registration: #{String(p.registrationId).slice(0,8)}
    } {p.eventId &&
    Event: {p.event?.title || p.eventId}
    } {p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
    Recorded by: {p.recordedBy.name}
    )}
  • ); })} {payments.length === 0 &&
  • No payments yet.
  • }
Today
Payments
{todayTotals.count}
Revenue
R {todayTotals.revenue.toFixed(2)}
Donations
{todayTotals.donations}
); } type RefundSectionProps = { payments: any[]; allUsers: any[]; usersList: { id: string; name: string }[]; regsForUser: (userId: string) => any[]; regOutstanding: Record; onDone: () => void | Promise; }; function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstanding, onDone }: RefundSectionProps) { const { token } = useAuth(); const [userId, setUserId] = useState(""); const [target, setTarget] = useState<'payment'|'registration'>('payment'); const [paymentId, setPaymentId] = useState(""); const [registrationId, setRegistrationId] = useState(""); const [amount, setAmount] = useState(""); const [method, setMethod] = useState(""); const [reason, setReason] = useState(''); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); const paymentsForUser = useMemo(() => { if (!userId) return [] as any[]; return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0); }, [payments, userId]); useEffect(() => { // Reset dependent fields on changes setPaymentId(""); setRegistrationId(""); setAmount(""); }, [userId, target]); useEffect(() => { // 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))); setMethod(refundMethodForOriginal(p.method)); } } }, [paymentId, target, payments]); const regs = useMemo(() => regsForUser(userId), [regsForUser, userId]); const submitRefund = async () => { if (!token) return; setErr(null); const amt = parseFloat(amount || '0'); if (!(amt > 0)) { setErr('Enter a valid refund amount'); return; } 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', { method: 'POST', authToken: token!, body: { userId, amount: amt, method, paymentId: target === 'payment' ? paymentId : undefined, registrationId: target === 'registration' ? registrationId : undefined, reason: reason || undefined } }); setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod(""); await onDone(); } catch (e: any) { setErr(e?.message || 'Failed to create refund'); } finally { setSubmitting(false); } }; return (
Refund
{err &&
{err}
}
setUserId(uid)} />
{target === 'payment' ? ( <> ) : ( <> )}
setAmount(e.target.value)} />
Mirrors the method being refunded — this is what nets against that method's total in reports.
setReason(e.target.value)} placeholder="Reason or note…" />
); } // Lists every donation-assignment leg (isDonationLeg) across all donations, with an Unassign // action per row. Legs aren't shown anywhere else in the UI — "Recent payments" and the "Today" // stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) — // so this is the only place staff can see and reverse an assignment. type AssignedDonationsListProps = { payments: any[]; onDone: () => void | Promise; }; function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps) { const { token } = useAuth(); const [unassigningId, setUnassigningId] = useState(null); const [err, setErr] = useDismissingState(null); const [query, setQuery] = useState(""); const [eventFilter, setEventFilter] = useState(""); const allLegs = useMemo(() => { return payments .filter(isDonationLeg) .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, [payments]); const donationById = useMemo(() => { const m = new Map(); payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); }); return m; }, [payments]); // Events with at least one assigned leg — built from the legs themselves so the dropdown // never shows an event with nothing to filter down to. const eventOptions = useMemo(() => { const m = new Map(); allLegs.forEach((leg: any) => { const ev = leg.registration?.event; if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id)); }); return Array.from(m.entries()).sort((a, b) => a[1].localeCompare(b[1], undefined, { sensitivity: 'base' })); }, [allLegs]); const legs = useMemo(() => { const q = query.trim().toLowerCase(); return allLegs.filter((leg: any) => { if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false; if (!q) return true; const donation = donationById.get(leg.originalPaymentId); const haystack = [ leg.registration?.user?.name, leg.registration?.user?.email, leg.registration?.event?.title, donation?.user?.name, donation?.user?.email, leg.registrationId, leg.originalPaymentId, ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [allLegs, donationById, query, eventFilter]); const unassign = async (leg: any) => { if (!token) return; setErr(null); const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked."); if (!ok) return; try { setUnassigningId(leg.id); await apiFetch('/api/payments/unassign-donation', { method: 'POST', authToken: token, body: { legId: leg.id } }); await onDone(); } catch (e: any) { setErr(e?.message || 'Failed to unassign donation'); } finally { setUnassigningId(null); } }; return (
Assigned donations
{err &&
{err}
} {allLegs.length > 0 && (
setQuery(e.target.value)} />
)} {allLegs.length === 0 ? (
No donations have been assigned to registrations yet.
) : legs.length === 0 ? (
No assigned donations match your search.
) : (
    {legs.map((leg: any) => { const donation = donationById.get(leg.originalPaymentId); return (
  • R {(leg.amount || 0).toFixed(2)} — {leg.registration?.user?.name || leg.registration?.userId || 'Registrant'}
    Registration: #{String(leg.registrationId).slice(0,8)}{leg.registration?.event?.title ? ` — ${leg.registration.event.title}` : ''}
    From donation by {donation?.user?.name || donation?.userId || 'Donor'} — #{String(leg.originalPaymentId).slice(0,8)}
    {new Date(leg.createdAt).toLocaleString()}
  • ); })}
)}
); } type DonationAssignSectionProps = { payments: any[]; allUsers: any[]; registrations: any[]; regOutstanding: Record; onDone: () => void | Promise; }; // Select a user, then one of their registrations, then one of the unassigned donations for // that registration's event. The backend rejects the assignment if either the donation's own // event or the target registration's event is closed (cashed up) — this UI filters both out // proactively so staff aren't offered a choice that will just be rejected on submit. function DonationAssignSection({ payments, allUsers, registrations, regOutstanding, onDone }: DonationAssignSectionProps) { const { token } = useAuth(); const [userId, setUserId] = useState(""); const [registrationId, setRegistrationId] = useState(""); const [paymentId, setPaymentId] = useState(""); const [amountStr, setAmountStr] = useState(""); const [assigning, setAssigning] = useState(false); const [err, setErr] = useState(null); useEffect(() => { setRegistrationId(""); setPaymentId(""); }, [userId]); useEffect(() => { setPaymentId(""); }, [registrationId]); // Registrations for the selected user that can actually receive a donation: an outstanding // balance to apply it to, and an event that isn't closed. const regsForUser = useMemo(() => { if (!userId) return [] as any[]; return registrations.filter((r: any) => { if (String(r.user?.id || r.userId) !== String(userId)) return false; if (r.status === 'cancelled') return false; if (r.event?.cashupStatus === 'closed') return false; const out = regOutstanding[r.id]?.outstanding ?? 0; return out > 0.000001; }); }, [registrations, regOutstanding, userId]); const selectedRegistration = useMemo( () => registrations.find((r: any) => String(r.id) === String(registrationId)), [registrations, registrationId] ); // 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. 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(); payments.forEach((p: any) => { if (p.originalPaymentId && !p.isDonation) { m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0)); } }); return m; }, [payments]); // Donations "relevant to that event" — donations logged against the same event as the // chosen registration that still have a remaining, unused balance. const donationsForEvent = useMemo(() => { if (!selectedRegistration) return [] as any[]; const eventId = selectedRegistration.eventId || selectedRegistration.event?.id; return payments.filter((p: any) => { if (!p.isDonation || p.registrationId) return false; if (String(p.eventId) !== String(eventId)) return false; const remaining = (p.amount || 0) - (legsById.get(p.id) || 0); return remaining > 0.000001; }); }, [payments, selectedRegistration, legsById]); const selectedDonation = useMemo( () => payments.find((p: any) => String(p.id) === String(paymentId)), [payments, paymentId] ); const donationRemaining = selectedDonation ? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0) : 0; const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0; // The most that can be allocated: never more than the donation's remaining balance, never // more than what's actually owed. Staff can type a smaller amount to leave a balance owing. const maxAllocatable = useMemo(() => { if (!selectedDonation) return 0; return Math.min(donationRemaining, outstanding); }, [selectedDonation, donationRemaining, outstanding]); // Default to "apply as much as needed" whenever a new donation is picked — the common // case needs no typing, but the field stays editable for a deliberate partial allocation. useEffect(() => { setAmountStr(maxAllocatable > 0 ? maxAllocatable.toFixed(2) : ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [paymentId]); const amountNum = parseFloat(amountStr || "0"); const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0; const assign = async () => { if (!token) return; setErr(null); if (!paymentId || !registrationId) { setErr('Select a donation and a registration'); return; } if (!(amountNum > 0)) { setErr('Enter a valid amount to allocate'); return; } if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; } try { setAssigning(true); await apiFetch('/api/payments/assign-donation', { method: 'PUT', authToken: token, body: { paymentId, registrationId, amount: amountNum } }); setUserId(""); setRegistrationId(""); setPaymentId(""); setAmountStr(""); await onDone(); } catch (e: any) { setErr(e?.message || 'Failed to assign donation'); } finally { setAssigning(false); } }; return (
Assign donation to a registration
{err &&
{err}
}
{userId && regsForUser.length === 0 && (
No eligible registrations for this user — either nothing outstanding, or the event is closed.
)} {registrationId && donationsForEvent.length === 0 && (
No donations with a remaining balance for this event.
)} {selectedDonation && ( <> setAmountStr(e.target.value)} />
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}. {leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.}
)}
); } export default function PaymentsPage() { return ( Loading...}> ); }