diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bd3dc..72a4466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project follows [Semantic Versioning](https://semver.org/). - The user dashboard's "(early bird)" label was a price-comparison heuristic — it fired on any line priced below the option's base price, including plain cheaper variants that were never actually early-bird tickets. It now checks the real applied-tier flag the backend already tracks. - Buying more of an already-purchased ticket type after its early-bird tier expired re-priced the *entire* line at the new price instead of adding the new quantity at the new price (e.g. 5 tickets @ R50 + 1 more after the price rose to R100 came out to R600 instead of R350). Each purchase now gets its own price "tranche" recorded against the registration line, so previously-bought tickets keep their original price and only the newly added quantity uses the current price. Registration/reporting pages that showed a single blended price per line now render (or total) each tranche separately. - An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did. +- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines. +- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too. ## [1.7.0] - 2026-08-20 diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 50e63df..3944420 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -1,6 +1,6 @@ const prisma = require('../config/db'); const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email'); -const { computeRegistrationTotalDue } = require('./pricing'); +const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing'); // ─── Formatting helpers ─────────────────────────────────────────────────────── @@ -72,7 +72,7 @@ async function loadRegistrationFull(registrationId) { return prisma.registration.findUnique({ where: { id: registrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: 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 } } } }, @@ -98,7 +98,7 @@ async function loadPaymentFull(paymentId) { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, registration: { include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: 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 } } } }, @@ -116,11 +116,11 @@ function selectionsTable(registrationOptions) { const rows = (registrationOptions || []).map(ro => { const name = ro.eventOption?.name || 'Option'; const qty = ro.quantity || 1; - const price = ro.eventOption?.price || 0; + const lineTotal = computeOptionLineTotal(ro, null, new Date()); return ` ${name} ×${qty} - ${fmtAmount(price * qty)} + ${fmtAmount(lineTotal)} `; }); @@ -270,7 +270,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) { ${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })} ${accountCta(isUserActive, org.url)}`; - const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); + const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n'); const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`; return { subject, text, html: emailWrapper(body, { preheader }) }; @@ -310,7 +310,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} ${accountCta(isUserActive, org.url)}`; - const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); + const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n'); const payText = balance > 0 ? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : formRequired @@ -338,7 +338,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = ` ${ro.eventOption?.name || 'Option'} ×${ro.quantity} - ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)} + ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))} `).join(''); const body = ` @@ -1104,7 +1104,7 @@ async function sendDonationUnassignmentEmails(leg) { prisma.registration.findUnique({ where: { id: leg.registrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: 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 } } } }, @@ -1175,7 +1175,7 @@ async function sendDailyEventSummaries(now = new Date()) { where: { eventId: ev.id }, include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } }, - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true, }, orderBy: { createdAt: 'asc' }, diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js index aa0a15f..ceb0d8f 100644 --- a/backend/src/utils/waMessages.js +++ b/backend/src/utils/waMessages.js @@ -46,8 +46,9 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } ? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.` : `Your registration for *${eventTitle}* has been updated.`; + const { computeOptionLineTotal } = require('./pricing'); const items = (reg.registrationOptions || []) - .map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`) + .map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`) .join('\n'); const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 724e49a..8993d09 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -967,37 +967,64 @@ export default function UserDashboardPage() { )} - {!editMode ? ( - + + ); + return ( +
+ {renderGroup('Early bird', earlyBirdRows)} + {renderGroup('Standard price', standardRows)} +
+ ); + })() : (
{editError &&

{editError}

} {editLoading ? (