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:
2026-08-27 14:50:11 +02:00
co-authored by Claude Sonnet 5
parent 98ac26bf70
commit 54b89d4f4b
43 changed files with 8414 additions and 92 deletions
+46
View File
@@ -0,0 +1,46 @@
const { computeDonationRemaining } = require('../src/utils/donationUtils');
describe('computeDonationRemaining', () => {
test('a donation with no legs has its full amount remaining', () => {
expect(computeDonationRemaining(500, [])).toBe(500);
});
test('an allocation leg reduces the remaining balance', () => {
const legs = [{ amount: 200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
const legs = [{ amount: 200 }, { amount: 150 }];
expect(computeDonationRemaining(500, legs)).toBe(150);
});
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
// 500 donation, never allocated, R200 of it refunded directly back to the donor
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
const legs = [{ amount: -200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
// Remaining = 500 - 200 - 100 = 200.
const legs = [{ amount: 200 }, { amount: -100 }];
expect(computeDonationRemaining(500, legs)).toBe(200);
});
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
// leg — is how an unassigned allocation becomes available again.
const legsAfterUnassign = [];
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
});
test('handles a null/undefined legs array', () => {
expect(computeDonationRemaining(500, null)).toBe(500);
expect(computeDonationRemaining(500, undefined)).toBe(500);
});
});
+146
View File
@@ -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();
});
});