diff --git a/CHANGELOG.md b/CHANGELOG.md
index c47ed59..673bc5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [1.6.0] - 2026-08-08
+
+### Added
+
+- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. The list can be searched (registrant, donor, or event) and filtered by event.
+
+### Fixed
+
+- The internal admin notification for a donation applied to a registration read "Payment recorded" / "Registration payment", indistinguishable from a real incoming payment even though no new money changed hands — it now says "Donation applied" throughout, matching the registrant-facing email's distinct wording.
+
+### 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
diff --git a/backend/package.json b/backend/package.json
index 5f503da..eea4792 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
- "version": "1.5.4",
+ "version": "1.6.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
diff --git a/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql b/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql
new file mode 100644
index 0000000..c5cba12
--- /dev/null
+++ b/backend/prisma/migrations/20260807215056_add_perf_indexes/migration.sql
@@ -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");
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 9f4f75a..c6924d7 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -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 {
diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js
index 6a8220f..44f030c 100644
--- a/backend/src/controllers/paymentController.js
+++ b/backend/src/controllers/paymentController.js
@@ -5,7 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
-const { assertEventOpen } = require('../utils/cashupUtils');
+const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
// @desc Create a new payment
// @route POST /api/payments
@@ -770,6 +770,129 @@ const assignDonationToRegistration = async (req, res) => {
}
};
+// @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts
+// the registration's status/tickets. The donation itself (never touched by assign)
+// is unaffected, so its remaining balance simply goes back up.
+// @route POST /api/payments/unassign-donation
+// @access Private/Supervisor
+const unassignDonationFromRegistration = async (req, res) => {
+ try {
+ const { legId } = req.body;
+ if (!legId) {
+ res.status(400);
+ throw new Error('legId is required');
+ }
+
+ const leg = await prisma.payment.findUnique({ where: { id: legId } });
+ if (!leg) {
+ res.status(404);
+ throw new Error('Payment not found');
+ }
+
+ // Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive,
+ // non-donation payment that references a donation via originalPaymentId. This also rejects
+ // refund rows, which set originalPaymentId too but always with a negative amount.
+ if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) {
+ res.status(400);
+ throw new Error('This payment is not a donation-assignment leg');
+ }
+ if (!leg.registrationId) {
+ res.status(400);
+ throw new Error('This leg is not linked to a registration');
+ }
+
+ const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } });
+ if (!donation || !donation.isDonation) {
+ res.status(400);
+ throw new Error('The original donation for this leg could not be found');
+ }
+
+ await assertRegistrationEventOpen(leg.registrationId, res);
+
+ const registration = await prisma.registration.findUnique({
+ where: { id: leg.registrationId },
+ include: {
+ registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
+ payments: true
+ }
+ });
+ if (!registration) {
+ res.status(404);
+ throw new Error('Registration not found');
+ }
+
+ const originalStatus = registration.status;
+ const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0);
+ const totalDue = computeRegistrationTotalDue(registration, new Date());
+ const totalPaidAfter = totalPaidExisting - leg.amount;
+ const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue);
+
+ if (willDowngradeFromPaid) {
+ // Same guard createRefund uses: block if any ticket on the registration has been scanned.
+ const regTickets = await prisma.ticket.findMany({
+ where: { registrationOption: { registrationId: leg.registrationId } },
+ include: { usages: true }
+ });
+ const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
+ if (hasUsed) {
+ res.status(400);
+ throw new Error('Cannot unassign this donation because a ticket has already been used');
+ }
+ }
+
+ await prisma.payment.delete({ where: { id: legId } });
+
+ // Recompute status the same way createRefund does after removing money from a registration.
+ const updatedRegistration = await prisma.registration.findUnique({
+ where: { id: leg.registrationId },
+ include: {
+ registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
+ payments: true
+ }
+ });
+ let finalRegistration = updatedRegistration;
+ if (updatedRegistration) {
+ const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0);
+ const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date());
+ let newStatus;
+ if (totalPaid >= totalDueNow) newStatus = 'paid';
+ else if (totalPaid > 0) newStatus = 'partial_paid';
+ else newStatus = 'pending';
+ finalRegistration = await prisma.registration.update({
+ where: { id: leg.registrationId },
+ data: { status: newStatus, updatedAt: new Date() }
+ });
+
+ // Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade
+ // clears every unused ticket on the registration, not just the ones this leg funded.
+ if (originalStatus === 'paid' && newStatus !== 'paid') {
+ await prisma.ticket.deleteMany({
+ where: {
+ registrationOption: { registrationId: leg.registrationId },
+ isUsed: false
+ }
+ });
+ }
+ }
+
+ // Fire-and-forget notification
+ const { sendDonationUnassignmentEmails } = require('../utils/notifications');
+ const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId };
+ (async () => {
+ try { await sendDonationUnassignmentEmails(_udLeg); }
+ catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
+ })();
+
+ return res.status(200).json({
+ message: 'Donation unassigned',
+ updatedRegistration: finalRegistration,
+ donationId: donation.id
+ });
+ } catch (error) {
+ res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+ }
+};
+
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -1057,20 +1180,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: `
Hi ${user.name || ''},
Here's your payment link for ${event?.title || 'your registration'}:
${redirectUrl}
`
- });
+ }).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 });
@@ -1299,6 +1424,7 @@ module.exports = {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
+ unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js
index ed783a2..e21b738 100644
--- a/backend/src/controllers/registrationController.js
+++ b/backend/src/controllers/registrationController.js
@@ -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 });
}
diff --git a/backend/src/controllers/yocoTransactionsController.js b/backend/src/controllers/yocoTransactionsController.js
index dab5d71..618add6 100644
--- a/backend/src/controllers/yocoTransactionsController.js
+++ b/backend/src/controllers/yocoTransactionsController.js
@@ -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({
diff --git a/backend/src/routes/paymentRoutes.js b/backend/src/routes/paymentRoutes.js
index 1774b47..67f761a 100644
--- a/backend/src/routes/paymentRoutes.js
+++ b/backend/src/routes/paymentRoutes.js
@@ -8,6 +8,7 @@ const {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
+ unassignDonationFromRegistration,
createYocoCheckout,
sendPaymentLink,
createRefund,
@@ -27,6 +28,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
+router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats);
diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js
index 3776a90..44f7c1c 100644
--- a/backend/src/utils/notifications.js
+++ b/backend/src/utils/notifications.js
@@ -639,6 +639,109 @@ function buildPaymentAdminNotice(payment) {
return { to, subject, text, html: emailWrapper(body) };
}
+// ─── Donation unassignment ─────────────────────────────────────────────────────
+//
+// Sent when staff reverse a previous donation-assignment. Distinct from
+// buildDonationAppliedToRegistrant: the leg payment no longer exists by the time this runs
+// (it's hard-deleted before the notification fires), so callers pass a synthetic payment-shaped
+// object — { amount, createdAt, registration } — built from the leg's captured values plus a
+// freshly re-fetched registration so the balance table reflects the post-removal total.
+// Kept anonymous (no donor name), same reasoning as the "applied" email.
+
+function buildDonationUnassignedFromRegistrant(payment) {
+ const org = getOrg();
+ const reg = payment.registration;
+ const eventTitle = reg?.event?.title || 'the event';
+ const totalDue = computeRegistrationTotalDue(reg, new Date());
+ const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+ const balance = Math.max(totalDue - totalPaid, 0);
+ const isUserActive = reg?.user?.isActive;
+ const subject = `A donation was removed from your registration – ${eventTitle}`;
+ const preheader = `A donation of ${fmtAmount(payment.amount)} was removed from your registration for ${eventTitle}.`;
+
+ const body = `
+ A donation was removed from your registration
+ Your balance has changed
+
+ Hi ${reg?.user?.name || 'there'},
+
+ A donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.
+
+
+ ${callout(`${fmtAmount(payment.amount)} removed
+ Date: ${fmtDate(new Date())}`,
+ 'warning')}
+
+
+
+ | Total due |
+ ${fmtAmount(totalDue)} |
+
+
+ | Total paid |
+ ${fmtAmount(totalPaid)} |
+
+
+ | ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} |
+ ${fmtAmount(balance)} |
+
+
+
+ ${balance > 0
+ ? callout(`A balance is now owing. Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning')
+ : ''}
+
+ ${accountCta(isUserActive, org.url)}`;
+
+ const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`;
+ return { subject, text, html: emailWrapper(body, { preheader }) };
+}
+
+// Internal admin notice for a donation-assignment — same table layout as
+// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
+// since legs copy userId from the original donation, not the registrant), relabeled so it
+// doesn't read as a fresh incoming payment: no new money changed hands here, an
+// already-recorded donation was just reallocated to a registration.
+function buildDonationAssignmentAdminNotice(payment) {
+ const notice = buildPaymentAdminNotice(payment);
+ const eventTitle = payment.registration?.event?.title || 'Event';
+ const payerName = payment.user?.name || '—';
+ const subject = `Donation applied: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
+ return {
+ ...notice,
+ subject,
+ html: notice.html
+ .replace('Payment recorded', 'Donation applied')
+ .replace('Internal notification', 'Internal notification — donation applied to a registration')
+ .replace('>Registration payment<', '>Donation applied<'),
+ text: notice.text
+ .replace('Payment recorded', 'Donation applied')
+ .replace('Type: Registration payment', 'Type: Donation applied'),
+ };
+}
+
+// Internal admin notice for a donation-unassignment — same table layout as
+// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
+// since legs copy userId from the original donation, not the registrant), with copy adjusted
+// for a removal rather than a new payment.
+function buildDonationUnassignmentAdminNotice(payment) {
+ const notice = buildPaymentAdminNotice(payment);
+ const eventTitle = payment.registration?.event?.title || 'Event';
+ const payerName = payment.user?.name || '—';
+ const subject = `Donation unassigned: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
+ return {
+ ...notice,
+ subject,
+ html: notice.html
+ .replace('Payment recorded', 'Donation unassigned')
+ .replace('Internal notification', 'Internal notification — donation removed from registration')
+ .replace('>Registration payment<', '>Donation unassigned<'),
+ text: notice.text
+ .replace('Payment recorded', 'Donation unassigned')
+ .replace('Type: Registration payment', 'Type: Donation unassigned'),
+ };
+}
+
// ─── Refund email ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) {
@@ -980,7 +1083,7 @@ async function sendDonationAssignmentEmails(paymentId) {
} else {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
}
- const adminMsg = buildPaymentAdminNotice(payment);
+ const adminMsg = buildDonationAssignmentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
@@ -990,6 +1093,65 @@ async function sendDonationAssignmentEmails(paymentId) {
}
}
+// Sent when staff reverse a previous donation-assignment (the leg payment is hard-deleted
+// before this runs, so it can't be re-fetched by id like loadPaymentFull does elsewhere —
+// callers pass a snapshot of the leg's fields captured just before deletion instead).
+// Notifies the registrant (balance likely increased) and logs an internal admin notice showing
+// the donor as "Payer", same as the original assignment notice did.
+async function sendDonationUnassignmentEmails(leg) {
+ try {
+ const [registration, donor] = await Promise.all([
+ prisma.registration.findUnique({
+ where: { id: leg.registrationId },
+ include: {
+ registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
+ payments: true,
+ user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
+ event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
+ },
+ }),
+ leg.userId ? prisma.user.findUnique({ where: { id: leg.userId }, select: { id: true, name: true, email: true } }) : null,
+ ]);
+ if (!registration) return;
+
+ // Synthetic payment-shaped object matching what buildDonationUnassignedFromRegistrant and
+ // buildPaymentAdminNotice (via buildDonationUnassignmentAdminNotice) expect.
+ const pseudoPayment = {
+ id: leg.id,
+ amount: leg.amount,
+ createdAt: leg.createdAt,
+ method: leg.method,
+ externalId: leg.externalId,
+ registrationId: leg.registrationId,
+ registration,
+ user: donor,
+ };
+
+ const user = registration.user;
+ const { shouldEmail, waText, waTextAny } = require('./notify');
+ const { buildWADonationUnassignedFromRegistrant } = require('./waMessages');
+
+ const sends = [];
+ const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
+ if (hasValidEmail) {
+ const msg = buildDonationUnassignedFromRegistrant(pseudoPayment);
+ if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
+ }
+ if (hasValidEmail) {
+ sends.push(waText(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
+ } else {
+ sends.push(waTextAny(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
+ }
+ const adminMsg = buildDonationUnassignmentAdminNotice(pseudoPayment);
+ if (adminMsg.to && adminMsg.to.length) {
+ sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
+ }
+ await Promise.all(sends);
+ } catch (e) {
+ console.error('Failed to send donation-unassignment emails:', e);
+ }
+}
+
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
@@ -1045,6 +1207,7 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
+ sendDonationUnassignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
};
diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js
index 8724c4d..ee891c3 100644
--- a/backend/src/utils/pricing.js
+++ b/backend/src/utils/pricing.js
@@ -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) };
}
/**
diff --git a/backend/src/utils/ticketUtils.js b/backend/src/utils/ticketUtils.js
index 2da9ef0..0d22cd5 100644
--- a/backend/src/utils/ticketUtils.js
+++ b/backend/src/utils/ticketUtils.js
@@ -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;
diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js
index 374d03b..c66113d 100644
--- a/backend/src/utils/waMessages.js
+++ b/backend/src/utils/waMessages.js
@@ -266,11 +266,44 @@ function buildWADonationAppliedToRegistrant(payment) {
].join('\n');
}
+// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
+// the leg's original creation time, not "now"; use the current date for the balance display.
+function buildWADonationUnassignedFromRegistrant(payment) {
+ const org = getOrg();
+ const reg = payment.registration;
+ const eventTitle = reg?.event?.title || 'the event';
+ const name = reg?.user?.name || 'there';
+ const amount = fmtAmount(payment.amount);
+
+ let balLine = '';
+ if (reg) {
+ const { computeRegistrationTotalDue } = require('./pricing');
+ const totalDue = computeRegistrationTotalDue(reg, new Date());
+ const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+ const balance = Math.max(totalDue - totalPaid, 0);
+ balLine = balance > 0
+ ? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
+ : '';
+ }
+
+ return [
+ `⚠️ *Donation Removed*`,
+ '',
+ `Hi ${name},`,
+ '',
+ `A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
+ balLine,
+ '',
+ `_${org.name}_ | ${org.url}`,
+ ].join('\n');
+}
+
module.exports = {
buildWARegistration,
buildWAPayment,
buildWARefund,
buildWADonationAppliedToRegistrant,
+ buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWAWelcome,
buildWAAccountClosed,
diff --git a/frontend/package.json b/frontend/package.json
index 2921391..ecfc36f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
- "version": "1.5.4",
+ "version": "1.6.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx
index a9261dc..e94d4de 100644
--- a/frontend/src/app/dashboard/supervisor/payments/page.tsx
+++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx
@@ -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("/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 = {};
+ const map: Record = {};
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(`/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 && Loading users…
}
>
@@ -789,9 +768,13 @@ 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 && Loading users…
}
+ { await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
+ />
>
)}
@@ -1030,6 +1013,144 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
);
}
+// 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[];
diff --git a/package.json b/package.json
index cc6fd9e..9609941 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hope-events",
- "version": "1.5.4",
+ "version": "1.6.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",