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('pick any (mix)', () => { const anyFour = { id: 'm', name: 'Any 4', kind: 'mix', price: 250, setSize: 4, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] }; test('2 adults + 2 children or 4 adults both make a set', () => { expect(applyBundles([anyFour], [line('adult', 2, 80), line('child', 2, 50)]).discount).toBe(10); expect(applyBundles([anyFour], [line('adult', 4, 80)]).discount).toBe(70); }); test('repeats, dearest tickets first, leftovers at full price', () => { const r = applyBundles([anyFour], [line('adult', 5, 80), line('child', 4, 50)]); // Set 1: 4 adults (320 → 250). Set 2: 1 adult + 3 children (230): no saving, so not applied. expect(r.applications[0].count).toBe(1); expect(r.discount).toBe(70); expect(charged(r.lines)).toBeCloseTo(250 + 80 + 200, 6); }); test('non-qualifying tickets are left alone', () => { const r = applyBundles([anyFour], [line('adult', 2, 80), line('lunch', 5, 65)]); expect(r.discount).toBe(0); }); }); describe('buy X get Y free', () => { const b3g1 = { id: 'f', name: 'Buy 3 get 1', kind: 'free', price: 0, setSize: 4, freeCount: 1, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] }; test('the cheapest ticket in the set is free', () => { const r = applyBundles([b3g1], [line('adult', 3, 80), line('child', 1, 50)]); expect(r.discount).toBe(50); expect(r.lines.find(l => l.unitPrice === 0)).toMatchObject({ eventOptionId: 'child', quantity: 1, bundled: true }); }); test('8 tickets make two sets', () => { const r = applyBundles([b3g1], [line('adult', 8, 80)]); expect(r.applications[0].count).toBe(2); expect(r.discount).toBe(160); }); test('three tickets get nothing', () => { expect(applyBundles([b3g1], [line('adult', 3, 80)]).discount).toBe(0); }); test('a free count that is not below the set size is ignored', () => { expect(applyBundles([{ ...b3g1, freeCount: 4 }], [line('adult', 8, 80)]).discount).toBe(0); }); }); describe('group discount', () => { const tenPlus = { id: 'g', name: 'Groups', kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] }; test('10+ qualifying tickets all get the group price, once, never dearer', () => { const r = applyBundles([tenPlus], [line('adult', 8, 80), line('child', 4, 50)]); // Adults 80 → 70 (8 × 10 = 80 saved); children already cheaper, unchanged. expect(r.discount).toBe(80); expect(r.applications).toEqual([{ bundleId: 'g', name: 'Groups', count: 1, discount: 80 }]); expect(charged(r.lines)).toBeCloseTo(8 * 70 + 4 * 50, 6); }); test('below the minimum: nothing', () => { expect(applyBundles([tenPlus], [line('adult', 9, 80)]).discount).toBe(0); }); test('percentage off everything in the group', () => { const pct = { ...tenPlus, groupPricing: 'percent', price: 10 }; expect(applyBundles([pct], [line('adult', 10, 80), line('child', 2, 50)]).discount).toBe(90); }); test('an exact bundle beats a group discount when it saves more', () => { const r = applyBundles([family, tenPlus], [line('adult', 5, 80), line('child', 5, 50)]); // Group: 5 × 10 = 50 saved. Family: 50 saved on 5A+3C, then group can't reach 10 again. // Equal savings: the first found wins, either way the total is 50. expect(r.discount).toBe(50); }); }); describe('describeBundle', () => { const { describeBundle } = require('../src/utils/bundles'); const names = [{ eventOptionId: 'a', quantity: 2, eventOption: { name: 'Adult' } }, { eventOptionId: 'c', quantity: 3, eventOption: { name: 'Child' } }]; test('one line per kind', () => { expect(describeBundle({ kind: 'exact', price: 220, items: names })).toBe('2 × Adult + 3 × Child for R220'); expect(describeBundle({ kind: 'mix', price: 250, setSize: 4, items: names })).toBe('Any 4 of Adult, Child for R250'); expect(describeBundle({ kind: 'free', setSize: 4, freeCount: 1, items: names })).toBe('Buy 3, get 1 free: Adult, Child'); expect(describeBundle({ kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: names })).toBe('10+ tickets (Adult, Child): R70 each'); expect(describeBundle({ kind: 'group', price: 15, setSize: 10, groupPricing: 'percent', items: names })).toBe('10+ tickets (Adult, Child): 15% off'); }); }); 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); }); });