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 `
{editError}
} {editLoading ? (