Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite
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>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user