Files
hope-events/backend/tests/seasonPass.test.js
T
joshuaandClaude Opus 5.5 a7c8148588 Season passes (1.47.3)
A pass sells a ticket at each of several events in a series for one
price. Supervisors set passes up on the Event series page (events and
ticket per event; fixed once sold). Buying on the series page registers
the buyer at every event at once with one checkout (free passes give
tickets straight away; Pay now if online payment isn't possible). The
price is split over the events in proportion to their normal prices:
each registration line is priced at its share and the webhook records
one Payment per event, so cashups stay per event. Pass registrations
aren't repriced, take no coupons and can't be edited. Migration
20261028150000_season_pass.

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

121 lines
6.3 KiB
JavaScript

const mockDb = {
payment: { findFirst: jest.fn(), create: jest.fn() },
seasonPassPurchase: { findUnique: jest.fn(), update: jest.fn() },
registration: { findUnique: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockStatus = jest.fn();
const mockSaveTx = jest.fn();
jest.mock('../src/payments/webhook', () => ({
updateRegistrationStatus: (...a) => mockStatus(...a),
saveProviderTransaction: (...a) => mockSaveTx(...a),
}));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn().mockResolvedValue() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn().mockResolvedValue() }));
const pass = require('../src/utils/seasonPass');
const { isBlocked } = require('../src/hub/readOnly');
describe('season passes', () => {
describe('splitting the price', () => {
test('in proportion to normal prices, in whole cents that add up', () => {
// R400 pass: a R300 camp and two R50 breakfasts.
expect(pass.splitCents(40000, [30000, 5000, 5000])).toEqual([30000, 5000, 5000]);
// R100 over three equal events: the odd cent goes to the first.
expect(pass.splitCents(10000, [5000, 5000, 5000])).toEqual([3334, 3333, 3333]);
const parts = pass.splitCents(9999, [1234, 5678, 91]);
expect(parts.reduce((s, x) => s + x, 0)).toBe(9999);
});
test('free tickets everywhere: split equally', () => {
expect(pass.splitCents(9000, [0, 0, 0])).toEqual([3000, 3000, 3000]);
});
test('a variant\'s own price counts, else the ticket\'s', () => {
const items = [
{ eventOption: { price: 300 }, variant: { price: 200 } },
{ eventOption: { price: 50 }, variant: { price: null } },
{ eventOption: { price: 50 }, variant: null },
];
expect(pass.itemShares(240, items)).toEqual([160, 40, 40]);
});
});
test('a payment pays what each registration owes, in order; anything over goes to the last', () => {
expect(pass.allocate([160, 40, 40], 240)).toEqual([160, 40, 40]);
expect(pass.allocate([160, 40, 40], 100)).toEqual([100, 0, 0]);
expect(pass.allocate([0, 40, 40], 90)).toEqual([0, 40, 50]);
});
describe('when a pass is on sale', () => {
const now = new Date('2027-01-10T08:00:00Z');
const item = (start, extra = {}) => ({ event: { title: 'Breakfast', isActive: true, cashupStatus: 'open', startDate: new Date(start), endDate: new Date(start), scheduleType: 'single', sessions: [], ...extra } });
const base = { isActive: true, saleEndsAt: null, items: [item('2027-02-01T07:00:00Z'), item('2027-03-01T07:00:00Z')] };
test('on sale before the first event starts', () => {
expect(pass.notOnSale(base, now)).toBeNull();
});
test('stops when the first event starts, at the sale end, or switched off', () => {
expect(pass.notOnSale({ ...base, items: [item('2027-01-09T07:00:00Z'), ...base.items] }, now)).toMatch(/first event started/);
expect(pass.notOnSale({ ...base, saleEndsAt: new Date('2027-01-01') }, now)).toMatch(/no longer on sale/);
expect(pass.notOnSale({ ...base, isActive: false }, now)).toMatch(/isn't on sale/);
expect(pass.notOnSale({ ...base, items: [item('2027-02-01T07:00:00Z', { registrationDeadline: new Date('2027-01-05') })] }, now)).toMatch(/has closed/);
});
});
describe('recording the payment', () => {
const regs = [
{ id: 'r1', eventId: 'camp', status: 'pending', createdAt: 1, registrationOptions: [{ quantity: 2, priceSnapshot: 160, tranches: [{ quantity: 2, priceSnapshot: 160 }] }], payments: [] },
{ id: 'r2', eventId: 'b1', status: 'pending', createdAt: 2, registrationOptions: [{ quantity: 2, priceSnapshot: 40, tranches: [{ quantity: 2, priceSnapshot: 40 }] }], payments: [] },
];
const event = { transaction: { externalId: 'ext-1', amountCents: 40000, status: 'succeeded', methodType: 'card', metadata: { type: 'season_pass', purchaseId: 'pp1' } } };
beforeEach(() => {
jest.clearAllMocks();
mockDb.payment.findFirst.mockResolvedValue(null);
mockDb.seasonPassPurchase.findUnique.mockResolvedValue({ id: 'pp1', userId: 'u1', registrations: regs });
mockDb.payment.create.mockImplementation(({ data }) => Promise.resolve(data));
mockStatus.mockResolvedValue({ status: 'paid' });
mockSaveTx.mockResolvedValue();
});
test('one payment per event for its share; only the first carries the provider id', async () => {
const out = await pass.recordPassPayment('yoco', event, {});
const made = mockDb.payment.create.mock.calls.map(c => c[0].data);
expect(made.map(p => [p.registrationId, p.eventId, p.amount, p.externalId, p.seasonPassPurchaseId])).toEqual([
['r1', 'camp', 320, 'ext-1', 'pp1'],
['r2', 'b1', 80, null, 'pp1'],
]);
expect(made.every(p => p.isDonation === undefined && p.originalPaymentId === undefined)).toBe(true);
expect(mockStatus).toHaveBeenCalledTimes(2);
expect(mockDb.seasonPassPurchase.update).toHaveBeenCalledWith({ where: { id: 'pp1' }, data: { status: 'paid' } });
expect(mockSaveTx.mock.calls[0][3]).toEqual({ reconciled: true, paymentId: made[0].id });
expect(out.status).toBe('success');
});
test('the same provider payment twice is recorded once', async () => {
mockDb.payment.findFirst.mockResolvedValue({ id: 'already' });
const out = await pass.recordPassPayment('yoco', event, {});
expect(out).toMatchObject({ status: 'already_processed', paymentId: 'already' });
expect(mockDb.payment.create).not.toHaveBeenCalled();
});
});
test('a registration from a pass can\'t be edited or take a code', async () => {
mockDb.registration.findUnique.mockResolvedValue({ seasonPassPurchaseId: 'pp1' });
const res = { status: jest.fn(() => res), json: jest.fn() };
const next = jest.fn();
pass.notOnSeasonPass({ params: { id: 'r1' } }, res, next);
await new Promise(r => setImmediate(r));
expect(res.status).toHaveBeenCalledWith(409);
expect(next).not.toHaveBeenCalled();
});
test('making, changing and buying passes is blocked in licence read-only mode', () => {
expect(isBlocked('POST', '/api/season-passes')).toBe(true);
expect(isBlocked('PUT', '/api/season-passes/p1')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/p1/buy')).toBe(true);
expect(isBlocked('POST', '/api/season-passes/purchases/pp1/checkout')).toBe(true);
});
});