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:
@@ -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({
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
},
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
|
||||
Reference in New Issue
Block a user