Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906c79a8e8 | ||
|
|
47b79181b7 | ||
|
|
34f9826829 | ||
|
|
252f80fadb | ||
|
|
0a5b08020f | ||
|
|
6759e9c2d3 | ||
|
|
8799eec717 | ||
|
|
56e0c8a31f | ||
|
|
4bcb07f9d1 | ||
|
|
a0a3dc2416 | ||
|
|
51639322e2 |
@@ -7,6 +7,35 @@ 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
|
||||
|
||||
- The homepage "Join Us" button sent logged-in users to the registration page instead of somewhere useful. It now shows "My Dashboard" and links to their dashboard when a user is already logged in.
|
||||
|
||||
## [1.5.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
|
||||
|
||||
## [1.5.2] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `<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 });
|
||||
@@ -1299,6 +1424,7 @@ module.exports = {
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was removed from your registration</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your balance has changed</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
A donation of <strong>${fmtAmount(payment.amount)}</strong> previously applied to your registration for <strong>${eventTitle}</strong> has been removed by our team.
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} removed</strong><br/>
|
||||
<span style="font-size:13px">Date: ${fmtDate(new Date())}</span>`,
|
||||
'warning')}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${balance > 0
|
||||
? callout(`<strong>A balance is now owing.</strong> 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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -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,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 && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
<AssignedDonationsList
|
||||
payments={payments}
|
||||
onDone={async()=>{ 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<void>;
|
||||
};
|
||||
|
||||
function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps) {
|
||||
const { token } = useAuth();
|
||||
const [unassigningId, setUnassigningId] = useState<string | null>(null);
|
||||
const [err, setErr] = useDismissingState<string | null>(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<string, any>();
|
||||
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<string, string>();
|
||||
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 (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">Assigned donations</div>
|
||||
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
|
||||
{allLegs.length > 0 && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 mb-3">
|
||||
<input
|
||||
className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Search by registrant, donor, event…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="w-full sm:w-56 border rounded px-3 py-2 text-sm shrink-0"
|
||||
value={eventFilter}
|
||||
onChange={e => setEventFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{eventOptions.map(([id, title]) => (<option key={id} value={id}>{title}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{allLegs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div>
|
||||
) : legs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
|
||||
) : (
|
||||
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
|
||||
{legs.map((leg: any) => {
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
return (
|
||||
<li key={leg.id} className="border rounded p-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div>
|
||||
<div className="font-medium">R {(leg.amount || 0).toFixed(2)} — {leg.registration?.user?.name || leg.registration?.userId || 'Registrant'}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Registration: #{String(leg.registrationId).slice(0,8)}{leg.registration?.event?.title ? ` — ${leg.registration.event.title}` : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
From donation by {donation?.user?.name || donation?.userId || 'Donor'} — #{String(leg.originalPaymentId).slice(0,8)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{new Date(leg.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={unassigningId === leg.id}
|
||||
onClick={() => unassign(leg)}
|
||||
className="px-2 py-1 text-xs rounded bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{unassigningId === leg.id ? 'Unassigning…' : 'Unassign'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DonationAssignSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { Calendar, UserPlus, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -58,10 +59,7 @@ export default async function HomePage() {
|
||||
<Calendar className="w-4 h-4" />
|
||||
View Events
|
||||
</a>
|
||||
<a href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Join Us
|
||||
</a>
|
||||
<JoinUsButton />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { UserPlus, LayoutDashboard } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export const JoinUsButton = () => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return user ? (
|
||||
<Link href="/dashboard/user" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
My Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Join Us
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -17,10 +17,10 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
||||
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={onClose} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="w-full max-w-3xl bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden"
|
||||
className="w-full max-w-3xl max-h-full bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white">
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
|
||||
@@ -36,7 +36,7 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
||||
</div>
|
||||
|
||||
{content.quickLinks && content.quickLinks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50">
|
||||
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50 shrink-0">
|
||||
{content.quickLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
@@ -51,9 +51,9 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="flex flex-col sm:flex-row flex-1 min-h-0 overflow-y-auto sm:overflow-visible">
|
||||
{content.tabs.length > 1 && (
|
||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50">
|
||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50 sm:overflow-y-auto">
|
||||
{content.tabs.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = activeTab?.key === t.key;
|
||||
@@ -72,12 +72,12 @@ export default function HelpGuideModal({ content, onClose }: { content: HelpCont
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
|
||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 sm:overflow-y-auto">
|
||||
{activeTab?.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50">
|
||||
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50 shrink-0">
|
||||
{content.supportContact && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Mail className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.5.2",
|
||||
"version": "1.6.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user