Files
hope-events/backend/tests/coupons.test.js
T
joshuaandClaude Opus 5.5 709ea38c10 Add coupon codes and staff pricing
Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:10 +02:00

177 lines
7.8 KiB
JavaScript

const {
normalizeCode,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
} = require('../src/utils/coupons');
const { computeRegistrationTotalDue, staffPriceFor } = require('../src/utils/pricing');
const NOW = new Date('2026-06-15T10:00:00Z');
const PAST = new Date('2026-06-01T00:00:00Z');
const FUTURE = new Date('2026-07-01T00:00:00Z');
const coupon = (over = {}) => ({
id: 'c1', code: 'STAFF50', type: 'percent', value: 50, stackEarlyBird: false, active: true,
validFrom: null, validUntil: null, maxUses: null, maxUsesPerUser: 1, minQuantity: null,
restrictRoles: [], restrictDomain: null, events: [], options: [], allowedUsers: [],
...over,
});
// 2 adult tickets at R200 (standard) and 1 T-shirt at R100.
const LINES = [
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 200, basePrice: 200 },
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 100, basePrice: 100 },
];
describe('normalizeCode', () => {
test('trims and uppercases', () => {
expect(normalizeCode(' staff50 ')).toBe('STAFF50');
expect(normalizeCode(null)).toBe('');
});
});
describe('computeCouponDiscount', () => {
test('percentage over the whole event', () => {
expect(computeCouponDiscount(coupon(), LINES)).toBe(250);
});
test('fixed amount, capped at what is in scope', () => {
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 120 }), LINES)).toBe(120);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 9999 }), LINES)).toBe(500);
});
test('comp covers only in-scope options', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'adult', variantId: null }] });
expect(computeCouponDiscount(c, LINES)).toBe(400);
});
test('scope can target a single variant', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'M' }] });
expect(computeCouponDiscount(c, LINES)).toBe(0);
const c2 = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'L' }] });
expect(computeCouponDiscount(c2, LINES)).toBe(100);
});
test('best price wins against early-bird when not stacking', () => {
// Early-bird R150 vs standard R200: 10% off standard = R180, early-bird already beats it.
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10 }), eb)).toBe(0);
// 50% off standard = R100, beats R150 early-bird by R50.
expect(computeCouponDiscount(coupon({ value: 50 }), eb)).toBe(50);
// Fixed R80 off standard = R120, beats R150 by R30.
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80 }), eb)).toBe(30);
});
test('stacking applies on top of the early-bird price', () => {
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10, stackEarlyBird: true }), eb)).toBe(15);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80, stackEarlyBird: true }), eb)).toBe(80);
});
test('never goes below zero or above the price', () => {
expect(computeCouponDiscount(coupon({ value: 150 }), LINES)).toBe(500);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: -10 }), LINES)).toBe(0);
expect(computeCouponDiscount(coupon(), [])).toBe(0);
});
});
describe('validateCoupon', () => {
const ctx = (over = {}) => ({ eventId: 'e1', user: { id: 'u1', role: 'user', email: 'a@church.org' }, now: NOW, usesTotal: 0, usesByUser: 0, lines: LINES, ...over });
test('accepts an unrestricted active code', () => {
expect(validateCoupon(coupon(), ctx())).toEqual({ ok: true });
});
test('rejects inactive, not-yet-valid and expired codes', () => {
expect(validateCoupon(coupon({ active: false }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ validFrom: FUTURE }), ctx()).message).toMatch(/isn't valid yet/);
expect(validateCoupon(coupon({ validUntil: PAST }), ctx()).message).toMatch(/expired/);
});
test('limits to the coupon events when any are set', () => {
expect(validateCoupon(coupon({ events: [{ eventId: 'e2' }] }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ events: [{ eventId: 'e1' }, { eventId: 'e2' }] }), ctx()).ok).toBe(true);
});
test('restricted codes need a known account matching any restriction', () => {
const staffOnly = coupon({ restrictRoles: ['staff', 'supervisor', 'admin'] });
expect(validateCoupon(staffOnly, ctx({ user: null })).message).toMatch(/Log in/);
expect(validateCoupon(staffOnly, ctx()).ok).toBe(false);
expect(validateCoupon(staffOnly, ctx({ user: { id: 'u1', role: 'staff', email: 'x@y.z' } })).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: '@church.org' }), ctx()).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: 'other.org' }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ allowedUsers: [{ userId: 'u1' }] }), ctx()).ok).toBe(true);
});
test('enforces total and per-user use limits', () => {
expect(validateCoupon(coupon({ maxUses: 5 }), ctx({ usesTotal: 5 })).message).toMatch(/fully used/);
expect(validateCoupon(coupon({ maxUsesPerUser: 1 }), ctx({ usesByUser: 1 })).message).toMatch(/already used/);
expect(validateCoupon(coupon({ maxUsesPerUser: null }), ctx({ usesByUser: 9 })).ok).toBe(true);
});
test('needs qualifying items and the minimum quantity', () => {
expect(validateCoupon(coupon({ options: [{ eventOptionId: 'other' }] }), ctx()).message).toMatch(/doesn't apply/);
expect(validateCoupon(coupon({ minQuantity: 4 }), ctx()).message).toMatch(/at least 4/);
expect(validateCoupon(coupon({ minQuantity: 3 }), ctx()).ok).toBe(true);
});
});
describe('buildCouponLines', () => {
test('one line per tranche; early-bird tranches keep the standard price as base', () => {
const reg = {
registrationOptions: [{
eventOptionId: 'adult', variantId: null, quantity: 3,
eventOption: { price: 200, variants: [] },
tranches: [
{ quantity: 2, priceSnapshot: 150, appliedTierId: 'tier1' },
{ quantity: 1, priceSnapshot: 200, appliedTierId: null },
],
}],
};
expect(buildCouponLines(reg)).toEqual([
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 150, basePrice: 200 },
{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 200, basePrice: 200 },
]);
});
});
describe('computeRegistrationTotalDue with a discount', () => {
const reg = (discountTotal) => ({
discountTotal,
registrationOptions: [{ quantity: 2, tranches: [{ quantity: 2, priceSnapshot: 100 }] }],
});
test('subtracts the coupon discount', () => {
expect(computeRegistrationTotalDue(reg(50), NOW)).toBe(150);
});
test('never goes below zero', () => {
expect(computeRegistrationTotalDue(reg(500), NOW)).toBe(0);
});
});
describe('staffPriceFor', () => {
const option = { price: 200, staffPrice: 50, variants: [{ id: 'v1', staffPrice: 20 }, { id: 'v2', staffPrice: null }] };
test('applies to Staff and above only', () => {
expect(staffPriceFor(option, null, { role: 'staff' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'admin' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'user' })).toBeNull();
expect(staffPriceFor(option, null, null)).toBeNull();
});
test('a variant staff price overrides the option one', () => {
expect(staffPriceFor(option, 'v1', { role: 'staff' })).toBe(20);
expect(staffPriceFor(option, 'v2', { role: 'staff' })).toBe(50);
});
});
describe('describeCoupon', () => {
test('summarises each type', () => {
expect(describeCoupon(coupon())).toBe('50% off');
expect(describeCoupon(coupon({ type: 'fixed', value: 25 }))).toBe('R25.00 off');
expect(describeCoupon(coupon({ type: 'comp' }))).toBe('Free');
});
});