Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for a set price), free (cheapest freeCount in each set free) and group (setSize or more qualifying tickets at a per-ticket price or % off, applied once). The supervisor page has a type picker, buyers see a one-line description of each deal, and the Complete bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
12 KiB
JavaScript
256 lines
12 KiB
JavaScript
/**
|
||
* Bundle deals — tickets sold together at a better price. Four kinds:
|
||
* - exact: these tickets in these numbers for a set price, e.g. 5 Adult + 3 Child for R500.
|
||
* - mix: any `setSize` of the qualifying tickets for a set price, e.g. any 4 for R250.
|
||
* - free: any `setSize` qualifying tickets with the cheapest `freeCount` free, e.g.
|
||
* buy 3 get 1 free (setSize 4, freeCount 1).
|
||
* - group: `setSize` or more qualifying tickets, all of them at `price` each
|
||
* (groupPricing per_ticket) or `price`% off (percent).
|
||
*
|
||
* Rules:
|
||
* - Applied automatically: no code needed. exact, mix and free apply as many times as
|
||
* the registration's items allow; leftover items are charged as usual. A group
|
||
* discount applies once, to every qualifying ticket.
|
||
* - Best price wins: a bundle only counts when it beats what its items cost at the
|
||
* prices actually charged (early-bird, staff price), so it never makes anything
|
||
* dearer. A group discount never raises a ticket's price. The most expensive matching
|
||
* units go into a bundle first, and when several bundles compete the one saving the
|
||
* most is applied first.
|
||
* - A bundle item with no variant matches any variant of its option.
|
||
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
|
||
* to the coupon code at their share of the bundle price (see utils/coupons.js).
|
||
* - The discount is stored on Registration.bundleDiscount (and included in
|
||
* discountTotal), with one RegistrationBundle row per bundle used.
|
||
* - A registration keeps any bundle it already has, even after the bundle is switched
|
||
* off or its dates pass, so editing a registration never takes a deal away.
|
||
*/
|
||
|
||
const prisma = require('../config/db');
|
||
|
||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||
|
||
const KINDS = ['exact', 'mix', 'free', 'group'];
|
||
|
||
/** Whether a bundle's settings make sense (bad rows are skipped, never applied). */
|
||
function isBundleUsable(bundle) {
|
||
const items = (bundle?.items || []).filter(i => i && i.eventOptionId);
|
||
if (items.length === 0 || !(Number(bundle.price) >= 0)) return false;
|
||
const kind = bundle.kind || 'exact';
|
||
const size = Number(bundle.setSize);
|
||
if (kind === 'exact') return items.some(i => Number(i.quantity) > 0);
|
||
if (!Number.isInteger(size) || size < 1) return false;
|
||
if (kind === 'free') return Number.isInteger(Number(bundle.freeCount)) && bundle.freeCount >= 1 && bundle.freeCount < size;
|
||
if (kind === 'group' && bundle.groupPricing === 'percent') return Number(bundle.price) > 0 && Number(bundle.price) <= 100;
|
||
return KINDS.includes(kind);
|
||
}
|
||
|
||
/** Whether a bundle can be offered right now (switched on, within its dates, set up properly). */
|
||
function isBundleAvailable(bundle, now = new Date()) {
|
||
if (!bundle || !bundle.isActive) return false;
|
||
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
|
||
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
|
||
return isBundleUsable(bundle);
|
||
}
|
||
|
||
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
|
||
const qualifies = (bundle, line) => (bundle.items || []).some(i => itemMatches(i, line));
|
||
|
||
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
|
||
function takeDearest(pool, left, need, matches) {
|
||
const takes = [];
|
||
const candidates = pool
|
||
.map((p, index) => ({ p, index }))
|
||
.filter(({ p, index }) => left[index] > 0 && matches(p.line))
|
||
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
|
||
for (const { p, index } of candidates) {
|
||
if (need === 0) break;
|
||
const q = Math.min(need, left[index]);
|
||
left[index] -= q;
|
||
need -= q;
|
||
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
|
||
}
|
||
return need > 0 ? null : takes;
|
||
}
|
||
|
||
// Every unit at the same share of a set price.
|
||
const atSetPrice = (takes, price) => {
|
||
const charged = takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0);
|
||
const ratio = charged > 0 ? price / charged : 0;
|
||
return { charged, takes: takes.map(t => ({ ...t, newPrice: t.unitPrice * ratio })) };
|
||
};
|
||
|
||
/**
|
||
* Works out one application of `bundle` against what's left in `pool`, without changing
|
||
* it. Returns { takes: [{ index, quantity, newPrice }], saving } or null when the
|
||
* bundle can't apply.
|
||
*/
|
||
function tryBundle(bundle, pool) {
|
||
const left = pool.map(p => p.remaining);
|
||
const kind = bundle.kind || 'exact';
|
||
const price = Number(bundle.price || 0);
|
||
let priced;
|
||
|
||
if (kind === 'exact') {
|
||
// Variant-specific items first, so an "any variant" item can't use up the units a
|
||
// specific one needs.
|
||
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
|
||
const takes = [];
|
||
for (const item of items) {
|
||
const got = takeDearest(pool, left, Number(item.quantity), line => itemMatches(item, line));
|
||
if (!got) return null;
|
||
takes.push(...got);
|
||
}
|
||
priced = atSetPrice(takes, price);
|
||
} else if (kind === 'mix' || kind === 'free') {
|
||
const takes = takeDearest(pool, left, Number(bundle.setSize), line => qualifies(bundle, line));
|
||
if (!takes) return null;
|
||
if (kind === 'mix') {
|
||
priced = atSetPrice(takes, price);
|
||
} else {
|
||
// The cheapest freeCount units cost nothing; split takes so only those are free.
|
||
let free = Number(bundle.freeCount);
|
||
const out = [];
|
||
for (const t of [...takes].sort((a, b) => a.unitPrice - b.unitPrice)) {
|
||
const f = Math.min(free, t.quantity);
|
||
free -= f;
|
||
if (f > 0) out.push({ ...t, quantity: f, newPrice: 0 });
|
||
if (t.quantity - f > 0) out.push({ ...t, quantity: t.quantity - f, newPrice: t.unitPrice });
|
||
}
|
||
priced = { charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0), takes: out };
|
||
}
|
||
} else if (kind === 'group') {
|
||
const total = pool.reduce((s, p, i) => s + (qualifies(bundle, p.line) ? left[i] : 0), 0);
|
||
if (total < Number(bundle.setSize)) return null;
|
||
const takes = takeDearest(pool, left, total, line => qualifies(bundle, line));
|
||
const pct = Math.min(100, Math.max(0, price)) / 100;
|
||
priced = {
|
||
charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0),
|
||
takes: takes.map(t => ({ ...t, newPrice: bundle.groupPricing === 'percent' ? t.unitPrice * (1 - pct) : Math.min(t.unitPrice, price) })),
|
||
};
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
const after = priced.takes.reduce((s, t) => s + t.quantity * t.newPrice, 0);
|
||
return { takes: priced.takes, saving: priced.charged - after };
|
||
}
|
||
|
||
/**
|
||
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
|
||
*
|
||
* @param {Array} bundles - [{ id, name, kind, price, setSize, freeCount, groupPricing,
|
||
* items: [{ eventOptionId, variantId?, quantity }] }]
|
||
* already filtered to the ones this registration may use
|
||
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
|
||
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
|
||
* `lines` are the input lines with bundled units split out at their bundle
|
||
* price (flagged `bundled: true`), ready for the coupon calculation.
|
||
*/
|
||
function applyBundles(bundles, lines) {
|
||
const usable = (bundles || []).filter(isBundleUsable);
|
||
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
|
||
const byBundle = new Map();
|
||
|
||
// Each round applies the single best-saving bundle once; stops when nothing saves.
|
||
const maxRounds = pool.reduce((s, p) => s + p.remaining, 0);
|
||
for (let round = 0; round < maxRounds; round++) {
|
||
let best = null;
|
||
for (const bundle of usable) {
|
||
// A group discount covers every qualifying ticket in one go, so it applies once.
|
||
if (bundle.kind === 'group' && byBundle.has(bundle.id)) continue;
|
||
const attempt = tryBundle(bundle, pool);
|
||
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
|
||
}
|
||
if (!best) break;
|
||
const { bundle, attempt } = best;
|
||
for (const t of attempt.takes) {
|
||
pool[t.index].remaining -= t.quantity;
|
||
pool[t.index].bundled.push({ quantity: t.quantity, unitPrice: t.newPrice });
|
||
}
|
||
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
|
||
entry.count += 1;
|
||
entry.discount += attempt.saving;
|
||
byBundle.set(bundle.id, entry);
|
||
}
|
||
|
||
const applications = [...byBundle.values()].map(a => ({ ...a, discount: round2(a.discount) }));
|
||
const outLines = [];
|
||
for (const p of pool) {
|
||
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
|
||
for (const b of p.bundled) {
|
||
outLines.push({ ...p.line, quantity: b.quantity, unitPrice: b.unitPrice, basePrice: b.unitPrice, bundled: true });
|
||
}
|
||
}
|
||
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
|
||
}
|
||
|
||
const itemLabel = (i) => `${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`;
|
||
|
||
/** "5 × Adult + 3 × Child (Large)", or for the other kinds "Adult, Child". Needs names loaded. */
|
||
function describeBundleItems(bundle) {
|
||
const items = bundle?.items || [];
|
||
if ((bundle?.kind || 'exact') === 'exact') return items.map(i => `${i.quantity} × ${itemLabel(i)}`).join(' + ');
|
||
return items.map(itemLabel).join(', ');
|
||
}
|
||
|
||
/**
|
||
* The deal in one line, e.g. "2 × Adult + 3 × Child for R220", "Any 4 of Adult, Child
|
||
* for R250", "Buy 3, get 1 free: Adult, Child", "10+ tickets (Adult, Child): R70 each".
|
||
*/
|
||
function describeBundle(bundle) {
|
||
const kind = bundle?.kind || 'exact';
|
||
const items = describeBundleItems(bundle);
|
||
const price = Number(bundle?.price || 0);
|
||
if (kind === 'mix') return `Any ${bundle.setSize} of ${items} for ${fmtR(price)}`;
|
||
if (kind === 'free') return `Buy ${bundle.setSize - bundle.freeCount}, get ${bundle.freeCount} free: ${items}`;
|
||
if (kind === 'group') {
|
||
return `${bundle.setSize}+ tickets (${items}): ${bundle.groupPricing === 'percent' ? `${price}% off` : `${fmtR(price)} each`}`;
|
||
}
|
||
return `${items} for ${fmtR(price)}`;
|
||
}
|
||
|
||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||
|
||
const BUNDLE_INCLUDE = {
|
||
items: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } } } },
|
||
};
|
||
|
||
// A registration's bundle deals, for API responses and receipts.
|
||
const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, discount: true, bundle: { select: { id: true, name: true } } } };
|
||
|
||
/**
|
||
* Bundles a registration on this event may use: the ones available now, plus any in
|
||
* `keepIds` (bundles the registration already has).
|
||
*/
|
||
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
|
||
if (!eventId) return [];
|
||
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && isBundleUsable(b)));
|
||
}
|
||
|
||
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
|
||
async function saveBundleApplications(registrationId, existing, applications, db = prisma) {
|
||
const key = (list) => JSON.stringify([...list].map(a => [a.bundleId, a.count, round2(a.discount)]).sort());
|
||
if (key(existing || []) === key(applications)) return;
|
||
await db.registrationBundle.deleteMany({ where: { registrationId } });
|
||
if (applications.length > 0) {
|
||
await db.registrationBundle.createMany({
|
||
data: applications.map(a => ({ registrationId, bundleId: a.bundleId, count: a.count, discount: a.discount })),
|
||
});
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
KINDS,
|
||
isBundleUsable,
|
||
isBundleAvailable,
|
||
applyBundles,
|
||
describeBundleItems,
|
||
describeBundle,
|
||
loadBundlesFor,
|
||
saveBundleApplications,
|
||
BUNDLE_INCLUDE,
|
||
BUNDLE_APPS_INCLUDE,
|
||
};
|