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>
109 lines
5.8 KiB
JavaScript
109 lines
5.8 KiB
JavaScript
const { applyBundles, isBundleAvailable } = require('../src/utils/bundles');
|
||
const { computeCouponDiscount } = require('../src/utils/coupons');
|
||
|
||
const line = (eventOptionId, quantity, unitPrice, extra = {}) => ({ eventOptionId, variantId: null, quantity, unitPrice, basePrice: unitPrice, ...extra });
|
||
const family = { id: 'b1', name: 'Family deal', price: 500, items: [{ eventOptionId: 'adult', quantity: 5 }, { eventOptionId: 'child', quantity: 3 }] };
|
||
const charged = (lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||
|
||
describe('applyBundles', () => {
|
||
test('5 adults + 3 children for R500 instead of R550', () => {
|
||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50)]);
|
||
expect(r.discount).toBe(50);
|
||
expect(r.applications).toEqual([{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 }]);
|
||
expect(charged(r.lines)).toBeCloseTo(500, 6);
|
||
expect(r.lines.every(l => l.bundled)).toBe(true);
|
||
});
|
||
|
||
test('applies as many times as fits, leftovers at full price', () => {
|
||
const r = applyBundles([family], [line('adult', 11, 80), line('child', 7, 50)]);
|
||
expect(r.applications[0].count).toBe(2);
|
||
expect(r.discount).toBe(100);
|
||
// 1 adult and 1 child left over at full price.
|
||
expect(charged(r.lines)).toBeCloseTo(1000 + 80 + 50, 6);
|
||
expect(r.lines.filter(l => !l.bundled).map(l => [l.eventOptionId, l.quantity])).toEqual([['adult', 1], ['child', 1]]);
|
||
});
|
||
|
||
test('nothing applies when the items are not all there', () => {
|
||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 2, 50)]);
|
||
expect(r.discount).toBe(0);
|
||
expect(r.applications).toEqual([]);
|
||
expect(charged(r.lines)).toBe(500);
|
||
});
|
||
|
||
test('best price wins: no discount when early-bird prices already beat the bundle', () => {
|
||
const r = applyBundles([family], [line('adult', 5, 60, { basePrice: 80 }), line('child', 3, 40, { basePrice: 50 })]);
|
||
expect(r.discount).toBe(0);
|
||
expect(charged(r.lines)).toBe(420);
|
||
});
|
||
|
||
test('the most expensive units go into the bundle first', () => {
|
||
// Two adults bought at early-bird R60, four at R80: the bundle should take the R80 ones.
|
||
const r = applyBundles([family], [line('adult', 2, 60), line('adult', 4, 80), line('child', 3, 50)]);
|
||
// 4×80 + 1×60 + 3×50 = 530 → R30 saving.
|
||
expect(r.discount).toBe(30);
|
||
expect(r.lines.find(l => !l.bundled)).toMatchObject({ eventOptionId: 'adult', quantity: 1, unitPrice: 60 });
|
||
});
|
||
|
||
test('when bundles compete, the bigger saving wins', () => {
|
||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||
const r = applyBundles([family, pair], [line('adult', 5, 80), line('child', 3, 50)]);
|
||
// Family saves 50 once; pairs save 10 each (two of them = 20), and would block the family deal.
|
||
expect(r.applications.map(a => a.bundleId)).toEqual(['b1']);
|
||
expect(r.discount).toBe(50);
|
||
});
|
||
|
||
test('different bundles can stack on different items', () => {
|
||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||
const r = applyBundles([family, pair], [line('adult', 7, 80), line('child', 3, 50)]);
|
||
expect(r.applications).toEqual([
|
||
{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 },
|
||
{ bundleId: 'b2', name: 'Pair', count: 1, discount: 10 },
|
||
]);
|
||
});
|
||
|
||
test('a variant-specific item only takes that variant; an any-variant item takes the rest', () => {
|
||
const shirts = { id: 'b3', name: 'Shirts', price: 300, items: [{ eventOptionId: 'shirt', variantId: 'L', quantity: 1 }, { eventOptionId: 'shirt', quantity: 2 }] };
|
||
const lines = [
|
||
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 150, basePrice: 150 },
|
||
{ eventOptionId: 'shirt', variantId: 'S', quantity: 2, unitPrice: 120, basePrice: 120 },
|
||
];
|
||
const r = applyBundles([shirts], lines);
|
||
expect(r.discount).toBe(90);
|
||
const noLarge = applyBundles([shirts], [{ ...lines[1], quantity: 3 }]);
|
||
expect(noLarge.discount).toBe(0);
|
||
});
|
||
|
||
test('a coupon after the bundle works out on the bundle price', () => {
|
||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50), line('lunch', 2, 40)]);
|
||
const tenPercent = { type: 'percent', value: 10, stackEarlyBird: true, options: [] };
|
||
// (500 + 80) × 10% = 58
|
||
expect(computeCouponDiscount(tenPercent, r.lines)).toBe(58);
|
||
// Best-price-wins coupons also see the bundle price as the base, so it still gives 10%.
|
||
expect(computeCouponDiscount({ ...tenPercent, stackEarlyBird: false }, r.lines)).toBe(58);
|
||
const childOnly = { ...tenPercent, options: [{ eventOptionId: 'child' }] };
|
||
// The 3 children cost 150 of 550 → 136.36 of the R500 bundle → 13.64 off.
|
||
expect(computeCouponDiscount(childOnly, r.lines)).toBe(13.64);
|
||
});
|
||
|
||
test('a free bundle (R0) still works', () => {
|
||
const r = applyBundles([{ ...family, price: 0 }], [line('adult', 5, 80), line('child', 3, 50)]);
|
||
expect(r.discount).toBe(550);
|
||
expect(charged(r.lines)).toBe(0);
|
||
});
|
||
|
||
test('bundles with no items are ignored', () => {
|
||
expect(applyBundles([{ id: 'x', name: 'x', price: 1, items: [] }], [line('adult', 3, 80)]).discount).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('isBundleAvailable', () => {
|
||
const now = new Date('2026-06-01T10:00:00Z');
|
||
test('switched off, not started, ended and empty bundles are not available', () => {
|
||
expect(isBundleAvailable({ ...family, isActive: true }, now)).toBe(true);
|
||
expect(isBundleAvailable({ ...family, isActive: false }, now)).toBe(false);
|
||
expect(isBundleAvailable({ ...family, isActive: true, validFrom: '2026-06-02' }, now)).toBe(false);
|
||
expect(isBundleAvailable({ ...family, isActive: true, validUntil: '2026-05-31' }, now)).toBe(false);
|
||
expect(isBundleAvailable({ ...family, isActive: true, items: [] }, now)).toBe(false);
|
||
});
|
||
});
|