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:
@@ -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 }) });
|
||||
|
||||
Reference in New Issue
Block a user