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() {
Auto-fills the first ticket option below — enter 0 for a free event.
} +Auto-fills the first ticket option below — enter 0 for a free event.
} +No contact details have been provided for this event.
+ )} +