Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a separate price tranche instead of overwriting a single price/quantity on repeat purchases, so buying more tickets after a tier expires no longer re-prices tickets already bought at the old price. Stock-limit checks, total-due calculation, and the Finance report's revenue-by- option are all tranche-aware; pages that showed one blended price per line now render/total each tranche. Viewing a pending/partially-paid registration (dashboard, detail page, or an event's registration list) now refreshes stale pricing on the spot instead of only at payment time. - Fixed the "(early bird)" dashboard label incorrectly firing on any line priced below the base option price (e.g. a plain cheaper variant) — it now checks the real applied-tier flag. - Added contact-only events (e.g. baptism): no registration/payment flow, shown on the public site with a "Contact us" popup instead of a Register button. Configurable via the admin event wizard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
+29
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
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,14 +514,13 @@ 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: {
|
||||
const include = {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: { select: { id: true, name: true, price: true } },
|
||||
tickets: true
|
||||
tickets: true,
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
event: true,
|
||||
@@ -490,14 +534,22 @@ const getRegistrationById = async (req, res) => {
|
||||
},
|
||||
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({
|
||||
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,46 +1228,98 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
}
|
||||
const mergedOptions = Array.from(mergedOptionsMap.values());
|
||||
|
||||
// Resolve pricing for each incoming option (variant-aware, with stock check)
|
||||
// Group existing registrationOptions by eventOptionId::variantId so tickets that
|
||||
// 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 || ''}`;
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, []);
|
||||
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, opt.quantity);
|
||||
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
|
||||
priceSnapshot = variantResolved.price;
|
||||
appliedTierId = variantResolved.tierId;
|
||||
} else {
|
||||
const resolved = await resolveOptionPrice(eventOption, opt.quantity);
|
||||
const resolved = await resolveOptionPrice(eventOption, delta);
|
||||
priceSnapshot = resolved.price;
|
||||
appliedTierId = resolved.tierId;
|
||||
}
|
||||
} catch (e) {
|
||||
priceSnapshot = Number(eventOption?.price || 0);
|
||||
}
|
||||
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId });
|
||||
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;
|
||||
}
|
||||
|
||||
const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0);
|
||||
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');
|
||||
}
|
||||
|
||||
// Group existing registrationOptions by eventOptionId::variantId so tickets that
|
||||
// have already been issued are never deleted, only ever updated in place.
|
||||
const oldByKey = new Map();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
if (!oldByKey.has(key)) oldByKey.set(key, []);
|
||||
oldByKey.get(key).push(ro);
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
},
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+132
-44
@@ -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;
|
||||
|
||||
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 registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
||||
}
|
||||
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
function attachComputedTotalsToList(registrations) {
|
||||
return (registrations || []).map(attachComputedTotals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -245,5 +330,8 @@ module.exports = {
|
||||
resolveOptionPrice,
|
||||
resolveVariantTierPrice,
|
||||
refreshPricingForRegistration,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
attachComputedTotals,
|
||||
attachComputedTotalsToList,
|
||||
};
|
||||
@@ -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() {
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.registrationOptions.map((opt: any) => (
|
||||
{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),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
return (
|
||||
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{(() => {
|
||||
const unit = (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)}`;
|
||||
})()}
|
||||
</div>
|
||||
{opt.appliedTierId && (
|
||||
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
|
||||
{tranches.map((t: any, idx: number) => {
|
||||
const unit = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<div key={t.id || idx} className="text-gray-500">
|
||||
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
|
||||
{t.appliedTierId && (
|
||||
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
<div className="border rounded p-3 text-center">
|
||||
<div className="text-xs text-gray-500">TOTAL</div>
|
||||
<div className="font-semibold">
|
||||
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)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 text-center bg-red-50">
|
||||
|
||||
@@ -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) {
|
||||
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
||||
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="contact-only-toggle"
|
||||
className="mt-0.5"
|
||||
checked={!draft.requiresRegistration}
|
||||
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
|
||||
/>
|
||||
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
</label>
|
||||
</div>
|
||||
{draft.requiresRegistration ? (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
|
||||
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
|
||||
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below — enter 0 for a free event.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 border rounded">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Name</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactName} onChange={e => upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Phone</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactPhone} onChange={e => upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Contact Email</label>
|
||||
<input type="email" className="w-full border rounded px-3 py-2 text-sm" value={draft.contactEmail} onChange={e => upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Picture</label>
|
||||
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function ManualRegistrationPage() {
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -199,8 +199,9 @@ function PaymentsContent() {
|
||||
const now = new Date();
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of list) {
|
||||
// totalDue uses priceSnapshot — not time-dependent
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
// 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) };
|
||||
}
|
||||
|
||||
@@ -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<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
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<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDue uses priceSnapshot — not time-dependent, 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<any[]>("/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<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
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() {
|
||||
</div>
|
||||
{!editMode ? (
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
{(activeReg.registrationOptions || []).map((opt: any) => {
|
||||
{(activeReg.registrationOptions || []).flatMap((opt: any) => {
|
||||
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
|
||||
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
// A line can span multiple price tranches (e.g. tickets bought before
|
||||
// and after an early-bird tier expired) — render one row per tranche so
|
||||
// each shows its own price, rather than one blended row for the line.
|
||||
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);
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
return tranches.map((t: any, idx: number) => {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<li key={opt.id}>
|
||||
{opt.eventOption?.name}{variantLabel} x {opt.quantity} — {formatRand(unitPrice * (opt.quantity || 0))}
|
||||
{unitPrice < (opt.eventOption?.price || 0) && (
|
||||
<li key={`${opt.id}-${t.id || idx}`}>
|
||||
{opt.eventOption?.name}{variantLabel} x {t.quantity} — {formatRand(unitPrice * (t.quantity || 0))}
|
||||
{t.appliedTierId && (
|
||||
<span className="ml-1 text-xs text-green-700">(early bird)</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<ContactButton
|
||||
contactName={event.contactName}
|
||||
contactPhone={event.contactPhone}
|
||||
contactEmail={event.contactEmail}
|
||||
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={className || "flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold">Contact us</h3>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{contactName && (
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<User className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<span>{contactName}</span>
|
||||
</div>
|
||||
)}
|
||||
{contactPhone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
|
||||
</div>
|
||||
)}
|
||||
{contactEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</a>
|
||||
{(() => {
|
||||
if (event.requiresRegistration === false) {
|
||||
return (
|
||||
<ContactButton
|
||||
contactName={event.contactName}
|
||||
contactPhone={event.contactPhone}
|
||||
contactEmail={event.contactEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user