diff --git a/CHANGELOG.md b/CHANGELOG.md index b410aad..b5bd3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step. + +### Fixed + +- 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. + ## [1.7.0] - 2026-08-20 ### Added diff --git a/backend/prisma/migrations/20260820103708_init/migration.sql b/backend/prisma/migrations/20260820103708_init/migration.sql new file mode 100644 index 0000000..96ae69b --- /dev/null +++ b/backend/prisma/migrations/20260820103708_init/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost. + - You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost. + - You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost. + - You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount"; + +-- AlterTable +ALTER TABLE "Payment" DROP COLUMN "feeAmount", +DROP COLUMN "feeChannel", +DROP COLUMN "feeRate"; diff --git a/backend/prisma/migrations/20260820140412_add_tranches_and_contact_events/migration.sql b/backend/prisma/migrations/20260820140412_add_tranches_and_contact_events/migration.sql new file mode 100644 index 0000000..9eb2d34 --- /dev/null +++ b/backend/prisma/migrations/20260820140412_add_tranches_and_contact_events/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT, +ADD COLUMN "contactName" TEXT, +ADD COLUMN "contactPhone" TEXT, +ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "RegistrationOptionTranche" ( + "id" TEXT NOT NULL, + "registrationOptionId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "priceSnapshot" DOUBLE PRECISION NOT NULL, + "appliedTierId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId"); + +-- CreateIndex +CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId"); + +-- AddForeignKey +ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c6924d7..95248bd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -101,6 +101,10 @@ model Event { isActive Boolean @default(true) isHidden Boolean @default(false) requiresAuth Boolean @default(true) + requiresRegistration Boolean @default(true) + contactName String? + contactPhone String? + contactEmail String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt createdById String? @@ -163,6 +167,7 @@ model EarlyBirdTier { eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) registrationOptions RegistrationOption[] + tranches RegistrationOptionTranche[] @@index([eventOptionId, deadline]) @@index([variantId]) @@ -217,6 +222,7 @@ model RegistrationOption { variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) tickets Ticket[] + tranches RegistrationOptionTranche[] @@index([registrationId]) @@index([eventOptionId]) @@ -224,6 +230,27 @@ model RegistrationOption { @@index([appliedTierId]) } +// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation +// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket +// type be bought in multiple batches at different early-bird prices without either batch's +// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId +// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId +// mirror the most recently added tranche) for the many call sites that only need "how many" +// or a single display price. +model RegistrationOptionTranche { + id String @id @default(uuid()) + registrationOptionId String + quantity Int + priceSnapshot Float + appliedTierId String? + createdAt DateTime @default(now()) + registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade) + appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) + + @@index([registrationOptionId]) + @@index([appliedTierId]) +} + model Payment { id String @id @default(uuid()) amount Float diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 34a17a9..ea4b519 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) { // @access Private/Admin const createEvent = async (req, res) => { try { - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body; const data = { id: uuidv4(), @@ -58,14 +58,19 @@ const createEvent = async (req, res) => { redirectUrl, isHidden: isHidden === true || isHidden === 'true', requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true, + requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true, + contactName: contactName || null, + contactPhone: contactPhone || null, + contactEmail: contactEmail || null, }; try { const event = await prisma.event.create({ data }); - // Automatically create a main ticket (event option) with the event price + // Automatically create a main ticket (event option) with the event price — contact-only + // events have no bookable options, so there's nothing to auto-create for them. try { - if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') { + if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') { await prisma.eventOption.create({ data: { id: uuidv4(), @@ -454,7 +459,7 @@ const updateEvent = async (req, res) => { // totals — same rule already enforced for payments/costs. Admin can reopen first. await assertEventOpen(req.params.id, res); - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body; const data = { title: title || event.title, @@ -468,6 +473,10 @@ const updateEvent = async (req, res) => { isActive: isActive !== undefined ? isActive : event.isActive, isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false), requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true), + requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true), + contactName: contactName !== undefined ? (contactName || null) : event.contactName, + contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone, + contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail, updatedAt: new Date(), redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, }; diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 44f030c..73ae4f4 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -78,7 +78,7 @@ const createPayment = async (req, res) => { registration = await 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 } } } @@ -134,7 +134,7 @@ const createPayment = async (req, res) => { registration = await 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 } } } @@ -242,7 +242,8 @@ const createPayment = async (req, res) => { include: { registrationOptions: { include: { - eventOption: { include: { earlyBirdTiers: true } } + eventOption: { include: { earlyBirdTiers: true } }, + tranches: true } }, payments: true @@ -641,7 +642,8 @@ const assignDonationToRegistration = async (req, res) => { include: { registrationOptions: { include: { - eventOption: { include: { earlyBirdTiers: true } } + eventOption: { include: { earlyBirdTiers: true } }, + tranches: true } }, payments: true @@ -812,7 +814,7 @@ const unassignDonationFromRegistration = async (req, res) => { const registration = await prisma.registration.findUnique({ where: { id: leg.registrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } }); @@ -846,7 +848,7 @@ const unassignDonationFromRegistration = async (req, res) => { const updatedRegistration = await prisma.registration.findUnique({ where: { id: leg.registrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } }); @@ -903,6 +905,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, + tranches: true, } }, payments: true, @@ -1017,7 +1020,7 @@ const createYocoCheckout = async (req, res) => { const freshReg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } }); @@ -1040,6 +1043,7 @@ const createYocoCheckout = async (req, res) => { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, + tranches: true, } }, payments: true, @@ -1270,7 +1274,7 @@ const createRefund = async (req, res) => { const registration = await prisma.registration.findUnique({ where: { id: linkRegistrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true, // Load tickets to check usage if needed _count: true @@ -1324,7 +1328,7 @@ const createRefund = async (req, res) => { const registration = await prisma.registration.findUnique({ where: { id: linkRegistrationId }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } }); diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index e21b738..fa0f1a5 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -4,7 +4,7 @@ const axios = require("axios"); const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { emailTickets } = require('./ticketController'); const { hashPassword } = require('../config/auth'); -const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing'); +const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing'); const { assertEventOpen } = require('../utils/cashupUtils'); /** @@ -226,17 +226,31 @@ const createRegistration = async (req, res) => { let registration; let isNewRegistration = false; if (existingReg) { - // Merge: upsert each requested option into the existing registration + // Merge: add a new price tranche per requested option into the existing registration. + // Never overwrite an existing row's priceSnapshot/quantity in place — that would blend + // tickets bought at different early-bird prices into a single (wrong) price. Each + // purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate. for (const opt of resolvedOptions) { // Match on eventOptionId + variantId for correct row const existing = existingReg.registrationOptions.find( ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ); if (existing) { - await prisma.registrationOption.update({ - where: { id: existing.id }, - data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null } - }); + await prisma.$transaction([ + prisma.registrationOptionTranche.create({ + data: { + id: uuidv4(), + registrationOptionId: existing.id, + quantity: opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + } + }), + prisma.registrationOption.update({ + where: { id: existing.id }, + data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null } + }) + ]); } else { await prisma.registrationOption.create({ data: { @@ -247,6 +261,14 @@ const createRegistration = async (req, res) => { variantId: opt.variantId || null, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot, + tranches: { + create: [{ + id: uuidv4(), + quantity: opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + }] + } } }); } @@ -263,7 +285,7 @@ const createRegistration = async (req, res) => { registration = await prisma.registration.findUnique({ where: { id: existingReg.id }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } } } @@ -286,11 +308,19 @@ const createRegistration = async (req, res) => { variantId: option.variantId || null, appliedTierId: option.appliedTierId || null, priceSnapshot: option.priceSnapshot, + tranches: { + create: [{ + id: uuidv4(), + quantity: option.quantity, + priceSnapshot: option.priceSnapshot, + appliedTierId: option.appliedTierId || null, + }] + } })) } }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } } } @@ -368,7 +398,7 @@ const createRegistration = async (req, res) => { } })(); - res.status(201).json(registration); + res.status(201).json(attachComputedTotals(registration)); } catch (error) { res.status(400).json({ message: error.message }); } @@ -385,6 +415,7 @@ const getRegistrations = async (req, res) => { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: true } }, + tranches: true, } }, event: true, @@ -403,7 +434,7 @@ const getRegistrations = async (req, res) => { // 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); + return res.json(attachComputedTotalsToList(registrations)); } const page = Math.max(1, parseInt(req.query.page) || 1); @@ -415,7 +446,7 @@ const getRegistrations = async (req, res) => { prisma.registration.count() ]); - res.json({ data: registrations, total, page, limit, pages: Math.ceil(total / limit) }); + res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(400).json({ message: error.message }); } @@ -434,6 +465,18 @@ const getUserRegistrations = async (req, res) => { event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } } }; + // Keep pending/partial-paid registrations' prices current before serving them — an + // early-bird tier can expire while items sit unpaid in someone's registration, and + // without this the dashboard would keep showing a price that was never actually locked + // in by a payment, indefinitely, until the user happens to attempt a payment. + const staleCandidates = await prisma.registration.findMany({ + where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } }, + select: { id: true } + }); + if (staleCandidates.length > 0) { + await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {}))); + } + const registrations = await prisma.registration.findMany({ where: whereClause, include: { @@ -441,6 +484,7 @@ const getUserRegistrations = async (req, res) => { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: true } }, + tranches: true, } }, // Nest the event's form so the frontend can tell whether attendee forms are @@ -453,12 +497,13 @@ const getUserRegistrations = async (req, res) => { email: true, phoneNumber: true } - } + }, + payments: true, }, orderBy: { createdAt: 'desc' } }); - res.json(registrations); + res.json(attachComputedTotalsToList(registrations)); } catch (error) { res.status(400).json({ message: error.message }); } @@ -469,35 +514,42 @@ const getUserRegistrations = async (req, res) => { // @access Private const getRegistrationById = async (req, res) => { try { - const registration = await prisma.registration.findUnique({ - where: { id: req.params.id }, - include: { - registrationOptions: { - include: { - eventOption: { include: { earlyBirdTiers: true } }, - variant: { select: { id: true, name: true, price: true } }, - tickets: true - } - }, - event: true, - user: { - select: { - id: true, - name: true, - email: true, - phoneNumber: true - } - }, - payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, - formResponses: { include: { answers: true } } - } - }); + const include = { + registrationOptions: { + include: { + eventOption: { include: { earlyBirdTiers: true } }, + variant: { select: { id: true, name: true, price: true } }, + tickets: true, + tranches: true + } + }, + event: true, + user: { + select: { + id: true, + name: true, + email: true, + phoneNumber: true + } + }, + payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, + formResponses: { include: { answers: true } } + }; + + let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include }); if (!registration) { res.status(404); throw new Error('Registration not found'); } + // See getUserRegistrations — keep an unpaid/partially-paid registration's price current + // whenever it's viewed, not just at payment time. + if (registration.status === 'pending' || registration.status === 'partial_paid') { + await refreshPricingForRegistration(registration.id).catch(() => {}); + registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include }); + } + // Guests (no auth) can view by knowing the registrationId (UUID = unguessable) // Authenticated users must be the owner or staff+ if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { @@ -505,7 +557,7 @@ const getRegistrationById = async (req, res) => { throw new Error('Not authorized to view this registration'); } - res.json(registration); + res.json(attachComputedTotals(registration)); } catch (error) { res.status(400).json({ message: error.message }); } @@ -659,6 +711,17 @@ const cancelRegistration = async (req, res) => { const getRegistrationsByEvent = async (req, res) => { try { const { search } = req.query; + + // See getUserRegistrations — keep pending/partial-paid registrations' prices current + // before serving them, rather than only at payment time. + const staleCandidates = await prisma.registration.findMany({ + where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } }, + select: { id: true } + }); + if (staleCandidates.length > 0) { + await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {}))); + } + let registrations = await prisma.registration.findMany({ where: { eventId: req.params.eventId }, include: { @@ -667,6 +730,7 @@ const getRegistrationsByEvent = async (req, res) => { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: true } }, tickets: true, + tranches: true, } }, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, @@ -684,7 +748,7 @@ const getRegistrationsByEvent = async (req, res) => { ); } - res.json(registrations); + res.json(attachComputedTotalsToList(registrations)); } catch (error) { res.status(400).json({ message: error.message }); } @@ -891,20 +955,33 @@ const createManualRegistration = async (req, res) => { let isNewRegistration = false; if (existingReg) { - // Upsert each requested option into the existing registration (all in parallel) + // Add a new price tranche per requested option into the existing registration (all in + // parallel) — never overwrite an existing row's priceSnapshot/quantity in place, or + // tickets bought at different early-bird prices would blend into a single wrong price. await Promise.all(resolvedManualOptions.map(opt => { const existing = existingReg.registrationOptions.find( ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ); if (existing) { - return prisma.registrationOption.update({ - where: { id: existing.id }, - data: { - quantity: existing.quantity + opt.quantity, - priceSnapshot: opt.priceSnapshot, - appliedTierId: opt.appliedTierId || null, - } - }); + return prisma.$transaction([ + prisma.registrationOptionTranche.create({ + data: { + id: uuidv4(), + registrationOptionId: existing.id, + quantity: opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + } + }), + prisma.registrationOption.update({ + where: { id: existing.id }, + data: { + quantity: existing.quantity + opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + } + }) + ]); } return prisma.registrationOption.create({ data: { @@ -915,6 +992,14 @@ const createManualRegistration = async (req, res) => { variantId: opt.variantId || null, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot, + tranches: { + create: [{ + id: uuidv4(), + quantity: opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + }] + } } }); })); @@ -922,7 +1007,7 @@ const createManualRegistration = async (req, res) => { const freshForStatus = await prisma.registration.findUnique({ where: { id: existingReg.id }, include: { - registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true, } }); @@ -937,7 +1022,7 @@ const createManualRegistration = async (req, res) => { registration = await prisma.registration.findUnique({ where: { id: existingReg.id }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, }, @@ -960,11 +1045,19 @@ const createManualRegistration = async (req, res) => { variantId: option.variantId || null, appliedTierId: option.appliedTierId || null, priceSnapshot: option.priceSnapshot, + tranches: { + create: [{ + id: uuidv4(), + quantity: option.quantity, + priceSnapshot: option.priceSnapshot, + appliedTierId: option.appliedTierId || null, + }] + } })), }, }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, }, @@ -1035,7 +1128,7 @@ const createManualRegistration = async (req, res) => { } })(); - return res.status(201).json(registration); + return res.status(201).json(attachComputedTotals(registration)); } catch (error) { console.error(error); return res.status(400).json({message: error.message}); @@ -1063,6 +1156,7 @@ const updateRegistrationOptions = async (req, res) => { registrationOptions: { include: { tickets: true, + tranches: true, eventOption: { include: { earlyBirdTiers: true, variants: true } } } }, @@ -1134,39 +1228,11 @@ const updateRegistrationOptions = async (req, res) => { } const mergedOptions = Array.from(mergedOptionsMap.values()); - // Resolve pricing for each incoming option (variant-aware, with stock check) - const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo])); - const resolvedUpdateOptions = []; - for (const opt of mergedOptions) { - const eventOption = eventOptionsMap.get(opt.eventOptionId); - const variantId = opt.variantId || null; - let priceSnapshot = null; - let appliedTierId = null; - try { - if (variantId) { - const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity); - priceSnapshot = variantResolved.price; - appliedTierId = variantResolved.tierId; - } else { - const resolved = await resolveOptionPrice(eventOption, opt.quantity); - priceSnapshot = resolved.price; - appliedTierId = resolved.tierId; - } - } catch (e) { - priceSnapshot = Number(eventOption?.price || 0); - } - resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId }); - } - - const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0); - - if (newTotalDue < totalPaid) { - res.status(400); - throw new Error('Cannot reduce items below the amount already paid'); - } - // Group existing registrationOptions by eventOptionId::variantId so tickets that - // have already been issued are never deleted, only ever updated in place. + // have already been issued are never deleted, only ever updated in place. Computed + // before pricing resolution because pricing now depends on whether a quantity is + // increasing (and by how much) — an unchanged or reduced quantity must never + // re-price tickets already locked in at an earlier price (see tranche design). const oldByKey = new Map(); for (const ro of registration.registrationOptions) { const key = `${ro.eventOptionId}::${ro.variantId || ''}`; @@ -1174,6 +1240,86 @@ const updateRegistrationOptions = async (req, res) => { oldByKey.get(key).push(ro); } + // A legacy row (created before the tranche migration) has no tranches — fall back to + // its own priceSnapshot/quantity as a single implicit tranche for totals purposes. + const trancheSum = (tranches) => (tranches || []).reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0); + const rowsTotal = (rows) => rows.reduce((sum, ro) => { + const tranches = ro.tranches || []; + if (tranches.length > 0) return sum + trancheSum(tranches); + return sum + Number(ro.quantity || 0) * Number(ro.priceSnapshot ?? ro.eventOption?.price ?? 0); + }, 0); + // Remove `qtyToRemove` units from a set of tranches, newest-first (LIFO) — mirrors the + // ticket-floor invariant below: issued tickets always map to the oldest tranches, so the + // newest (least-committed) tranches are the ones trimmed first on a quantity decrease. + const planLIFORemoval = (tranches, qtyToRemove) => { + const sorted = [...tranches].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + let remaining = qtyToRemove; + const ops = []; + const kept = []; + for (const t of sorted) { + if (remaining <= 0) { kept.push(t); continue; } + if (t.quantity <= remaining) { + remaining -= t.quantity; + ops.push({ type: 'delete', trancheId: t.id }); + } else { + const newQuantity = t.quantity - remaining; + remaining = 0; + ops.push({ type: 'update', trancheId: t.id, newQuantity }); + kept.push({ ...t, quantity: newQuantity }); + } + } + return { kept, ops }; + }; + + // Resolve pricing for each incoming option (variant-aware, with stock check) — but only + // for the delta being newly added. Unchanged or decreasing quantities never re-resolve. + const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo])); + const resolvedUpdateOptions = []; + let newTotalDue = 0; + for (const opt of mergedOptions) { + const key = `${opt.eventOptionId}::${opt.variantId || ''}`; + const existingRows = oldByKey.get(key) || []; + const existingQty = existingRows.reduce((sum, ro) => sum + (ro.quantity || 0), 0); + const delta = (opt.quantity || 0) - existingQty; + const existingTotal = rowsTotal(existingRows); + + const eventOption = eventOptionsMap.get(opt.eventOptionId); + const variantId = opt.variantId || null; + let priceSnapshot = null; + let appliedTierId = null; + let removalPlan = null; + + if (delta > 0) { + try { + if (variantId) { + const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta); + priceSnapshot = variantResolved.price; + appliedTierId = variantResolved.tierId; + } else { + const resolved = await resolveOptionPrice(eventOption, delta); + priceSnapshot = resolved.price; + appliedTierId = resolved.tierId; + } + } catch (e) { + priceSnapshot = Number(eventOption?.price || 0); + } + newTotalDue += existingTotal + delta * (priceSnapshot || 0); + } else if (delta < 0) { + const existingTranches = existingRows.flatMap(ro => ro.tranches || []); + removalPlan = planLIFORemoval(existingTranches, -delta); + newTotalDue += trancheSum(removalPlan.kept); + } else { + newTotalDue += existingTotal; + } + + resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan }); + } + + if (newTotalDue < totalPaid) { + res.status(400); + throw new Error('Cannot reduce items below the amount already paid'); + } + // Per-item floor: a ticket is only ever created once a registration is paid, and it is // never deleted or shrunk — only grown. So an option can never be reduced (or removed) // below the quantity of any ticket already issued for it. @@ -1208,20 +1354,63 @@ const updateRegistrationOptions = async (req, res) => { const existingRows = oldByKey.get(key); if (existingRows && existingRows.length > 0) { const [primary, ...dupes] = existingRows; - await tx.registrationOption.update({ - where: { id: primary.id }, - data: { - quantity: opt.quantity, - appliedTierId: opt.appliedTierId || null, - priceSnapshot: opt.priceSnapshot, - } - }); + + // Consolidate dupes into primary first: move their tranches and tickets rather + // than discarding them, so price history (and money) survives duplicate cleanup. for (const dup of dupes) { + if ((dup.tranches || []).length > 0) { + await tx.registrationOptionTranche.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } }); + } if ((dup.tickets || []).length > 0) { await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } }); } await tx.registrationOption.delete({ where: { id: dup.id } }); } + + if (opt.delta > 0) { + // Increase: a new tranche for the delta at the freshly-resolved price — never + // overwrite the existing tranches' locked-in prices. + await tx.registrationOptionTranche.create({ + data: { + id: uuidv4(), + registrationOptionId: primary.id, + quantity: opt.delta, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + } + }); + await tx.registrationOption.update({ + where: { id: primary.id }, + data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot } + }); + } else if (opt.delta < 0 && opt.removalPlan) { + // Decrease: trim tranches newest-first (LIFO); issued tickets always map to the + // oldest tranches, and the floor check above already guarantees this never dips + // below issued-ticket quantity. + for (const op of opt.removalPlan.ops) { + if (op.type === 'delete') { + await tx.registrationOptionTranche.delete({ where: { id: op.trancheId } }); + } else { + await tx.registrationOptionTranche.update({ where: { id: op.trancheId }, data: { quantity: op.newQuantity } }); + } + } + const newest = [...opt.removalPlan.kept].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0]; + await tx.registrationOption.update({ + where: { id: primary.id }, + data: { + quantity: opt.quantity, + appliedTierId: newest ? (newest.appliedTierId || null) : null, + priceSnapshot: newest ? newest.priceSnapshot : null, + } + }); + } else { + // Unchanged quantity: only reflect dupe-consolidation in the aggregate; leave + // the locked-in price/tier untouched. + await tx.registrationOption.update({ + where: { id: primary.id }, + data: { quantity: opt.quantity } + }); + } } else { await tx.registrationOption.create({ data: { @@ -1232,6 +1421,14 @@ const updateRegistrationOptions = async (req, res) => { variantId: opt.variantId || null, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot, + tranches: { + create: [{ + id: uuidv4(), + quantity: opt.quantity, + priceSnapshot: opt.priceSnapshot, + appliedTierId: opt.appliedTierId || null, + }] + } } }); } @@ -1253,7 +1450,7 @@ const updateRegistrationOptions = async (req, res) => { const updated = await prisma.registration.findUnique({ where: { id: registrationId }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, payments: true, @@ -1277,7 +1474,7 @@ const updateRegistrationOptions = async (req, res) => { })(); } - return res.json(updated); + return res.json(attachComputedTotals(updated)); } catch (error) { return res.status(400).json({ message: error.message }); } @@ -1296,7 +1493,7 @@ const submitFormResponses = async (req, res) => { const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, } }); @@ -1388,7 +1585,7 @@ const replaceFormResponses = async (req, res) => { const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { - registrationOptions: { include: { eventOption: true } }, + registrationOptions: { include: { eventOption: true, tranches: true } }, event: true, } }); diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index 0f8ff0e..139165a 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => { include: { earlyBirdTiers: true } - } + }, + tranches: true } }, payments: true diff --git a/backend/src/utils/cashupUtils.js b/backend/src/utils/cashupUtils.js index 159d842..fd0f216 100644 --- a/backend/src/utils/cashupUtils.js +++ b/backend/src/utils/cashupUtils.js @@ -83,7 +83,7 @@ async function computeEventFinancials(eventId) { }), prisma.registrationOption.findMany({ where: { registration: { eventId, status: 'paid' } }, - include: { eventOption: { select: { id: true, name: true, price: true } } } + include: { eventOption: { select: { id: true, name: true, price: true } }, tranches: true } }), prisma.eventCashup.findMany({ where: { eventId }, @@ -191,15 +191,17 @@ async function computeEventFinancials(eventId) { const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const netProfit = effectiveTotalRevenue - totalCosts; - // What was actually sold, by ticket type — for the Finance report's income-stream breakdown + // What was actually sold, by ticket type — for the Finance report's income-stream breakdown. + // Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche + // at the price it was actually bought at, not one blended/stale price for the whole line. + const { computeOptionLineTotal } = require('./pricing'); const salesByOptionMap = {}; for (const ro of salesRows) { const opt = ro.eventOption; if (!opt) continue; if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 }; - const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price; salesByOptionMap[opt.id].quantitySold += ro.quantity; - salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity; + salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date()); } const salesByOption = Object.values(salesByOptionMap); diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js index ee891c3..eeec90d 100644 --- a/backend/src/utils/pricing.js +++ b/backend/src/utils/pricing.js @@ -7,11 +7,21 @@ * Used at registration-creation time and again at payment-initiation time. * - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and * as a fallback for legacy RegistrationOption rows that have no priceSnapshot. - * - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after - * refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice. - * - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption - * that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier - * is now expired or its stock is exhausted. + * - A RegistrationOption is an aggregate line (one per registration+option+variant); each + * purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity + + * priceSnapshot + appliedTierId, never mutated after creation — mirrors the Payment model's + * append-only pattern). This is what lets someone buy more of the same ticket type after an + * early-bird tier expires without the new price bleeding onto tickets already bought. + * RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror + * (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche) + * for code that only needs "how many" or a single display price. + * - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's + * tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for + * rows created before tranches existed). + * - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of + * each RegistrationOption; if its tier has expired or lost stock, resolves the next + * applicable tier/price for just that tranche. Older tranches — already priced-in — are + * never touched. */ const prisma = require('../config/db'); @@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) { // Skip expired tiers if (now >= new Date(tier.deadline)) continue; - // Check stock limit if one is set + // Check stock limit if one is set. Sold-so-far is summed across tranches (not + // RegistrationOption rows directly) because one RegistrationOption can now span + // multiple tiers across its tranches — the row's own appliedTierId/quantity only + // reflects its most recent tranche. if (tier.stockLimit > 0) { - const soldAgg = await prisma.registrationOption.aggregate({ + const soldAgg = await prisma.registrationOptionTranche.aggregate({ where: { appliedTierId: tier.id, - registration: { status: { not: 'cancelled' } } + registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }); @@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) { for (const tier of tiers) { if (now >= new Date(tier.deadline)) continue; if (tier.stockLimit > 0) { - const soldAgg = await prisma.registrationOption.aggregate({ - where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } }, + const soldAgg = await prisma.registrationOptionTranche.aggregate({ + where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }); const tierSold = soldAgg._sum?.quantity || 0; @@ -154,21 +167,46 @@ async function refreshPricingForRegistration(registrationId) { include: { registrationOptions: { include: { - eventOption: { include: { earlyBirdTiers: true, variants: true } } + eventOption: { include: { earlyBirdTiers: true, variants: true } }, + tranches: true, } - } + }, + payments: true, } }); if (!registration) return { changed: false }; - // 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) return false; + const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0); + + // For each option, only its single newest tranche is ever a repricing candidate — older + // tranches were already locked in at purchase time and must never be touched. A newest + // tranche is only touched once payments-so-far are established to not yet cover it (i.e. + // it's the still-unpaid remainder) — legacy rows with no tranches are left to the + // getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead. + let registrationOlderTotal = 0; + const candidates = []; + for (const ro of registration.registrationOptions) { + const tranches = ro.tranches || []; + if (tranches.length === 0) continue; + const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + const newest = sorted[sorted.length - 1]; + const older = sorted.slice(0, -1); + registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0); + candidates.push({ ro, newest }); + } + + // Payments don't even cover the already-locked-in older tranches yet — leave everything + // alone rather than guessing which portion is "paid for". + if (totalPaid < registrationOlderTotal - 0.001) return { changed: false }; + + // Each candidate 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(candidates.map(async ({ ro, newest }) => { + // Only refresh tranches that were priced via a tier + if (!newest.appliedTierId) return false; // Find the currently applied tier - const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId); + const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId); if (currentTier && new Date() < new Date(currentTier.deadline)) { // The tier's deadline is still in the future — honor the locked price. @@ -177,19 +215,21 @@ async function refreshPricingForRegistration(registrationId) { // Deadline has passed (or tier record missing) — resolve the next applicable tier const resolved = ro.variantId - ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity) - : await resolveOptionPrice(ro.eventOption, ro.quantity); + ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity) + : await resolveOptionPrice(ro.eventOption, newest.quantity); - const tierChanged = resolved.tierId !== ro.appliedTierId; - const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001; + const tierChanged = resolved.tierId !== newest.appliedTierId; + const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001; if (tierChanged || priceChanged) { + await prisma.registrationOptionTranche.update({ + where: { id: newest.id }, + data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId } + }); + // Mirror onto the aggregate row — it's this option's most recent tranche. await prisma.registrationOption.update({ where: { id: ro.id }, - data: { - priceSnapshot: resolved.price, - appliedTierId: resolved.tierId - } + data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId } }); return true; } @@ -199,14 +239,43 @@ async function refreshPricingForRegistration(registrationId) { return { changed: results.some(Boolean) }; } +/** + * Compute the total for a single RegistrationOption line — sums quantity*priceSnapshot + * across its tranches (each priced at whatever was in effect when it was purchased). + * Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows + * created before tranches existed. + * + * @param {object} ro - RegistrationOption, optionally with .tranches[] included + * @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback + * @param {Date} atTime - evaluation time (used only for the legacy fallback) + * @returns {number} + */ +function computeOptionLineTotal(ro, lastPaymentAt, atTime) { + if (Array.isArray(ro.tranches) && ro.tranches.length > 0) { + return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0); + } + + const qty = Number(ro.quantity || 0); + let unit; + if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) { + // priceSnapshot is authoritative — set at registration creation and kept current + // by refreshPricingForRegistration at payment initiation time. + unit = ro.priceSnapshot; + } else { + // Fallback: legacy row without a snapshot — re-evaluate from tier deadlines + const eo = ro.eventOption || {}; + unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime); + } + return qty * unit; +} + /** * Compute total due for a registration at a given time. * - * Uses priceSnapshot when present (authoritative — set at registration time and refreshed - * before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice - * for legacy rows without a snapshot. + * Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when + * `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise). * - * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption} + * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?} * and optionally payments[] * @param {Date} atTime - evaluation time (used for legacy fallback only) * @returns {number} @@ -222,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) { } } catch {} - return registration.registrationOptions.reduce((sum, ro) => { - const qty = Number(ro.quantity || 0); - let unit; + return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0); +} - if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) { - // priceSnapshot is authoritative — set at registration creation and kept current - // by refreshPricingForRegistration at payment initiation time. - unit = ro.priceSnapshot; - } else { - // Fallback: legacy row without a snapshot — re-evaluate from tier deadlines - const eo = ro.eventOption || {}; - unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime); +/** + * Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on + * each RegistrationOption and `totalDueComputed` on the registration itself. Lets API + * responses hand the frontend an exact total instead of every page re-deriving + * unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches). + * + * @param {object} registration - requires registrationOptions[].tranches included + * @returns {object} the same registration, mutated + */ +function attachComputedTotals(registration) { + if (!registration) return registration; + let lastPaymentAt = null; + try { + if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) { + lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))); } + } catch {} + const now = new Date(); + if (Array.isArray(registration.registrationOptions)) { + for (const ro of registration.registrationOptions) { + ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now); + } + } + registration.totalDueComputed = computeRegistrationTotalDue(registration, now); + return registration; +} - return sum + qty * unit; - }, 0); +function attachComputedTotalsToList(registrations) { + return (registrations || []).map(attachComputedTotals); } module.exports = { @@ -245,5 +330,8 @@ module.exports = { resolveOptionPrice, resolveVariantTierPrice, refreshPricingForRegistration, + computeOptionLineTotal, computeRegistrationTotalDue, + attachComputedTotals, + attachComputedTotalsToList, }; \ No newline at end of file diff --git a/frontend/src/app/dashboard/admin/registrations/page.tsx b/frontend/src/app/dashboard/admin/registrations/page.tsx index e67402e..ded92ed 100644 --- a/frontend/src/app/dashboard/admin/registrations/page.tsx +++ b/frontend/src/app/dashboard/admin/registrations/page.tsx @@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() { return "text-gray-700 bg-gray-50"; }; - const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => { + // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple + // early-bird prices) — fall back to the old client-side estimate only for stale payloads. + const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => { const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price || 0); @@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
Ticket options
- {r.registrationOptions.map((opt: any) => ( -
-
- {opt.eventOption?.name || opt.eventOptionId} - {opt.variant?.name && ({opt.variant.name})} -
-
- {(() => { - const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + {r.registrationOptions.map((opt: any) => { + // A line can span multiple price tranches (e.g. tickets bought + // before and after an early-bird tier expired) — show one row per + // tranche so its own price/tier status is accurate, not blended. + const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0 + ? opt.tranches + : [{ + id: opt.id, + quantity: opt.quantity, + priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) - : (opt.variant?.price ?? opt.eventOption?.price ?? 0); - return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`; - })()} + : (opt.variant?.price ?? opt.eventOption?.price ?? 0), + appliedTierId: opt.appliedTierId, + }]; + return ( +
+
+ {opt.eventOption?.name || opt.eventOptionId} + {opt.variant?.name && ({opt.variant.name})} +
+ {tranches.map((t: any, idx: number) => { + const unit = Number(t.priceSnapshot || 0); + return ( +
+ {`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`} + {t.appliedTierId && ( + (early bird) + )} +
+ ); + })}
- {opt.appliedTierId && ( -
Early-bird price applied
- )} -
- ))} + ); + })}
Total: R {totalDue.toFixed(2)}
diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 5a19927..57e703f 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -608,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) { const options = registration.options || registration.registrationOptions || []; const payments = registration.payments || []; - const totalValue = options.reduce((sum: number, opt: any) => { - // Use priceSnapshot (authoritative backend price, variant-aware) if available + // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans + // multiple early-bird prices) — fall back to the old client-side estimate otherwise. + const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => { const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price ?? opt.price ?? 0); @@ -650,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) { const updatedOptions = updated.options || updated.registrationOptions || []; const updatedPayments = updated.payments || []; - const totalValue = updatedOptions.reduce((sum: number, opt: any) => { + const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => { const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) ? Number(opt.priceSnapshot) : (opt.eventOption?.price ?? opt.price ?? 0); @@ -1737,12 +1738,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
TOTAL
- R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { + R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined ? Number(o.priceSnapshot) : (o.eventOption?.price || o.price || 0); return s + price * (o.quantity || 0); - }, 0).toFixed(2)} + }, 0)).toFixed(2)}
diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index 8ef00eb..20e9685 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -755,12 +755,14 @@ interface EventDraft { title: string; description: string; startDate: string; endDate: string; registrationDeadline: string; goLiveAt: string; price: string; picture: string; redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean; + requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string; } const blankDraft = (): EventDraft => ({ title: "", description: "", startDate: "", endDate: "", registrationDeadline: "", goLiveAt: "", price: "", picture: "", redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true, + requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "", }); const blankOptions = (): OptionDraft[] => [ @@ -791,6 +793,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt), price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "", isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false, + requiresRegistration: ev.requiresRegistration !== false, + contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", } : blankDraft()); // ── options (with per-variant tiers) ── @@ -873,6 +877,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { // ── save helpers ── const saveOptions = async (eventId: string) => { + if (!draft.requiresRegistration) { + // Contact-only events have no ticket options to save — but a form (if any) still needs + // saving in edit mode; for create mode the form is already included in the POST body. + if (mode === "edit") { + await apiFetch(`/api/events/${eventId}`, { + method: "PUT", authToken: token || undefined, + body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } } + }); + } + return; + } if (mode === "edit") { for (const opt of options) { // Build flat tier array: option-level + all variant tiers @@ -1032,7 +1047,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { const handleSave = async () => { if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; } if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } - if (mode === "create") { + if (mode === "create" && draft.requiresRegistration) { if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; } if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; } } @@ -1043,9 +1058,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(), registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined, goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined, - price: draft.price ? parseFloat(draft.price) : 0, + price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0, picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth, redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined, + requiresRegistration: draft.requiresRegistration, + contactName: draft.contactName || undefined, + contactPhone: draft.contactPhone || undefined, + contactEmail: draft.contactEmail || undefined, }; if (mode === "edit") { @@ -1115,10 +1134,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { const isOnLastSubstep = step !== 1 || pricingSubstep === 2; // Basic Details step requires title, start/end dates, and a base price before moving on // (mirrors handleSave's own checks). Base price is only compulsory when creating a new event. - const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price))); + const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price))); // Items & Pricing: every option needs a valid price before leaving the step (checked across // all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate). - const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price)); + // Not applicable to contact-only events, which have no ticket options at all. + const optionsIncomplete = mode === "create" && draft.requiresRegistration && step === 1 && options.some(o => isPriceInvalid(o.price)); const nextDisabled = basicDetailsIncomplete || optionsIncomplete; // ── render ── @@ -1189,11 +1209,40 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { upd({ registrationDeadline: v })} /> upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
-
- - upd({ price: e.target.value })} placeholder="0" /> - {mode === "create" &&

Auto-fills the first ticket option below — enter 0 for a free event.

} +
+ upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })} + /> +
+ {draft.requiresRegistration ? ( +
+ + upd({ price: e.target.value })} placeholder="0" /> + {mode === "create" &&

Auto-fills the first ticket option below — enter 0 for a free event.

} +
+ ) : ( +
+
+ + upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" /> +
+
+ + upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" /> +
+
+ + upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" /> +
+
+ )}
{draft.picture && } diff --git a/frontend/src/app/dashboard/supervisor/manual/page.tsx b/frontend/src/app/dashboard/supervisor/manual/page.tsx index 5807899..b120fcf 100644 --- a/frontend/src/app/dashboard/supervisor/manual/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual/page.tsx @@ -126,7 +126,7 @@ export default function ManualRegistrationPage() { const regOutstanding = useMemo(() => { const map: Record = {}; for (const r of allRegistrations) { - const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0); + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0); 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) }; } diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index e94d4de..ed61d04 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -199,8 +199,9 @@ function PaymentsContent() { const now = new Date(); 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); + // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans + // multiple early-bird prices) — fall back to the old client-side estimate otherwise. + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); 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) }; } diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index f1562ca..724e49a 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -140,12 +140,11 @@ export default function UserDashboardPage() { setRegistrations(myRegs); setTickets(myTicks); - // Compute totalDue from priceSnapshot (authoritative backend price, variant-aware). - // priceSnapshot is set at registration time and refreshed before each payment. - const now = new Date(); + // Use the backend's tranche-aware totalDueComputed (exact even when a line spans + // multiple early-bird prices) rather than re-deriving from priceSnapshot client-side. const totals: Record = {}; for (const r of myRegs) { - const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0); totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; } setBilling(totals); @@ -156,8 +155,8 @@ export default function UserDashboardPage() { 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, no need to recompute per payment time - const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); + // totalDueComputed is not time-dependent, no need to recompute per payment time + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0); const outstanding = Math.max(0, totalDue - totalPaid); setBilling(prev => ({ ...prev, @@ -638,11 +637,10 @@ export default function UserDashboardPage() { const myTicks = await apiFetch("/api/tickets/mytickets", { authToken: token }); setTickets(myTicks); } catch {} - // Recompute billing totals using priceSnapshot - const now2 = new Date(); + // Recompute billing totals using the backend's tranche-aware totalDueComputed const totals: Record = {}; for (const r of myRegs) { - const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0); + const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0); totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; } setBilling(totals); @@ -971,19 +969,32 @@ export default function UserDashboardPage() {
{!editMode ? ( ) : ( diff --git a/frontend/src/app/events/[id]/page.tsx b/frontend/src/app/events/[id]/page.tsx index c4b34a1..323bdd3 100644 --- a/frontend/src/app/events/[id]/page.tsx +++ b/frontend/src/app/events/[id]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import ClientActions from "@/app/events/[id]/ClientActions"; +import { ContactButton } from "@/components/events/ContactButton"; import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react"; export const revalidate = 60; @@ -32,6 +33,10 @@ type Event = { eventOptions?: EventOption[]; attachments?: EventAttachment[]; requiresAuth?: boolean; + requiresRegistration?: boolean; + contactName?: string | null; + contactPhone?: string | null; + contactEmail?: string | null; }; import { apiFetch, ApiError } from "@/lib/api"; @@ -51,6 +56,16 @@ function lowStockThreshold(stockLimit: number): number { } function RegisterCta({ event }: { event: Event }) { + if (event.requiresRegistration === false) { + return ( + + ); + } const now = new Date(); const end = new Date(event.endDate); const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; diff --git a/frontend/src/app/register/[eventId]/page.tsx b/frontend/src/app/register/[eventId]/page.tsx index 195e02d..a101301 100644 --- a/frontend/src/app/register/[eventId]/page.tsx +++ b/frontend/src/app/register/[eventId]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import { apiFetch, ApiError } from "@/lib/api"; @@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event throw e; } + // Contact-only events (e.g. baptism) have no registration flow — bounce a stale/direct + // link back to the event detail page, which renders the Contact affordance instead. + if (event.requiresRegistration === false) { + redirect(`/events/${eventId}`); + } + return (
diff --git a/frontend/src/app/registration/success/page.tsx b/frontend/src/app/registration/success/page.tsx index 658b3e9..9a306f5 100644 --- a/frontend/src/app/registration/success/page.tsx +++ b/frontend/src/app/registration/success/page.tsx @@ -56,6 +56,9 @@ function RegistrationSuccessContent() { const totalDue = React.useMemo(() => { if (!reg) return 0; + // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans + // multiple early-bird prices) — fall back to the old client-side estimate otherwise. + if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed; try { return (reg.registrationOptions || []).reduce((s: number, ro: any) => { const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) diff --git a/frontend/src/components/events/ContactButton.tsx b/frontend/src/components/events/ContactButton.tsx new file mode 100644 index 0000000..900b4d8 --- /dev/null +++ b/frontend/src/components/events/ContactButton.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { X, Phone, Mail, User } from "lucide-react"; + +type ContactButtonProps = { + contactName?: string | null; + contactPhone?: string | null; + contactEmail?: string | null; + className?: string; + label?: string; +}; + +export function ContactButton({ contactName, contactPhone, contactEmail, className, label = "Contact us" }: ContactButtonProps) { + const [open, setOpen] = useState(false); + const hasDetails = !!(contactName || contactPhone || contactEmail); + + return ( + <> + + {open && ( +
setOpen(false)} + > +
e.stopPropagation()}> +
+

Contact us

+ +
+ {hasDetails ? ( +
+ {contactName && ( +
+ + {contactName} +
+ )} + {contactPhone && ( + + )} + {contactEmail && ( + + )} +
+ ) : ( +

No contact details have been provided for this event.

+ )} +
+
+ )} + + ); +} diff --git a/frontend/src/components/events/EventCard.tsx b/frontend/src/components/events/EventCard.tsx index 2c29320..ec922b0 100644 --- a/frontend/src/components/events/EventCard.tsx +++ b/frontend/src/components/events/EventCard.tsx @@ -1,5 +1,6 @@ import { ApiImage } from "@/components/shared/ApiImage"; import { Calendar } from "lucide-react"; +import { ContactButton } from "@/components/events/ContactButton"; type Event = { id: string; @@ -12,6 +13,10 @@ import { Calendar } from "lucide-react"; price: number; picture?: string; isSoldOut?: boolean; + requiresRegistration?: boolean; + contactName?: string | null; + contactPhone?: string | null; + contactEmail?: string | null; }; import { formatDateTimeRange } from "@/lib/date"; @@ -47,6 +52,15 @@ export const EventCard = ({ event }: { event: Event }) => { View details {(() => { + if (event.requiresRegistration === false) { + return ( + + ); + } const now = new Date(); const end = new Date(event.endDate); const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; diff --git a/frontend/src/components/reports/ReportsV2.tsx b/frontend/src/components/reports/ReportsV2.tsx index ceff001..4f761bd 100644 --- a/frontend/src/components/reports/ReportsV2.tsx +++ b/frontend/src/components/reports/ReportsV2.tsx @@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) { Object.keys(registrationsByEvent).forEach(evId => { (registrationsByEvent[evId] || []).forEach((r: any) => { const lastAt = lastPaymentAtByReg.get(r.id) || null; - const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0); + // Backend attaches a tranche-aware totalDueComputed, which locks each tranche's price + // at the time it was purchased — it's already time-invariant, so the "dueNow vs + // dueAtLast" lock-in dance below is only needed as a fallback for legacy rows without it. + const hasComputed = r.totalDueComputed !== null && r.totalDueComputed !== undefined; + const dueNow = hasComputed ? r.totalDueComputed : (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0); const paid = paidByReg.get(r.id) || 0; let outstanding = Math.max(dueNow - paid, 0); - if (lastAt) { + if (lastAt && !hasComputed) { const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0); if (paid >= dueAtLast) outstanding = 0; } @@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) { const ro = (r.registrationOptions || []) .find((x: any) => x.eventOption?.id === opt.id); - const price = ro ? optionUnitPrice(ro, null, new Date()) : 0; + // Backend attaches a tranche-aware lineTotal (exact even when this line spans + // multiple early-bird prices) — fall back to the old blended-price estimate + // otherwise. __prices stores an *average* unit price derived from that, purely + // for display; __revenue carries the real total used for aggregation below. + const lineTotal = ro + ? (ro.lineTotal !== null && ro.lineTotal !== undefined ? ro.lineTotal : optionUnitPrice(ro, null, new Date()) * qty) + : 0; - baseRow.__prices[opt.name] = price; // 👈 store price - baseRow.orderTotal += price * qty; + baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0; + baseRow.__revenue = baseRow.__revenue || {}; + baseRow.__revenue[opt.name] = lineTotal; + baseRow.orderTotal += lineTotal; }); rows.push(baseRow); @@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) { const qty = row[opt.name] || 0; totals[opt.name] += qty; - // revenue per option - const price = - Number( - masterRows - .find(r => r === row)?.__prices?.[opt.name] ?? 0 - ); - - totals[`${opt.name}_revenue`] += qty * price; + // revenue per option — use the tranche-aware per-row total computed above rather + // than re-deriving qty*price from a blended average price. + totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0); }); });