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:
@@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Performance
|
||||
|
||||
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
|
||||
- Registration creation and payment capture now resolve per-option pricing, stock checks, and ticket generation concurrently instead of one option at a time.
|
||||
- The supervisor Payments page no longer fires one request per registration to compute outstanding balances (that data was already included in the registrations response); post-action refreshes also run in parallel instead of sequentially.
|
||||
- Added indexes for `Registration(createdAt, status)`, `Ticket(createdAt)`, and `Payment(originalPaymentId, isDonation)` to speed up dashboard stats and donation-leg lookups. `GET /api/registrations` now supports optional `page`/`limit` pagination.
|
||||
|
||||
## [1.5.4] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Payment_originalPaymentId_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_originalPaymentId_isDonation_idx" ON "Payment"("originalPaymentId", "isDonation");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_createdAt_status_idx" ON "Registration"("createdAt", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_createdAt_idx" ON "Ticket"("createdAt");
|
||||
@@ -201,6 +201,7 @@ model Registration {
|
||||
@@index([eventId])
|
||||
@@index([userId, status])
|
||||
@@index([eventId, status])
|
||||
@@index([createdAt, status])
|
||||
}
|
||||
|
||||
model RegistrationOption {
|
||||
@@ -249,7 +250,7 @@ model Payment {
|
||||
@@index([registrationId])
|
||||
@@index([eventId])
|
||||
@@index([createdAt])
|
||||
@@index([originalPaymentId])
|
||||
@@index([originalPaymentId, isDonation])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -271,6 +272,7 @@ model Ticket {
|
||||
@@index([eventId])
|
||||
@@index([userId])
|
||||
@@index([registrationOptionId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
|
||||
@@ -1057,20 +1057,22 @@ const sendPaymentLink = async (req, res) => {
|
||||
const { user, event } = registration;
|
||||
const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`;
|
||||
|
||||
// Validation is synchronous (fast, no network); the actual send is backgrounded since
|
||||
// SMTP/WAWP round trips shouldn't block this request.
|
||||
if (channel === 'email') {
|
||||
if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); }
|
||||
const { sendMail } = require('../utils/email');
|
||||
await sendMail({
|
||||
sendMail({
|
||||
to: user.email,
|
||||
subject: `Payment link — ${event?.title || 'Registration'}`,
|
||||
text: message,
|
||||
html: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>`
|
||||
});
|
||||
}).catch(e => console.error('Failed to send payment link email:', e));
|
||||
} else {
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); }
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
await waTextAny(user, message);
|
||||
waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e));
|
||||
}
|
||||
|
||||
return res.status(200).json({ sent: true, channel });
|
||||
|
||||
@@ -136,16 +136,16 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// Validate each option and resolve prices / check stock
|
||||
const resolvedOptions = [];
|
||||
for (const option of options) {
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
const resolvedOptions = await Promise.all(options.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
res.status(400);
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
res.status(400);
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
@@ -155,11 +155,12 @@ const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty);
|
||||
if (!stockCheck.available) {
|
||||
res.status(400);
|
||||
throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (res.statusCode !== 200) throw e; // propagate stock errors
|
||||
if (e.isStockError) throw e; // propagate stock errors
|
||||
// If stock check function fails (pre-migration), continue without stock check
|
||||
}
|
||||
|
||||
@@ -169,17 +170,17 @@ const createRegistration = async (req, res) => {
|
||||
if (variantId && canIncludeVariants) {
|
||||
const variant = (eventOption.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) {
|
||||
res.status(400);
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty);
|
||||
if (!vStock.available) {
|
||||
res.status(400);
|
||||
throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (res.statusCode !== 200) throw e;
|
||||
if (e.isStockError) throw e;
|
||||
}
|
||||
variantPrice = variant.price; // null = use option price
|
||||
}
|
||||
@@ -213,8 +214,8 @@ const createRegistration = async (req, res) => {
|
||||
}
|
||||
if (priceSnapshot === null) priceSnapshot = eventOption.price;
|
||||
|
||||
resolvedOptions.push({ ...option, variantId, appliedTierId, priceSnapshot });
|
||||
}
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot };
|
||||
}));
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
@@ -378,28 +379,43 @@ const createRegistration = async (req, res) => {
|
||||
// @access Private/Admin
|
||||
const getRegistrations = async (req, res) => {
|
||||
try {
|
||||
const registrations = await prisma.registration.findMany({
|
||||
include: {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
const include = {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
res.json(registrations);
|
||||
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ include });
|
||||
return res.json(registrations);
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [registrations, total] = await prisma.$transaction([
|
||||
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.registration.count()
|
||||
]);
|
||||
|
||||
res.json({ data: registrations, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Optionally update registration status when applicable and generate/email tickets if paid
|
||||
// Optionally update registration status when applicable and generate tickets if paid.
|
||||
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
|
||||
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
|
||||
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
@@ -172,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
|
||||
try {
|
||||
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
} catch (emailErr) {
|
||||
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
|
||||
}
|
||||
}
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
||||
}
|
||||
@@ -191,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Send emails for the reconciled payment
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(payment.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails after reconciliation:', e);
|
||||
}
|
||||
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
const _rxPaymentId = payment.id;
|
||||
const _rxUserId = registration?.userId;
|
||||
const _rxRegId = registration?.id;
|
||||
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
|
||||
if (_rxShouldEmailTickets && _rxUserId) {
|
||||
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Update yoco transaction as reconciled
|
||||
const updatedTx = await Yoco.update({
|
||||
|
||||
@@ -161,18 +161,18 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
let anyChanged = false;
|
||||
|
||||
for (const ro of registration.registrationOptions) {
|
||||
// Each registrationOption is independent, so resolve/update them concurrently
|
||||
// instead of one at a time — this loop sits directly in the payment-capture path.
|
||||
const results = await Promise.all(registration.registrationOptions.map(async (ro) => {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) continue;
|
||||
if (!ro.appliedTierId) return false;
|
||||
|
||||
// Find the currently applied tier
|
||||
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
|
||||
|
||||
if (currentTier && new Date() < new Date(currentTier.deadline)) {
|
||||
// The tier's deadline is still in the future — honor the locked price.
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
||||
@@ -191,11 +191,12 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
appliedTierId: resolved.tierId
|
||||
}
|
||||
});
|
||||
anyChanged = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
|
||||
return { changed: anyChanged };
|
||||
return { changed: results.some(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,42 +54,43 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first)
|
||||
for (const [, group] of byOption) {
|
||||
if (group.length <= 1) continue;
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first).
|
||||
// Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
|
||||
// concurrently instead of one at a time.
|
||||
await Promise.all(Array.from(byOption.values()).map(async (group) => {
|
||||
if (group.length <= 1) return;
|
||||
|
||||
// Prefer the option that already has tickets
|
||||
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
|
||||
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
|
||||
const duplicates = group.filter(ro => ro.id !== primary.id);
|
||||
|
||||
// Move all tickets from duplicates to primary, then delete duplicate options
|
||||
for (const dup of duplicates) {
|
||||
for (const t of (dup.tickets || [])) {
|
||||
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
|
||||
}
|
||||
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
|
||||
});
|
||||
await prisma.registrationOption.delete({ where: { id: dup.id } });
|
||||
}
|
||||
// Move all tickets from duplicates to primary (independent rows — safe to parallelize)
|
||||
await Promise.all(duplicates.map(dup => Promise.all(
|
||||
(dup.tickets || []).map(t =>
|
||||
prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
|
||||
)
|
||||
)));
|
||||
|
||||
// Re-load the primary's current quantity after merge
|
||||
// Single write of the merged quantity, then delete the now-empty duplicate options
|
||||
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: { quantity: (primary.quantity || 0) + totalMergedQty }
|
||||
});
|
||||
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
|
||||
|
||||
// Re-load the primary's current quantity + tickets after merge
|
||||
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
|
||||
primary.quantity = updated?.quantity ?? primary.quantity;
|
||||
// Reload tickets
|
||||
primary.tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: primary.id },
|
||||
include: { usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
|
||||
const generatedTickets = [];
|
||||
|
||||
// Re-read fresh list (some options may have been deleted above)
|
||||
const freshOptions = await prisma.registrationOption.findMany({
|
||||
where: { registrationId },
|
||||
@@ -98,13 +99,14 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const option of freshOptions) {
|
||||
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
|
||||
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
|
||||
const targetQty = option.quantity || 1;
|
||||
const existingTickets = option.tickets || [];
|
||||
|
||||
if (existingTickets.length === 0) {
|
||||
// Create one ticket
|
||||
const ticket = await prisma.ticket.create({
|
||||
return prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
@@ -115,27 +117,28 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
generatedTickets.push(ticket);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick primary: prefer scanned, otherwise oldest
|
||||
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
|
||||
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
|
||||
|
||||
const updates = [];
|
||||
// Update quantity on primary if needed
|
||||
if (primary.quantity !== targetQty) {
|
||||
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
|
||||
updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }));
|
||||
}
|
||||
|
||||
// Delete unscanned duplicates
|
||||
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
|
||||
if (dups.length > 0) {
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
|
||||
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
|
||||
}
|
||||
}
|
||||
if (updates.length > 0) await Promise.all(updates);
|
||||
return null;
|
||||
}));
|
||||
|
||||
return generatedTickets;
|
||||
return perOptionResults.filter(Boolean);
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
throw error;
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user