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:
2026-08-20 17:26:35 +02:00
co-authored by Claude Sonnet 5
parent f2c3172e16
commit f0f8d4c242
22 changed files with 804 additions and 225 deletions
@@ -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";
@@ -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;
+27
View File
@@ -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
+13 -4
View File
@@ -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,
};
+13 -9
View File
@@ -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
}
});
+292 -95
View File
@@ -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,
}
});
+2 -1
View File
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: {
earlyBirdTiers: true
}
}
},
tranches: true
}
},
payments: true
+6 -4
View File
@@ -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);
+131 -43
View File
@@ -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,
};