Six site improvements picked from a "what could be better" review, plus a Jest test suite covering the two areas with the trickiest money-handling history in this project (early-bird pricing tranches, donation-leg accounting): - "Add to calendar" .ics download on event pages and in confirmation emails - sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages - Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set - Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download - Admin audit trail for refunds, donations, manual registrations, event and settings changes, and staff-initiated cancellations - Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug and the 1.4.2 donation-balance-inflation bug Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet needs a paid Apple Developer account the project doesn't have yet, and the user preferred shipping both together later rather than Google alone now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
147 lines
5.0 KiB
JavaScript
147 lines
5.0 KiB
JavaScript
const {
|
|
getEffectiveUnitPrice,
|
|
computeOptionLineTotal,
|
|
computeRegistrationTotalDue,
|
|
attachComputedTotals,
|
|
} = require('../src/utils/pricing');
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
const NOW = new Date('2026-06-01T00:00:00Z');
|
|
const PAST = new Date(NOW.getTime() - DAY);
|
|
const FUTURE = new Date(NOW.getTime() + DAY);
|
|
|
|
describe('getEffectiveUnitPrice', () => {
|
|
test('returns base price when there are no early-bird tiers', () => {
|
|
const option = { price: 100, earlyBirdTiers: [] };
|
|
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
|
});
|
|
|
|
test('returns tier price when the tier deadline is still in the future', () => {
|
|
const option = {
|
|
price: 100,
|
|
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
|
|
};
|
|
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
|
|
});
|
|
|
|
test('falls back to base price once the tier deadline has passed', () => {
|
|
const option = {
|
|
price: 100,
|
|
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
|
|
};
|
|
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
|
});
|
|
|
|
test('requires the deadline to be after both referenceTime and atTime', () => {
|
|
const midDeadline = new Date(NOW.getTime() - DAY / 2);
|
|
const option = {
|
|
price: 100,
|
|
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
|
|
};
|
|
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
|
|
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
|
|
});
|
|
|
|
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
|
|
const soonerDeadline = new Date(NOW.getTime() + DAY);
|
|
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
|
|
const option = {
|
|
price: 100,
|
|
earlyBirdTiers: [
|
|
{ id: 'later', price: 80, deadline: laterDeadline },
|
|
{ id: 'sooner', price: 60, deadline: soonerDeadline },
|
|
],
|
|
};
|
|
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
|
|
});
|
|
|
|
test('returns 0 for a missing eventOption', () => {
|
|
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('computeOptionLineTotal', () => {
|
|
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
|
|
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
|
|
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
|
|
const ro = {
|
|
quantity: 6,
|
|
priceSnapshot: 100,
|
|
tranches: [
|
|
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
|
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
|
],
|
|
};
|
|
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
|
|
});
|
|
|
|
test('sums a single tranche correctly', () => {
|
|
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
|
|
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
|
|
});
|
|
|
|
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
|
|
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
|
|
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
|
|
});
|
|
|
|
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
|
|
const ro = {
|
|
quantity: 2,
|
|
priceSnapshot: null,
|
|
tranches: [],
|
|
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
|
|
};
|
|
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
|
|
});
|
|
});
|
|
|
|
describe('computeRegistrationTotalDue', () => {
|
|
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
|
|
const registration = {
|
|
registrationOptions: [
|
|
{
|
|
quantity: 6,
|
|
priceSnapshot: 100,
|
|
tranches: [
|
|
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
|
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
|
],
|
|
},
|
|
{
|
|
quantity: 2,
|
|
priceSnapshot: 20,
|
|
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
|
|
},
|
|
],
|
|
payments: [],
|
|
};
|
|
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
|
|
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
|
|
});
|
|
|
|
test('returns 0 for a registration with no options', () => {
|
|
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
|
|
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('attachComputedTotals', () => {
|
|
test('mutates the registration with per-line and total computed amounts', () => {
|
|
const registration = {
|
|
registrationOptions: [
|
|
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
|
|
],
|
|
payments: [],
|
|
};
|
|
const result = attachComputedTotals(registration);
|
|
expect(result).toBe(registration); // mutated in place
|
|
expect(result.registrationOptions[0].lineTotal).toBe(100);
|
|
expect(result.totalDueComputed).toBe(100);
|
|
});
|
|
|
|
test('handles a null registration gracefully', () => {
|
|
expect(attachComputedTotals(null)).toBeNull();
|
|
});
|
|
});
|