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,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);
|
||||
});
|
||||
});
|
||||
@@ -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