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>
195 lines
10 KiB
JavaScript
195 lines
10 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('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);
|
||
});
|
||
});
|