Speed up payment/refund processing on the supervisor dashboard

Reconciling a Yoco payment and sending payment links blocked the HTTP
response on ticket-PDF generation and email/WhatsApp sends; they now
run in the background like the other payment flows already did.
Registration/payment option loops (pricing, stock checks, ticket
generation) now resolve concurrently instead of sequentially. The
Payments page dropped a per-registration N+1 fetch and now refreshes
its lists in parallel after each action. Added missing indexes for
dashboard stats and donation-leg lookups, and made GET
/api/registrations optionally paginated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 00:00:29 +02:00
co-authored by Claude Sonnet 5
parent 6759e9c2d3
commit 0a5b08020f
9 changed files with 145 additions and 123 deletions
@@ -187,42 +187,24 @@ function PaymentsContent() {
return (price >= 0) ? price : base;
};
// Load registrations for dropdowns
// 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<any[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
// Compute initial totalDue using priceSnapshot (authoritative backend price)
const now = new Date();
const baseMap: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of list) {
// totalDue uses priceSnapshot — not time-dependent
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
baseMap[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue };
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(baseMap);
// Fetch payments per registration to compute outstanding
await Promise.all(
list.map(async (r: any) => {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = (pays || []).reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setRegOutstanding(prev => ({
...prev,
[r.id]: {
totalDue,
totalPaid,
outstanding: Math.max(0, totalDue - totalPaid)
}
}));
} catch (e) {
// ignore per-reg errors
}
})
);
setRegOutstanding(map);
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
@@ -449,8 +431,7 @@ function PaymentsContent() {
});
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
await loadPayments();
await loadRegistrations();
await Promise.all([loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || "Failed to create payment");
} finally {
@@ -532,9 +513,7 @@ function PaymentsContent() {
setInfo('Reconciled as donation');
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await loadYocoUnreconciled();
await loadPayments();
await loadRegistrations();
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || 'Failed to reconcile');
} finally {
@@ -776,7 +755,7 @@ function PaymentsContent() {
usersList={usersList}
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
regOutstanding={regOutstanding}
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Refund recorded'); }}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
</>
@@ -789,7 +768,7 @@ function PaymentsContent() {
allUsers={allUsers}
registrations={registrations}
regOutstanding={regOutstanding}
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Donation assigned to registration'); }}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
</>