Add bundle deals

Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 15:14:53 +02:00
co-authored by Claude Opus 5.5
parent 8f92a8c3e9
commit 763b66ff56
35 changed files with 1766 additions and 149 deletions
+38 -8
View File
@@ -31,6 +31,7 @@ require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const { applyBundles } = require('../src/utils/bundles');
const prisma = createPrismaClient();
@@ -161,8 +162,9 @@ const add = (model, row) => {
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'registration', 'registrationOption',
'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'ticketUsage', 'couponRedemption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
'ticketUsage', 'couponRedemption', 'registrationBundle',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
@@ -241,6 +243,19 @@ function addSection(event, name, options) {
return section;
}
/** A bundle deal: items are [option, quantity] pairs. */
function addBundle(event, data, items) {
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
for (const [option, quantity] of items) add('eventBundleItem', { id: uuidv4(), bundleId: bundle.id, eventOptionId: option.id, variantId: null, quantity });
return bundle;
}
/** The event's bundles with their items, in the shape utils/bundles.js expects. */
function bundlesFor(event) {
return (rows.eventBundle || []).filter(b => b.eventId === event.id)
.map(b => ({ ...b, items: (rows.eventBundleItem || []).filter(i => i.bundleId === b.id) }));
}
function addCost(event, label, costType, amount, option = null, notes = null) {
add('eventCost', { id: uuidv4(), eventId: event.id, eventOptionId: option ? option.id : null, label, costType, amount, paidFromMethod: null, notes });
}
@@ -281,12 +296,14 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
eventId: event.id,
status: 'pending',
discountTotal: 0,
bundleDiscount: 0,
ticketDelivery: 'payer',
analyticsSessionId: null,
createdAt,
});
let subtotal = 0;
const bundleLines = [];
const regLines = lines.filter(l => l.quantity > 0).map(l => {
const { price, tier } = priceAt(l.option, l.variant || null, createdAt, l.quantity);
const ro = add('registrationOption', {
@@ -307,6 +324,7 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
createdAt,
});
subtotal += l.quantity * price;
bundleLines.push({ eventOptionId: l.option.id, variantId: l.variant ? l.variant.id : null, quantity: l.quantity, unitPrice: price, basePrice: price });
const attendees = (l.attendees || []).map((a, order) => add('attendee', {
id: uuidv4(),
registrationId: registration.id,
@@ -320,9 +338,19 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
return { ro, option: l.option, attendees };
});
// Bundle deals first, the same way the app applies them; then the coupon on what's left.
const deals = applyBundles(bundlesFor(event), bundleLines);
if (deals.discount > 0) {
registration.bundleDiscount = deals.discount;
registration.discountTotal = deals.discount;
for (const a of deals.applications) {
add('registrationBundle', { id: uuidv4(), registrationId: registration.id, bundleId: a.bundleId, count: a.count, discount: a.discount, createdAt });
}
}
if (coupon && pay !== 'cancelled') {
const discount = coupon.type === 'percent' ? round2(subtotal * coupon.value / 100) : Math.min(subtotal, coupon.value);
registration.discountTotal = discount;
const base = subtotal - registration.bundleDiscount;
const discount = coupon.type === 'percent' ? round2(base * coupon.value / 100) : Math.min(base, coupon.value);
registration.discountTotal = round2(registration.discountTotal + discount);
add('couponRedemption', { id: uuidv4(), couponId: coupon.id, registrationId: registration.id, userId: user.id, discountAmount: discount, createdAt });
}
const total = round2(subtotal - registration.discountTotal);
@@ -424,8 +452,8 @@ function buildFamilyFunDay(people) {
});
const adult = addOption(event, { name: 'Adult', price: 80 });
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
const family = addOption(event, { name: 'Family pass (2 adults + 3 kids)', price: 220 });
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
addBundle(event, { name: 'Family deal', price: 220, description: 'Two adults and three kids, R60 less than buying them separately.' }, [[adult, 2], [child, 3]]);
add('coupon', {
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
@@ -434,15 +462,17 @@ function buildFamilyFunDay(people) {
});
add('couponEvent', { id: uuidv4(), couponId: rows.coupon[rows.coupon.length - 1].id, eventId: event.id });
addSection(event, 'Main gate', [adult, child, family]);
addSection(event, 'Main gate', [adult, child]);
addSection(event, 'Food stall', [lunch]);
addCost(event, 'Jumping castles & games hire', 'once_off', 4500);
addCost(event, 'Face painter', 'once_off', 1200);
addCost(event, 'Lunch packs', 'per_item', 38, lunch, 'Supplied by the catering team');
for (const user of shuffle(people.everyone).slice(0, 30)) {
const lines = chance(0.35)
? [line(family, 1)]
// About a third come as a family of 2 + 3 and get the bundle; a few bring two families.
const families = chance(0.35) ? (chance(0.15) ? 2 : 1) : 0;
const lines = families
? [line(adult, 2 * families), line(child, 3 * families)]
: [line(adult, int(1, 2)), line(child, Number(weighted({ 0: 2, 1: 3, 2: 3, 3: 1 })))];
lines.push(line(lunch, chance(0.6) ? int(1, 4) : 0));
addRegistration(event, user, lines, { createdAt: pastDate(30), ...payPlan({ card: 0.72, eft: 0.12, none: 0.16 }) });