diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index e0d1ced..be2c908 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useRouter, useSearchParams } from "next/navigation"; import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; @@ -8,13 +8,15 @@ import { useDismissingState } from "@/hooks/useDismissingState"; import { scoreUser } from "@/lib/fuzzyMatch"; import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react"; import { errorMessage } from "@/lib/utils"; +import { registrationOptionUnitPrice } from "@/lib/pricing"; +import type { Event, Payment, Registration, User } from "@/types"; // 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 { +function isDonationLeg(p: Payment): boolean { return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0; } @@ -30,13 +32,27 @@ function refundMethodForOriginal(method: string | null | undefined): string { return ""; } +type Outstanding = Record; + +// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the +// webhook as Yoco sent it; its checkout metadata says who/what the payment was for. +type YocoTx = { + id: string; + amount: number; + checkoutId?: string | null; + externalId?: string | null; + methodType?: string | null; + createdDate?: string | null; + raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null; +}; + function RegistrationOptions({ regs, regOutstanding }: { - regs: any[]; + regs: Registration[]; regOutstanding: Record; }) { return ( <> - {regs.map((r: any) => { + {regs.map((r) => { 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 ( @@ -50,7 +66,7 @@ function RegistrationOptions({ regs, regOutstanding }: { } function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: { - allUsers: any[]; + allUsers: User[]; value: string; onChange: (userId: string) => void; placeholder?: string; @@ -137,73 +153,45 @@ function PaymentsContent() { if (!user) router.replace("/login"); }, [user, loading, router]); - const [payments, setPayments] = useState([]); + const [payments, setPayments] = useState([]); const [loadingList, setLoadingList] = useState(false); const [error, setError] = useDismissingState(null); const [info, setInfo] = useDismissingState(null); - const [registrations, setRegistrations] = useState([]); + const [registrations, setRegistrations] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); - const [regOutstanding, setRegOutstanding] = useState>({}); + const [regOutstanding, setRegOutstanding] = useState({}); - const loadPayments = async () => { + const loadPayments = useCallback(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())); + setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())); } catch (e) { setError(errorMessage(e) || "Failed to load payments"); } finally { setLoadingList(false); } - }; + }, [token, setError]); - 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; - }; + useEffect(() => { loadPayments(); }, [loadPayments]); // 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 () => { + const loadRegistrations = useCallback(async () => { if (!token) return; try { setLoadingRegs(true); - const regs = await apiFetch("/api/registrations", { authToken: token }); + const regs = await apiFetch("/api/registrations", { authToken: token }); const list = Array.isArray(regs) ? regs : []; const now = new Date(); - const map: Record = {}; + const map: Outstanding = {}; 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); + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); + const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; } setRegOutstanding(map); @@ -215,9 +203,9 @@ function PaymentsContent() { } finally { setLoadingRegs(false); } - }; + }, [token]); - useEffect(() => { loadRegistrations(); }, [token]); + useEffect(() => { loadRegistrations(); }, [loadRegistrations]); // Load all users for Refund section useEffect(() => { @@ -241,12 +229,12 @@ function PaymentsContent() { const [registrationId, setRegistrationId] = useState(""); const [isDonation, setIsDonation] = useState(false); const [eventId, setEventId] = useState(""); - const [events, setEvents] = 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 [allUsers, setAllUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(false); // New: user selection states for split dropdowns @@ -254,33 +242,12 @@ function PaymentsContent() { // 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)); + return registrations.filter((r) => (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))); + return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(selectedUserId))); }, [outstandingRegs, selectedUserId]); // Payment link tab state @@ -293,7 +260,7 @@ function PaymentsContent() { const [linkCopied, setLinkCopied] = useState(false); const regsForLinkUser = useMemo(() => { - return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId))); + return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(linkUserId))); }, [outstandingRegs, linkUserId]); useEffect(() => { @@ -372,7 +339,7 @@ function PaymentsContent() { // 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 reg = registrations.find((r) => String(r.id) === String(registrationId)); const uid = reg?.user?.id || reg?.userId; if (uid) setSelectedUserId(String(uid)); }, [registrationId, registrations]); @@ -388,7 +355,7 @@ function PaymentsContent() { 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 }); + const evs = await apiFetch(`/api/events/all${qs}`, { authToken: token }); setEvents(evs || []); } catch {} })(); @@ -455,38 +422,38 @@ function PaymentsContent() { // Yoco unreconciled list and actions const [yocoLoading, setYocoLoading] = useState(false); - const [yocoTxs, setYocoTxs] = useState([]); - const loadYocoUnreconciled = async () => { + const [yocoTxs, setYocoTxs] = useState([]); + const loadYocoUnreconciled = useCallback(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 : []); + const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token }); + const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []); setYocoTxs(items); } catch { // ignore silently, shown on demand via error state if needed } finally { setYocoLoading(false); } - }; - useEffect(() => { loadYocoUnreconciled(); }, [token]); + }, [token]); + useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]); // 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 openReconcileToRegistration = (tx: YocoTx) => { 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 openReconcileAsDonation = (tx: YocoTx) => { 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) => { + .filter((r) => String(r.user?.id || r.userId) === String(uid)) + .filter((r) => { const out = regOutstanding[r.id]?.outstanding ?? 0; return out > 0.000001; // show only registrations with an outstanding balance }); @@ -638,7 +605,7 @@ function PaymentsContent() { setPaymentId(e.target.value)} disabled={!userId}> - {paymentsForUser.map((p:any)=>{ + {paymentsForUser.map((p)=>{ const label = `R ${(p.amount||0).toFixed(2)} — ${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`; return ; })} @@ -980,7 +947,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone