Files
hope-events/backend/tests/bundles.test.js
T
joshuaandClaude Opus 5.5 763b66ff56 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>
2026-10-21 15:14:53 +02:00

109 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});