Files
hope-events/backend/scripts/set-demo.js
T
joshuaandClaude Opus 5.5 f33d04a57c Add bundle types: pick any, buy X get Y free, group discount
Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for
a set price), free (cheapest freeCount in each set free) and group (setSize or more
qualifying tickets at a per-ticket price or % off, applied once). The supervisor page
has a type picker, buyers see a one-line description of each deal, and the Complete
bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00

1362 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* set-demo.js
*
* Replaces the database contents with demo data for showing the system to other
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
* completed events from the past year with door sales, check-ins, feedback and cashups.
*
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
* today, so the upcoming events are always in the future whenever this is run.
*
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
* have no phone numbers, so later reminders can't reach a real person.
*
* Usage:
* cd backend
* npm run set-demo (asks for confirmation)
* npm run set-demo -- --yes (no prompt)
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const readline = require('readline');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { createPrismaClient } = require('../src/config/db');
const { applyBundles } = require('../src/utils/bundles');
const prisma = createPrismaClient();
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
const DEMO_PASSWORD = 'Demo@1234';
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
// Not truncated: migrations, site settings, users (all but the admin are deleted
// separately) and the admin's own account-security rows.
const KEEP_TABLES = new Set([
'_prisma_migrations', 'AppSetting', 'User',
'SecurityEvent', 'PasswordReset', 'TwoFactorChallenge', 'TrustedDevice',
]);
const SCHEDULED_EMAILS_PATH = path.resolve(__dirname, '../data/scheduled-emails.json');
// ─── Random helpers (seeded, so every run produces the same-looking demo) ────
let seed = 20261021;
function rand() {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const int = (min, max) => min + Math.floor(rand() * (max - min + 1));
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
const chance = (p) => rand() < p;
const round2 = (n) => Math.round(n * 100) / 100;
/** Picks a key from { key: weight }. */
function weighted(weights) {
const total = Object.values(weights).reduce((s, w) => s + w, 0);
let r = rand() * total;
for (const [key, w] of Object.entries(weights)) {
if ((r -= w) < 0) return key;
}
return Object.keys(weights)[0];
}
function shuffle(arr) {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
// ─── Dates ───────────────────────────────────────────────────────────────────
const NOW = new Date();
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** The first `weekday` (0 = Sunday) at least `minDays` from today, at hour:minute. */
function nextWeekday(minDays, weekday, hour, minute = 0) {
const d = new Date(NOW);
d.setDate(d.getDate() + minDays);
while (d.getDay() !== weekday) d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const addTime = (date, ms) => new Date(date.getTime() + ms);
const daysFromNow = (days) => addTime(NOW, days * DAY);
/** A random moment between minDaysAgo and maxDaysAgo, during waking hours, never in the future. */
function pastDate(maxDaysAgo, minDaysAgo = 0) {
const d = new Date(NOW.getTime() - (minDaysAgo + rand() * (maxDaysAgo - minDaysAgo)) * DAY);
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
return d < NOW ? d : new Date(NOW.getTime() - int(5, 120) * MINUTE);
}
/** A moment a little after `from`, clamped to the past. */
function after(from, minMinutes, maxMinutes) {
const t = from.getTime() + int(minMinutes, maxMinutes) * MINUTE;
return new Date(Math.min(t, NOW.getTime() - MINUTE));
}
// ─── People ──────────────────────────────────────────────────────────────────
const FEMALE_FIRST = [
'Thandi', 'Nomsa', 'Lerato', 'Zanele', 'Palesa', 'Ayanda', 'Naledi', 'Busisiwe', 'Precious',
'Refilwe', 'Anri', 'Elmarie', 'Liezl', 'Marike', 'Annelie', 'Charlene', 'Michelle', 'Nicole',
'Sarah', 'Rachel', 'Hannah', 'Grace', 'Megan', 'Jessica', 'Lauren', 'Kelly', 'Tamryn', 'Candice',
'Fatima', 'Priya', 'Kavitha', 'Nadia', 'Bianca', 'Chantelle', 'Tanya', 'Mpho', 'Keabetswe',
'Lindiwe', 'Sibongile', 'Yolanda', 'Ruth', 'Esther', 'Joanne', 'Karen', 'Debbie', 'Nompumelelo',
'Carmen', 'Simone', 'Natasha', 'Ilse', 'Rochelle', 'Tumi', 'Dineo', 'Hlengiwe', 'Abigail',
];
const MALE_FIRST = [
'Sipho', 'Thabo', 'Bongani', 'Mandla', 'Kagiso', 'Tshepo', 'Lwazi', 'Sizwe', 'Themba', 'Musa',
'Pieter', 'Johan', 'Francois', 'Hendrik', 'Marius', 'Wikus', 'Riaan', 'Gerhard', 'David',
'Michael', 'Daniel', 'Matthew', 'Andrew', 'Jonathan', 'Ryan', 'Craig', 'Brandon', 'Shaun',
'Grant', 'Kyle', 'Ashwin', 'Rajesh', 'Yusuf', 'Tumelo', 'Neo', 'Karabo', 'Peter', 'Mark',
'Paul', 'Simon', 'Brian', 'Trevor', 'Wayne', 'Chris', 'Luyanda', 'Jaco', 'Stefan', 'Ernest',
'Vusi', 'Kabelo', 'Dumisani', 'Graham', 'Clive', 'Nathan', 'Ruben', 'Emmanuel', 'Isaac',
];
const LAST = [
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo', 'Khumalo', 'Molefe',
'Mahlangu', 'Sithole', 'Mabaso', 'Radebe', 'Baloyi', 'Maseko', 'Ntuli', 'du Plessis',
'van der Merwe', 'Botha', 'Steyn', 'Fourie', 'Pretorius', 'Venter', 'Coetzee', 'van Niekerk',
'Lombard', 'Nel', 'Kruger', 'Smit', 'Jacobs', 'Smith', 'Williams', 'Brown', 'Johnson', 'Adams',
'Petersen', 'Daniels', 'Hendricks', 'Abrahams', 'Isaacs', 'Naidoo', 'Pillay', 'Govender',
'Reddy', 'Moodley', 'Patel', 'Campbell', 'Taylor', 'Thomas', 'Wilson', 'Clarke', 'Morgan',
];
const GIRL_FIRST = [
'Amahle', 'Lesedi', 'Isabella', 'Mia', 'Zoe', 'Ava', 'Leah', 'Kayla', 'Nandi', 'Olwethu', 'Jade',
'Chloe', 'Emma', 'Lisa', 'Anika', 'Karabo', 'Lilitha', 'Bella', 'Gabriella', 'Imaan',
];
const BOY_FIRST = [
'Liam', 'Noah', 'Ethan', 'Luke', 'Josh', 'Siyabonga', 'Lethabo', 'Caleb', 'Aiden', 'Jayden',
'Ruan', 'Tiaan', 'Bandile', 'Ofentse', 'Zaid', 'Reece', 'Keegan', 'Asanda', 'Jordan', 'Kian',
];
const emailSlug = (s) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
// ─── Row collection ──────────────────────────────────────────────────────────
// Everything is built in memory with pre-generated ids, then written with createMany
// in dependency order inside one transaction.
const rows = {};
const add = (model, row) => {
(rows[model] = rows[model] || []).push(row);
return row;
};
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
'ticketUsage', 'couponRedemption', 'registrationBundle',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
];
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
const userMeta = new Map();
let admin;
let passwordHash;
const staffTeam = [];
function addUser({ first, last, gender, role = 'user', email }) {
const usedEmails = new Set((rows.user || []).map(u => u.email).concat(ADMIN_EMAIL));
let address = email || `${emailSlug(first)}.${emailSlug(last)}@example.com`;
for (let n = 2; usedEmails.has(address); n++) address = `${emailSlug(first)}.${emailSlug(last)}${n}@example.com`;
const user = add('user', {
id: uuidv4(),
name: `${first} ${last}`,
email: address,
password: passwordHash,
role,
isActive: true,
notificationPreference: 'email',
createdAt: pastDate(420, 40),
});
userMeta.set(user.id, { first, last, gender });
return user;
}
function buildPeople() {
staffTeam.push(
addUser({ first: 'Lerato', last: 'Khumalo', gender: 'female', role: 'supervisor', email: 'lerato.khumalo@example.com' }),
addUser({ first: 'Sipho', last: 'Dlamini', gender: 'male', role: 'staff', email: 'sipho.dlamini@example.com' }),
addUser({ first: 'Anri', last: 'Botha', gender: 'female', role: 'staff', email: 'anri.botha@example.com' }),
addUser({ first: 'Daniel', last: 'Pillay', gender: 'male', role: 'staff', email: 'daniel.pillay@example.com' }),
);
const women = [];
const men = [];
for (let i = 0; i < 62; i++) women.push(addUser({ first: pick(FEMALE_FIRST), last: pick(LAST), gender: 'female' }));
for (let i = 0; i < 64; i++) men.push(addUser({ first: pick(MALE_FIRST), last: pick(LAST), gender: 'male' }));
return { women, men, everyone: women.concat(men) };
}
// ─── Event building blocks ───────────────────────────────────────────────────
function addEvent(data) {
return add('event', {
id: uuidv4(),
createdById: admin.id,
goLiveAt: addTime(NOW, -35 * DAY),
createdAt: addTime(NOW, -38 * DAY),
isActive: true,
contactName: 'Lerato Khumalo',
contactEmail: 'events@example.com',
contactPhone: '021 555 0142',
...data,
});
}
function addOption(event, data) {
return add('eventOption', { id: uuidv4(), eventId: event.id, isMainTicket: true, stockLimit: 0, ...data });
}
function addVariants(option, variants) {
return variants.map((v, order) => add('optionVariant', { id: uuidv4(), eventOptionId: option.id, order, stockLimit: 0, price: null, ...v }));
}
function addTier(option, data) {
return add('earlyBirdTier', { id: uuidv4(), eventOptionId: option.id, variantId: null, order: 0, stockLimit: 0, ...data });
}
function addSection(event, name, options) {
const section = add('section', { id: uuidv4(), eventId: event.id, name });
for (const o of options) add('sectionOption', { id: uuidv4(), sectionId: section.id, eventOptionId: o.id });
return section;
}
/** A bundle deal: items are [option, quantity] pairs (quantity 1 for pick-any and the other kinds). */
function addBundle(event, data, items) {
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, kind: 'exact', setSize: null, freeCount: null, groupPricing: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
for (const [option, quantity] of items) add('eventBundleItem', { id: uuidv4(), bundleId: bundle.id, eventOptionId: option.id, variantId: null, quantity });
return bundle;
}
/** The event's bundles with their items, in the shape utils/bundles.js expects. */
function bundlesFor(event) {
return (rows.eventBundle || []).filter(b => b.eventId === event.id)
.map(b => ({ ...b, items: (rows.eventBundleItem || []).filter(i => i.bundleId === b.id) }));
}
function addCost(event, label, costType, amount, option = null, notes = null) {
add('eventCost', { id: uuidv4(), eventId: event.id, eventOptionId: option ? option.id : null, label, costType, amount, paidFromMethod: null, notes });
}
// Early-bird tiers already sold, for stock limits.
const tierSold = new Map();
/** The price a line bought at `when` gets: cheapest open tier with stock left, else the base price. */
function priceAt(option, variant, when, quantity) {
const tiers = (rows.earlyBirdTier || [])
.filter(t => t.eventOptionId === option.id && t.variantId === (variant ? variant.id : null))
.sort((a, b) => a.price - b.price);
for (const tier of tiers) {
if (when >= tier.deadline) continue;
const sold = tierSold.get(tier.id) || 0;
if (tier.stockLimit > 0 && sold + quantity > tier.stockLimit) continue;
tierSold.set(tier.id, sold + quantity);
return { price: tier.price, tier };
}
const base = variant && variant.price !== null ? variant.price : option.price;
return { price: base, tier: null };
}
const line = (option, quantity, extra = {}) => ({ option, quantity, ...extra });
// ─── Registrations ───────────────────────────────────────────────────────────
/**
* One registration with its lines, payments and (once paid) tickets.
* pay: 'full' | 'split' (deposit then balance) | 'deposit' | 'none' | 'cancelled' | 'refunded'
* atDoor: sold at the event by a staff member (card on the card machine, not online).
* payBy: the latest a payment may be dated (e.g. the event's start, for past events).
*/
function addRegistration(event, user, lines, { createdAt, pay = 'full', method = 'card', deposit = 0, coupon = null, atDoor = false, payBy = null }) {
const registration = add('registration', {
id: uuidv4(),
userId: user.id,
eventId: event.id,
status: 'pending',
discountTotal: 0,
bundleDiscount: 0,
ticketDelivery: 'payer',
analyticsSessionId: null,
createdAt,
});
let subtotal = 0;
const bundleLines = [];
const regLines = lines.filter(l => l.quantity > 0).map(l => {
const { price, tier } = priceAt(l.option, l.variant || null, createdAt, l.quantity);
const ro = add('registrationOption', {
id: uuidv4(),
registrationId: registration.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
});
add('registrationOptionTranche', {
id: uuidv4(),
registrationOptionId: ro.id,
quantity: l.quantity,
priceSnapshot: price,
appliedTierId: tier ? tier.id : null,
createdAt,
});
subtotal += l.quantity * price;
bundleLines.push({ eventOptionId: l.option.id, variantId: l.variant ? l.variant.id : null, quantity: l.quantity, unitPrice: price, basePrice: price });
const attendees = (l.attendees || []).map((a, order) => add('attendee', {
id: uuidv4(),
registrationId: registration.id,
registrationOptionId: ro.id,
eventOptionId: l.option.id,
variantId: l.variant ? l.variant.id : null,
order,
createdAt,
...a,
}));
return { ro, option: l.option, attendees };
});
// Bundle deals first, the same way the app applies them; then the coupon on what's left.
const deals = applyBundles(bundlesFor(event), bundleLines);
if (deals.discount > 0) {
registration.bundleDiscount = deals.discount;
registration.discountTotal = deals.discount;
for (const a of deals.applications) {
add('registrationBundle', { id: uuidv4(), registrationId: registration.id, bundleId: a.bundleId, count: a.count, discount: a.discount, createdAt });
}
}
if (coupon && pay !== 'cancelled') {
const base = subtotal - registration.bundleDiscount;
const discount = coupon.type === 'percent' ? round2(base * coupon.value / 100) : Math.min(base, coupon.value);
registration.discountTotal = round2(registration.discountTotal + discount);
add('couponRedemption', { id: uuidv4(), couponId: coupon.id, registrationId: registration.id, userId: user.id, discountAmount: discount, createdAt });
}
const total = round2(subtotal - registration.discountTotal);
const payments = [];
const online = method === 'card' && !atDoor;
const recorder = pick(staffTeam);
const pay1 = (amount, at, how = method) => {
payments.push(add('payment', {
id: uuidv4(),
amount: round2(amount),
method: how,
userId: user.id,
recordedById: online ? null : recorder.id,
registrationId: registration.id,
eventId: event.id,
status: online ? 'completed' : null,
externalId: online ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: payBy && at > payBy ? payBy : at,
}));
};
const firstPaidAt = atDoor
? after(createdAt, 1, 3)
: after(createdAt, method === 'card' ? 2 : 600, method === 'card' ? 15 : 3 * 24 * 60);
if (total > 0) {
if (pay === 'full' || pay === 'refunded') pay1(total, firstPaidAt);
if (pay === 'deposit' || pay === 'split') pay1(Math.min(deposit, total), firstPaidAt);
if (pay === 'split') pay1(total - Math.min(deposit, total), after(firstPaidAt, 3 * 24 * 60, 20 * 24 * 60));
}
if (pay === 'refunded' && payments.length) {
// Recorded the way the payments screen does it: a negative "<method>-refund" payment
// linked to the original, with the reason in status.
const original = payments[0];
add('payment', {
id: uuidv4(), amount: -original.amount, method: `${method}-refund`, userId: user.id, recordedById: pick(staffTeam).id,
registrationId: registration.id, eventId: event.id, originalPaymentId: original.id,
status: pick(['Can no longer attend', 'Family emergency', 'Booked twice by mistake']),
createdAt: after(original.createdAt, 24 * 60, 7 * 24 * 60),
});
}
const paid = round2(payments.reduce((s, p) => s + p.amount, 0));
if (pay === 'cancelled' || pay === 'refunded') registration.status = 'cancelled';
else if (paid >= total) registration.status = 'paid';
else if (paid > 0) registration.status = 'partial_paid';
if (registration.status === 'paid') {
const issuedAt = payments.length ? payments[payments.length - 1].createdAt : after(createdAt, 0, 1);
for (const { ro, attendees } of regLines) {
for (const a of attendees) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: 1, attendeeId: a.id, holderUserId: a.userId || null, emailSent: true, createdAt: issuedAt });
}
const unnamed = ro.quantity - attendees.length;
if (unnamed > 0) {
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: event.id, quantity: unnamed, emailSent: true, createdAt: issuedAt });
}
}
}
return { registration, regLines };
}
/** Picks a payment plan from weights like { card: 0.7, eft: 0.1, none: 0.2 }. */
function payPlan(weights) {
const choice = weighted(weights);
return choice === 'none' ? { pay: 'none', method: 'card' } : { pay: 'full', method: choice };
}
function addForm(event, fields) {
const form = add('eventForm', { id: uuidv4(), eventId: event.id, isRequired: true });
return fields.map((f, order) => add('eventFormField', { id: uuidv4(), formId: form.id, order, isRequired: false, helpText: null, options: null, ...f }));
}
function addFormResponse(registration, answers) {
const response = add('formResponse', { id: uuidv4(), registrationId: registration.id, createdAt: registration.createdAt });
for (const [field, value] of answers) {
if (value !== null && value !== '') add('formAnswer', { id: uuidv4(), responseId: response.id, fieldId: field.id, value: String(value) });
}
}
// ─── The six events ──────────────────────────────────────────────────────────
const IMG = (id) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1600&q=80`;
function buildFamilyFunDay(people) {
const start = nextWeekday(15, 6, 10);
const event = addEvent({
title: 'Family Fun Day',
description: 'A full day of fun for the whole family on the church grounds!\n\n'
+ 'Jumping castles, a petting zoo, face painting, a five-a-side soccer tournament and a '
+ 'treasure hunt for the kids — plus coffee, boerewors rolls and a quiet shady spot for the grown-ups.\n\n'
+ 'Bring a picnic blanket and your camp chairs. Children under 3 get in free.',
startDate: start,
endDate: addTime(start, 5 * HOUR),
registrationDeadline: addTime(start, -DAY),
price: 80,
picture: IMG('photo-1529156069898-49953e39b3ac'),
location: 'Church grounds & sports field',
});
const adult = addOption(event, { name: 'Adult', price: 80 });
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
addBundle(event, { name: 'Family deal', price: 220, description: 'Two adults and three kids, R60 less than buying them separately.' }, [[adult, 2], [child, 3]]);
addBundle(event, { name: 'Lunch for four', kind: 'mix', setSize: 4, price: 220, description: 'Any four lunch packs for R220.' }, [[lunch, 1]]);
add('coupon', {
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
stackEarlyBird: false, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 50, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: rows.coupon[rows.coupon.length - 1].id, eventId: event.id });
addSection(event, 'Main gate', [adult, child]);
addSection(event, 'Food stall', [lunch]);
addCost(event, 'Jumping castles & games hire', 'once_off', 4500);
addCost(event, 'Face painter', 'once_off', 1200);
addCost(event, 'Lunch packs', 'per_item', 38, lunch, 'Supplied by the catering team');
for (const user of shuffle(people.everyone).slice(0, 30)) {
// About a third come as a family of 2 + 3 and get the bundle; a few bring two families.
const families = chance(0.35) ? (chance(0.15) ? 2 : 1) : 0;
const lines = families
? [line(adult, 2 * families), line(child, 3 * families)]
: [line(adult, int(1, 2)), line(child, Number(weighted({ 0: 2, 1: 3, 2: 3, 3: 1 })))];
lines.push(line(lunch, chance(0.6) ? int(1, 4) : 0));
addRegistration(event, user, lines, { createdAt: pastDate(30), ...payPlan({ card: 0.72, eft: 0.12, none: 0.16 }) });
}
return event;
}
function buildMensBreakfast(people) {
const start = nextWeekday(8, 6, 7, 30);
const event = addEvent({
title: "Men's Breakfast: Built to Last",
description: 'Join the men of the church for a full English breakfast and an honest conversation '
+ 'about faith, work and family.\n\n'
+ 'Our guest speaker is Pastor Ernest Molefe, who has spent twenty years mentoring young fathers. '
+ 'Bring a friend, a neighbour or a colleague — there is a seat for everyone.',
startDate: start,
endDate: addTime(start, 2.5 * HOUR),
registrationDeadline: addTime(start, -2 * DAY),
price: 150,
picture: IMG('photo-1414235077428-338989a2e8c0'),
location: 'Church hall',
waitlistEnabled: true,
contactName: 'Sipho Dlamini',
});
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
addSection(event, 'Hall entrance', [ticket]);
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
addCost(event, 'Guest speaker gift', 'once_off', 750);
const men = shuffle(people.men);
let sold = 0;
while (sold < 60) {
const user = men.shift();
const quantity = Math.min(chance(0.2) ? 2 : 1, 60 - sold);
sold += quantity;
addRegistration(event, user, [line(ticket, quantity)], { createdAt: pastDate(28), ...payPlan({ card: 0.65, cash: 0.15, eft: 0.08, none: 0.12 }) });
}
for (let i = 0; i < 2; i++) {
addRegistration(event, men.shift(), [line(ticket, 1)], { createdAt: pastDate(25, 5), pay: 'cancelled' });
}
for (let position = 1; position <= 5; position++) {
add('waitlistEntry', {
id: uuidv4(), eventId: event.id, eventOptionId: ticket.id, userId: men.shift().id,
quantity: position === 3 ? 2 : 1, position, status: 'waiting', createdAt: pastDate(4),
});
}
return event;
}
function buildWomensConference(people) {
const start = nextWeekday(34, 5, 18);
const event = addEvent({
title: 'Rooted Women’s Conference',
description: 'Two days of worship, teaching and real connection for women of every age and season.\n\n'
+ 'Friday evening opens with a worship night. Saturday brings two main sessions, your choice of '
+ 'workshop and a long lunch together in the garden.\n\n'
+ 'Early-bird prices are available for a limited time — gather your small group and register together.',
startDate: start,
endDate: addTime(start, 22 * HOUR),
registrationDeadline: addTime(start, -3 * DAY),
price: 450,
picture: IMG('photo-1511632765486-a01980e01a18'),
location: 'Main auditorium',
contactName: 'Anri Botha',
});
const full = addOption(event, { name: 'Full conference (Fri + Sat)', price: 450 });
const saturday = addOption(event, { name: 'Saturday only', price: 280 });
const shirt = addOption(event, { name: 'Conference T-shirt', price: 180, isMainTicket: false });
const sizes = addVariants(shirt, [{ name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }, { name: 'XXL' }]);
addTier(full, { deadline: addTime(NOW, -8 * DAY), price: 300, stockLimit: 25, order: 0 });
addTier(full, { deadline: addTime(NOW, 10 * DAY), price: 380, stockLimit: 0, order: 1 });
const coupon = add('coupon', {
id: uuidv4(), code: 'ROOTED20', description: '20% off for small groups registering together', type: 'percent', value: 20,
stackEarlyBird: true, validFrom: addTime(NOW, -30 * DAY), validUntil: start, maxUses: 30, maxUsesPerUser: 1,
restrictRoles: [], active: true, createdById: admin.id, createdAt: addTime(NOW, -30 * DAY),
});
add('couponEvent', { id: uuidv4(), couponId: coupon.id, eventId: event.id });
addSection(event, 'Main hall', [full, saturday]);
addSection(event, 'Merch table', [shirt]);
addCost(event, 'Venue, sound & lighting', 'once_off', 12500);
addCost(event, 'Catering (full conference)', 'per_item', 120, full);
addCost(event, 'Catering (Saturday)', 'per_item', 70, saturday);
addCost(event, 'T-shirt printing', 'per_item', 95, shirt);
const [diet, workshop, firstTime, prayer] = addForm(event, [
{ type: 'multiple_choice', label: 'Dietary requirements', isRequired: true, options: ['None', 'Vegetarian', 'Vegan', 'Halaal', 'Gluten-free'] },
{ type: 'multiple_choice', label: 'Which Saturday workshop would you like to attend?', isRequired: true, options: ['Prayer & intercession', 'Marriage & family', 'Women in leadership', 'Finding rest'] },
{ type: 'yes_no', label: 'Is this your first Rooted conference?', isRequired: true },
{ type: 'text', label: 'Is there anything we can pray with you about?', helpText: 'Only our prayer team will see this.' },
]);
const PRAYERS = ['My mom’s health', 'A new job', 'Wisdom for my family', 'My teenage kids', 'Healing', 'Peace in a tough season'];
for (const user of shuffle(people.women).slice(0, 50)) {
const quantity = Number(weighted({ 1: 70, 2: 20, 3: 6, 4: 4 }));
const main = chance(0.75) ? full : saturday;
const lines = [line(main, quantity)];
if (chance(0.4)) lines.push(line(shirt, 1, { variant: pick(sizes) }));
const plan = payPlan({ card: 0.8, eft: 0.08, none: 0.12 });
const { registration } = addRegistration(event, user, lines, {
createdAt: pastDate(30),
...plan,
coupon: quantity >= 2 && plan.pay === 'full' && chance(0.5) ? coupon : null,
});
for (let i = 0; i < quantity; i++) {
addFormResponse(registration, [
[diet, weighted({ None: 70, Vegetarian: 12, Vegan: 4, Halaal: 8, 'Gluten-free': 6 })],
[workshop, pick(workshop.options)],
[firstTime, chance(0.35) ? 'yes' : 'no'],
[prayer, chance(0.25) ? pick(PRAYERS) : null],
]);
}
}
return event;
}
function buildYouthCamp(people) {
const start = nextWeekday(46, 1, 9);
const event = addEvent({
title: 'Ignite Summer Camp',
description: 'Four days in the mountains for Grade 7–12s: hiking, river tubing, campfires, '
+ 'night games and powerful evening services.\n\n'
+ 'The bus leaves the church at 09:00 on Monday and returns on Thursday afternoon. All meals, '
+ 'activities and accommodation are included. A R500 deposit per camper secures a place, with '
+ 'the balance due two weeks before camp.\n\n'
+ 'Want to help a teenager get to camp? Use “Sponsor a spot” to give towards our bursary fund.',
startDate: start,
endDate: addTime(start, 3 * DAY + 5 * HOUR),
registrationDeadline: addTime(start, -14 * DAY),
price: 1650,
picture: IMG('photo-1478131143081-80f7f84ca84d'),
location: 'Bergview Camp, Drakensberg',
allowNamedAttendees: true,
requiresParentalConsent: true,
consentText: 'I am the parent or legal guardian of this camper and give permission for them to attend '
+ 'Ignite Summer Camp, take part in all supervised activities (including hiking and water activities) '
+ 'and travel on the camp bus. In an emergency I authorise the camp leaders to obtain medical treatment.',
consentCollectPhoto: true,
collectGender: true,
allowSponsorships: true,
sponsorshipGoalSpots: 10,
contactName: 'Daniel Pillay',
});
const camper = addOption(event, { name: 'Camper (Grade 7–12)', price: 1650, stockLimit: 80 });
const hoodie = addOption(event, { name: 'Camp hoodie', price: 380, isMainTicket: false });
const sizes = addVariants(hoodie, [{ name: 'Youth L' }, { name: 'S' }, { name: 'M' }, { name: 'L' }, { name: 'XL' }]);
addSection(event, 'Bus check-in', [camper]);
addCost(event, 'Campsite (per camper)', 'per_item', 820, camper);
addCost(event, 'Bus hire', 'once_off', 16800);
addCost(event, 'Camp hoodies', 'per_item', 210, hoodie);
const campers = [];
for (const parent of shuffle(people.everyone).slice(0, 30)) {
const surname = userMeta.get(parent.id).last;
const count = Number(weighted({ 1: 55, 2: 35, 3: 10 }));
const attendees = [];
for (let i = 0; i < count; i++) {
const gender = chance(0.5) ? 'female' : 'male';
// 12–17 on the first day of camp.
const dateOfBirth = new Date(start.getTime() - (int(12, 17) * 365.25 + int(10, 350)) * DAY);
dateOfBirth.setUTCHours(0, 0, 0, 0);
attendees.push({
name: `${pick(gender === 'female' ? GIRL_FIRST : BOY_FIRST)} ${surname}`,
gender,
dateOfBirth,
parentName: parent.name,
parentEmail: parent.email,
});
}
const lines = [line(camper, count, { attendees })];
if (chance(0.4)) lines.push(line(hoodie, int(1, count), { variant: pick(sizes) }));
const plan = weighted({ full: 30, split: 20, deposit: 35, none: 15 });
const { registration, regLines } = addRegistration(event, parent, lines, {
createdAt: pastDate(32),
pay: plan,
method: chance(0.5) ? 'card' : 'eft',
deposit: 500 * count,
});
if (registration.status === 'pending') continue;
for (const attendee of regLines[0].attendees) {
campers.push(attendee);
add('consentRequest', {
id: uuidv4(),
attendeeId: attendee.id,
eventId: event.id,
tokenHash: crypto.createHash('sha256').update(uuidv4()).digest('hex'),
status: 'given',
textVersion: 1,
consentText: event.consentText,
parentName: parent.name,
parentEmail: parent.email,
signedName: parent.name,
photoConsent: chance(0.9),
respondedAt: after(registration.createdAt, 30, 2 * 24 * 60),
lastSentAt: registration.createdAt,
expiresAt: event.startDate,
createdAt: registration.createdAt,
});
}
}
// Cabins, with most campers already placed.
const cabins = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Cabins', showOnTicket: true, order: 0 });
const groups = [
['Eagle cabin', 'male', staffTeam[1]], ['Lion cabin', 'male', staffTeam[3]],
['Dove cabin', 'female', staffTeam[0]], ['Fawn cabin', 'female', staffTeam[2]],
].map(([name, gender, leader], order) => add('allocationGroup', { id: uuidv4(), setId: cabins.id, name, capacity: 14, gender, leaderUserId: leader.id, order }));
const placed = new Map(groups.map(g => [g.id, 0]));
for (const attendee of campers) {
if (!chance(0.8)) continue;
const group = groups.find(g => g.gender === attendee.gender && placed.get(g.id) < g.capacity);
if (!group) continue;
placed.set(group.id, placed.get(group.id) + 1);
add('allocationAssignment', { id: uuidv4(), setId: cabins.id, groupId: group.id, attendeeId: attendee.id });
}
// Sponsor-a-spot donations into the bursary pool.
const MESSAGES = ['Every child deserves camp!', 'Camp changed my life — pass it on.', 'Praying for a great week.', null, null];
for (const sponsor of shuffle(people.everyone).slice(0, 9)) {
const anonymous = chance(0.3);
add('payment', {
id: uuidv4(), amount: pick([500, 825, 1000, 1650, 1650, 3300]), method: 'card', userId: sponsor.id,
eventId: event.id, isDonation: true, isSponsorship: true, sponsorName: anonymous ? null : sponsor.name,
sponsorMessage: pick(MESSAGES), sponsorAnonymous: anonymous, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildCarols(people) {
const start = nextWeekday(55, 0, 19);
const event = addEvent({
title: 'Carols by Candlelight',
description: 'Our much-loved Christmas evening under the stars.\n\n'
+ 'Bring a blanket and the whole family for carols, the Christmas story told by our children’s '
+ 'ministry, and hot chocolate afterwards. Entry is free, but please register so we know how '
+ 'many candles and chairs to set out.\n\n'
+ 'A collection will be taken for the local children’s home.',
startDate: start,
endDate: addTime(start, 2 * HOUR),
price: 0,
picture: IMG('photo-1543589077-47d81606c1bf'),
location: 'Church lawn (hall if it rains)',
});
const entry = addOption(event, { name: 'Free entry', price: 0, stockLimit: 500 });
addSection(event, 'Lawn entrance', [entry]);
addCost(event, 'Candles & songbooks', 'once_off', 1800);
addCost(event, 'Sound & lighting', 'once_off', 3500);
for (const user of shuffle(people.everyone).slice(0, 48)) {
const quantity = Number(weighted({ 1: 15, 2: 30, 3: 15, 4: 25, 5: 10, 6: 5 }));
addRegistration(event, user, [line(entry, quantity)], { createdAt: pastDate(30) });
}
for (const donor of shuffle(people.everyone).slice(0, 14)) {
add('payment', {
id: uuidv4(), amount: pick([100, 150, 200, 250, 500, 500, 1000]), method: 'card', userId: donor.id,
eventId: event.id, isDonation: true, status: 'completed',
externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: pastDate(30),
});
}
return event;
}
function buildMarriageWeekend(people) {
const start = nextWeekday(98, 5, 17);
const event = addEvent({
title: 'Marriage Enrichment Weekend',
description: 'Take a weekend away just for the two of you.\n\n'
+ 'Three relaxed sessions on communication, conflict and dreaming together, led by Pastor Johan '
+ '& Elmarie Venter — with plenty of free time to walk, rest and enjoy the lodge.\n\n'
+ 'Your package covers two nights’ accommodation, all meals and the course material. A R1 000 '
+ 'deposit secures your room; rooms are limited.',
startDate: start,
endDate: addTime(start, 43 * HOUR),
registrationDeadline: addTime(start, -21 * DAY),
price: 3400,
picture: IMG('photo-1606800052052-a08af7148866'),
location: 'Kloofzicht Lodge, Muldersdrift',
contactName: 'Lerato Khumalo',
});
const couple = addOption(event, { name: 'Couple package (2 nights)', price: 3400 });
const [garden, mountain] = addVariants(couple, [
{ name: 'Garden room', price: 3400, stockLimit: 8 },
{ name: 'Mountain-view room', price: 3900, stockLimit: 6 },
]);
addTier(couple, { variantId: garden.id, deadline: addTime(NOW, 45 * DAY), price: 2990 });
addTier(couple, { variantId: mountain.id, deadline: addTime(NOW, 45 * DAY), price: 3450 });
const spa = addOption(event, { name: 'Saturday spa treatment (per person)', price: 650, isMainTicket: false });
addCost(event, 'Lodge accommodation & meals', 'per_item', 1950, couple);
addCost(event, 'Spa treatments', 'per_item', 420, spa);
addCost(event, 'Course workbooks', 'once_off', 1100);
const variants = shuffle([...Array(6).fill(garden), ...Array(5).fill(mountain)]);
for (const user of shuffle(people.everyone).slice(0, variants.length)) {
const lines = [line(couple, 1, { variant: variants.pop() })];
if (chance(0.4)) lines.push(line(spa, 2));
const plan = weighted({ eft: 35, card: 15, deposit: 35, none: 15 });
addRegistration(event, user, lines, {
createdAt: pastDate(30),
pay: plan === 'eft' || plan === 'card' ? 'full' : plan,
method: plan === 'card' ? 'card' : 'eft',
deposit: 1000,
});
}
return event;
}
// ─── Past events ─────────────────────────────────────────────────────────────
// Ten completed events over the past year, with door sales, check-ins, feedback and every
// kind of cashup, so the cashup screens and the historical reports have something to show.
// The cashups themselves run after the insert, through the real cashup code (see
// closePastEvents), so their figures always agree with what the app works out.
const pastEvents = [];
/** The `weekday` (0 = Sunday) at hour:minute, on or before `daysAgo` days ago. */
function lastWeekday(daysAgo, weekday, hour, minute = 0) {
const d = new Date(NOW);
d.setDate(d.getDate() - daysAgo);
while (d.getDay() !== weekday) d.setDate(d.getDate() - 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const within = (from, to) => new Date(from.getTime() + rand() * (to.getTime() - from.getTime()));
/** A random moment between two dates during waking hours, kept inside them. */
function daytimeBetween(from, to) {
const d = within(from, to);
d.setHours(int(7, 21), int(0, 59), int(0, 59), 0);
return d < from ? from : d > to ? to : d;
}
const IMPROVEMENTS = [
'More parking marshals', 'Start on time', 'Bigger venue next year', 'More vegetarian options',
'Clearer signage', 'Shorter announcements', 'More time to chat', 'Better sound at the back',
];
/**
* One completed event. spec: title, description, start, hours, picture, location, options,
* costs(opts), pool ('everyone' | 'women' | 'men'), online, refunds, door, lines(opts, atDoor),
* onlinePay (weights, or a function returning registration options), doorPay (weights),
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
*/
function buildPastEvent(people, spec) {
const { start } = spec;
const event = addEvent({
title: spec.title,
description: spec.description,
startDate: start,
endDate: addTime(start, spec.hours * HOUR),
registrationDeadline: addTime(start, -HOUR),
price: spec.options[0].price,
picture: IMG(spec.picture),
location: spec.location,
goLiveAt: addTime(start, -40 * DAY),
createdAt: addTime(start, -45 * DAY),
...(spec.contactName ? { contactName: spec.contactName } : {}),
});
const opts = spec.options.map(o => addOption(event, o));
for (const [label, type, amount, option] of spec.costs(opts)) addCost(event, label, type, amount, option || null);
const buyers = shuffle(people[spec.pool || 'everyone']);
const nextBuyer = () => {
if (buyers.length === 0) throw new Error(`Not enough demo people for ${spec.title}`);
return buyers.shift();
};
const salesFrom = addTime(start, -35 * DAY);
const payBy = addTime(start, -HOUR);
for (let i = 0; i < spec.online; i++) {
const plan = typeof spec.onlinePay === 'function' ? spec.onlinePay() : payPlan(spec.onlinePay);
addRegistration(event, nextBuyer(), spec.lines(opts, false), { createdAt: daytimeBetween(salesFrom, addTime(start, -DAY)), payBy, ...plan });
}
for (let i = 0; i < (spec.refunds || 0); i++) {
addRegistration(event, nextBuyer(), spec.lines(opts, false), { createdAt: daytimeBetween(salesFrom, addTime(start, -10 * DAY)), pay: 'refunded', method: 'card' });
}
for (let i = 0; i < (spec.door || 0); i++) {
const method = weighted(spec.doorPay || { cash: 70, card: 30 });
addRegistration(event, nextBuyer(), spec.lines(opts, true), { createdAt: within(addTime(start, -30 * MINUTE), addTime(start, HOUR)), method, atDoor: true });
}
const { card = 0, cash = 0, amounts = [] } = spec.donations || {};
for (let i = 0; i < card; i++) {
add('payment', {
id: uuidv4(), amount: pick(amounts), method: 'card', userId: pick(people.everyone).id, eventId: event.id, isDonation: true,
status: 'completed', externalId: `demo_${uuidv4().replace(/-/g, '')}`, createdAt: daytimeBetween(salesFrom, start),
});
}
for (let i = 0; i < cash; i++) {
add('payment', {
id: uuidv4(), amount: pick(amounts), method: 'cash', userId: pick(people.everyone).id, recordedById: pick(staffTeam).id,
eventId: event.id, isDonation: true, createdAt: within(start, event.endDate),
});
}
addCheckIns(event, spec.showRate ?? 0.9);
if (spec.feedback) addFeedback(event, spec.feedback);
pastEvents.push({ event, cashup: spec.cashup });
return event;
}
function addCheckIns(event, showRate) {
const start = event.startDate;
for (const ticket of rows.ticket.filter(t => t.eventId === event.id)) {
if (!chance(showRate)) continue;
// Now and then only part of a group ticket arrives.
const redeemed = ticket.quantity > 1 && chance(0.15) ? int(1, ticket.quantity - 1) : ticket.quantity;
const scannedAt = within(addTime(start, -30 * MINUTE), addTime(start, 45 * MINUTE));
add('ticketUsage', {
id: uuidv4(), ticketId: ticket.id, scannedById: pick(staffTeam).id, quantityRedeemed: redeemed,
scannedAt: scannedAt < ticket.createdAt ? addTime(ticket.createdAt, 2 * MINUTE) : scannedAt,
});
ticket.isUsed = redeemed === ticket.quantity;
}
}
/** A post-event survey sent to everyone who checked in, with about half responding. */
function addFeedback(event, enjoyed) {
const sentAt = addTime(event.endDate, DAY);
const remindedAt = addTime(sentAt, 3 * DAY);
const form = add('feedbackForm', {
id: uuidv4(), eventId: event.id, sendAt: sentAt, audience: 'checked_in', anonymous: false,
intro: `Thanks for coming to ${event.title}! Two minutes of feedback helps us plan the next one.`,
sentAt, reminderSentAt: remindedAt, createdAt: addTime(event.startDate, -7 * DAY),
});
const [overall, best, again, better] = [
{ type: 'rating', label: 'Overall, how was the event?', isRequired: true },
{ type: 'multiple_choice', label: 'What did you enjoy most?', isRequired: true, options: enjoyed },
{ type: 'yes_no', label: 'Would you come again?', isRequired: true },
{ type: 'text', label: 'What could we do better next time?' },
].map((f, order) => add('feedbackField', { id: uuidv4(), formId: form.id, order, isRequired: false, helpText: null, options: null, ...f }));
const scanned = new Set((rows.ticketUsage || []).map(u => u.ticketId));
const attendees = [...new Set(rows.ticket.filter(t => t.eventId === event.id && scanned.has(t.id)).map(t => t.userId))];
for (const userId of attendees) {
const respondedAt = chance(0.45) ? within(sentAt, addTime(sentAt, 6 * DAY)) : null;
add('feedbackInvite', {
id: uuidv4(), formId: form.id, userId, sentAt,
tokenHash: crypto.createHash('sha256').update(uuidv4()).digest('hex'),
remindedAt: respondedAt && respondedAt < remindedAt ? null : remindedAt,
respondedAt,
});
if (!respondedAt) continue;
const response = add('feedbackResponse', { id: uuidv4(), formId: form.id, userId, createdAt: respondedAt });
const rating = Number(weighted({ 5: 45, 4: 35, 3: 14, 2: 5, 1: 1 }));
const answers = [
[overall, String(rating)],
[best, pick(enjoyed)],
[again, rating >= 3 && chance(0.95) ? 'yes' : 'no'],
[better, chance(0.35) ? pick(IMPROVEMENTS) : null],
];
for (const [field, value] of answers) {
if (value !== null) add('feedbackAnswer', { id: uuidv4(), responseId: response.id, fieldId: field.id, value });
}
}
}
// How each method's money is settled in the demo cashups. Card settles net of Yoco's fee.
const cardFee = (amount) => round2(amount * 0.0295);
function cardLine(exp) {
if (!(exp.card > 0)) return null;
const fee = cardFee(exp.card);
return { method: 'card', bankAmount: round2(exp.card - fee), writeOffs: [{ category: 'card_fees', amount: fee, reason: 'Yoco fees (2.95%)' }] };
}
function eftLine(exp, pending = 0, reference = null) {
if (!(exp.eft > 0)) return null;
const owed = Math.min(pending, exp.eft);
return { method: 'eft', bankAmount: round2(exp.eft - owed), pendingAmount: owed || null, pendingReference: owed ? reference : null };
}
function cashLine(counted, { keep = 0, depositFee = 0, shortfall = 0 } = {}) {
if (!counted) return null;
const writeOffs = [];
if (depositFee) writeOffs.push({ category: 'bank_charges', amount: depositFee, reason: 'Cash deposit fee' });
if (shortfall) writeOffs.push({ category: 'cash_shortfall', amount: shortfall, reason: 'Short in the count' });
return { method: 'cash', bankAmount: round2(counted - keep - depositFee), writeOffs };
}
/** ZAR notes and coins adding up to `amount`, largest first. */
function toDenominations(amount) {
const out = [];
let left = Math.round(amount * 100);
for (const value of [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1]) {
const cents = Math.round(value * 100);
const count = Math.floor(left / cents);
if (count > 0) {
out.push({ value, count });
left -= count * cents;
}
}
return out;
}
function buildPastEvents(people) {
const qty = (weights) => Number(weighted(weights));
return [
buildPastEvent(people, {
title: 'Church Family Picnic',
description: 'Our end-of-year picnic on the sports field: boerewors rolls, a jumping castle, tug of war and the annual staff vs youth soccer match.',
start: lastWeekday(330, 6, 11), hours: 5, picture: 'photo-1529156069898-49953e39b3ac', location: 'Church sports field',
options: [{ name: 'Adult', price: 60 }, { name: 'Child (3–12)', price: 30 }],
costs: o => [['Jumping castle hire', 'once_off', 1800], ['Boerewors rolls', 'per_item', 22, o[0]], ['Boerewors rolls (kids)', 'per_item', 15, o[1]]],
online: 35, onlinePay: { card: 80, eft: 15, none: 5 }, door: 10, doorPay: { cash: 80, card: 20 },
lines: o => [line(o[0], int(1, 2)), line(o[1], qty({ 0: 3, 1: 3, 2: 3, 3: 1 }))],
showRate: 0.92,
cashup: { close: 'quick', daysAfter: 4, notes: 'Small event, so quick closed. Totals checked against the bank statement.' },
}),
buildPastEvent(people, {
title: 'Christmas Eve Candlelight Service',
description: 'Carols, candles and the Christmas story, for the whole family. Entry is free; the collection goes to the children’s home.',
start: lastWeekday(301, 3, 18), hours: 1.5, picture: 'photo-1543589077-47d81606c1bf', location: 'Main auditorium',
options: [{ name: 'Free entry', price: 0, stockLimit: 600 }],
costs: () => [['Candles & holders', 'once_off', 1450], ['Sound & lighting hire', 'once_off', 2800]],
online: 60, onlinePay: { card: 1 },
lines: o => [line(o[0], qty({ 1: 15, 2: 30, 3: 15, 4: 25, 5: 10, 6: 5 }))],
donations: { card: 18, cash: 9, amounts: [100, 150, 200, 250, 500, 500, 1000] },
showRate: 0.88,
cashup: { close: 'full', daysAfter: 6, lines: (exp, counted) => [cardLine(exp), cashLine(counted)], notes: 'Christmas collection banked on the 29th.' },
}),
buildPastEvent(people, {
title: 'Easter Sunrise Service & Breakfast',
description: 'A sunrise service on the hill, followed by a cooked breakfast together on the lawn.',
start: lastWeekday(199, 0, 6), hours: 3.5, picture: 'photo-1511632765486-a01980e01a18', location: 'Church lawn',
options: [{ name: 'Adult breakfast', price: 95 }, { name: 'Child breakfast (under 12)', price: 45 }],
costs: o => [['Breakfast catering', 'per_item', 48, o[0]], ['Breakfast catering (kids)', 'per_item', 25, o[1]], ['Gazebos & chairs', 'once_off', 1600]],
online: 45, onlinePay: { card: 75, eft: 15, none: 10 }, refunds: 1, door: 16, doorPay: { cash: 75, card: 25 },
lines: o => [line(o[0], int(1, 2)), line(o[1], qty({ 0: 4, 1: 3, 2: 2 }))],
showRate: 0.9,
feedback: ['The sunrise service', 'Breakfast together', 'Kids activities', 'Worship'],
cashup: { close: 'full', daysAfter: 2, lines: (exp, counted) => [cashLine(counted, { depositFee: 58.5 }), cardLine(exp), eftLine(exp)], notes: 'Cash deposited at the branch on Tuesday.' },
}),
buildPastEvent(people, {
title: 'Mother’s Day Brunch',
description: 'Spoil the moms in your life with a long, slow brunch, a flower for every mom and a short word of encouragement.',
start: lastWeekday(164, 0, 11), hours: 3, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall',
options: [{ name: 'Brunch ticket', price: 180 }],
costs: o => [['Brunch catering', 'per_item', 95, o[0]], ['Flowers for every mom', 'once_off', 1400]],
online: 40, onlinePay: { card: 70, eft: 25, none: 5 }, door: 6, doorPay: { cash: 85, card: 15 },
lines: o => [line(o[0], qty({ 1: 30, 2: 45, 3: 15, 4: 10 }))],
showRate: 0.95,
cashup: {
close: 'reclose', daysAfter: 1, shortBy: 50, firstNotes: 'Cash R50 short. Will recount tomorrow.',
reopenDaysAfter: 2, reopenNotes: 'Found R50 in the float bag. Recounting.',
secondDaysAfter: 3, notes: 'Recounted: the cash is all there.',
lines: (exp, counted) => [cashLine(counted), cardLine(exp), eftLine(exp)],
},
}),
buildPastEvent(people, {
title: 'Youth Winter Camp',
description: 'A weekend away for Grade 8–12s: hikes, campfires, night games and three evening services.',
start: lastWeekday(103, 5, 17), hours: 45, picture: 'photo-1478131143081-80f7f84ca84d', location: 'Hartbeespoort campsite',
options: [{ name: 'Camper', price: 1200, stockLimit: 60 }],
costs: o => [['Campsite (per camper)', 'per_item', 520, o[0]], ['Food (per camper)', 'per_item', 260, o[0]], ['Bus hire', 'once_off', 9800]],
online: 34,
onlinePay: () => ({ pay: weighted({ full: 55, split: 40, none: 5 }), method: chance(0.55) ? 'eft' : 'card', deposit: 400 }),
lines: o => [line(o[0], qty({ 1: 75, 2: 25 }))],
showRate: 0.97,
feedback: ['Night games', 'Evening services', 'Hiking', 'Small groups', 'The food'],
cashup: {
close: 'full', daysAfter: 5, notes: 'Two parents’ EFTs still clearing.',
lines: (exp) => [eftLine(exp, 2400, 'POPs from two parents'), cardLine(exp)],
later: { method: 'eft', outcome: 'received', daysAfter: 12, notes: 'Both EFTs on the bank statement' },
},
}),
buildPastEvent(people, {
title: 'Men’s Golf Day',
description: 'Our annual four-ball alliance in support of the youth ministry, with halfway-house lunch and prize-giving at the clubhouse.',
start: lastWeekday(80, 5, 7, 30), hours: 8, picture: 'photo-1528605248644-14dd04022da1', location: 'Kloof Country Club', pool: 'men',
options: [{ name: 'Four-ball', price: 2400 }, { name: 'Single player', price: 650 }, { name: 'Halfway-house lunch', price: 120, isMainTicket: false }],
costs: o => [['Green fees (four-ball)', 'per_item', 1800, o[0]], ['Green fees (single)', 'per_item', 450, o[1]], ['Lunch', 'per_item', 75, o[2]], ['Prizes & trophies', 'once_off', 3500]],
online: 22, onlinePay: { card: 65, eft: 35 }, refunds: 1,
lines: o => (chance(0.4) ? [line(o[0], 1), line(o[2], 4)] : [line(o[1], 1), line(o[2], chance(0.7) ? 1 : 0)]),
showRate: 0.96,
cashup: { close: 'full', daysAfter: 3, lines: (exp) => [cardLine(exp), eftLine(exp)], notes: 'Card settlements match the Yoco payout report.' },
}),
buildPastEvent(people, {
title: 'Kids Holiday Club',
description: 'Four mornings of Bible stories, crafts, games and snacks for children aged 4–12.',
start: lastWeekday(62, 1, 8, 30), hours: 3 * 24 + 4.5, picture: 'photo-1529156069898-49953e39b3ac', location: 'Children’s ministry wing',
options: [{ name: 'Child (whole week)', price: 250 }, { name: 'Child (one day)', price: 70 }],
costs: o => [['Craft supplies', 'once_off', 2200], ['Snacks', 'per_item', 60, o[0]], ['Snacks (day visitors)', 'per_item', 15, o[1]]],
online: 40, onlinePay: { card: 60, eft: 35, none: 5 }, door: 18, doorPay: { cash: 85, card: 15 },
lines: (o, atDoor) => (atDoor ? [line(o[1], int(1, 3))] : [line(o[0], qty({ 1: 50, 2: 35, 3: 15 }))]),
showRate: 0.93,
feedback: ['Bible stories', 'Crafts', 'Games', 'Snacks', 'The leaders'],
cashup: {
close: 'full', daysAfter: 4, shortBy: 20, notes: 'R500 kept as the float for the next holiday club. R20 short written off.',
lines: (exp, counted) => [cashLine(counted, { keep: 500, shortfall: 20 }), cardLine(exp), eftLine(exp)],
},
}),
buildPastEvent(people, {
title: 'Women’s High Tea',
description: 'An afternoon of tea, scones and good conversation, with a short talk on finding rest in a busy season.',
start: lastWeekday(45, 6, 14), hours: 3, picture: 'photo-1511632765486-a01980e01a18', location: 'Church hall', pool: 'women',
options: [{ name: 'High tea ticket', price: 220 }],
costs: o => [['High tea catering', 'per_item', 110, o[0]], ['Décor & flowers', 'once_off', 1500]],
online: 40, onlinePay: { card: 75, eft: 20, none: 5 }, door: 6, doorPay: { cash: 80, card: 20 },
lines: o => [line(o[0], qty({ 1: 50, 2: 30, 3: 10, 4: 10 }))],
showRate: 0.94,
feedback: ['The speaker', 'The food', 'Time with friends', 'The décor'],
cashup: { close: 'full', daysAfter: 2, shortBy: 30, lines: (exp, counted) => [cashLine(counted), cardLine(exp), eftLine(exp)], notes: 'Cash R30 short and we could not trace it.' },
}),
buildPastEvent(people, {
title: 'Missions Fundraiser Dinner',
description: 'A three-course dinner with stories from our mission partners. Proceeds go to the Malawi school project.',
start: lastWeekday(27, 5, 18, 30), hours: 3.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall',
options: [{ name: 'Dinner ticket', price: 350 }, { name: 'Table of 8', price: 2600 }],
costs: o => [['Dinner catering', 'per_item', 160, o[0]], ['Dinner catering (tables)', 'per_item', 1280, o[1]], ['Venue décor', 'once_off', 3000]],
online: 30, onlinePay: { eft: 50, card: 45, none: 5 },
lines: o => (chance(0.25) ? [line(o[1], 1)] : [line(o[0], qty({ 1: 30, 2: 55, 4: 15 }))]),
donations: { card: 12, cash: 6, amounts: [200, 300, 500, 500, 1000, 2000] },
showRate: 0.95,
cashup: {
close: 'full', daysAfter: 3, notes: 'One table paid by EFT on the night. POP received, waiting for it to clear.',
lines: (exp, counted) => [eftLine(exp, 2600, 'POP: Molefe family table'), cardLine(exp), cashLine(counted)],
},
}),
buildPastEvent(people, {
title: 'Worship Night',
description: 'An evening of worship with the band, with coffee and the food trucks from 18:00.',
start: lastWeekday(10, 5, 19), hours: 2.5, picture: 'photo-1501281668745-f7f57925c3b4', location: 'Main auditorium',
options: [{ name: 'Entry', price: 80 }],
costs: () => [['Band sound & lights', 'once_off', 2800]],
online: 50, onlinePay: { card: 95, none: 5 }, door: 20, doorPay: { cash: 70, card: 30 },
lines: o => [line(o[0], qty({ 1: 30, 2: 40, 3: 15, 4: 15 }))],
showRate: 0.9,
// Left open, with the cash already counted, so a cashup can be done live in a demo.
cashup: { close: 'none' },
}),
];
}
/**
* Runs each past event's cashup through the real cashup code (counts, close, reopen,
* settling pending money), then dates it a few days after the event.
*/
async function closePastEvents() {
const cashupController = require('../src/controllers/cashupController');
const { computeEventFinancials, savePersonCashCount, bucketForMethod } = require('../src/utils/cashupUtils');
const call = (fn, params, body = {}) => new Promise((resolve, reject) => {
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(data) { if (this.statusCode >= 400) reject(new Error(data.message)); else resolve(data); },
};
fn({ params, body, user: admin, query: {} }, res);
});
// Each staff member's count of the cash they took; the first one comes up `shortBy` short.
const enterCounts = async (event, shortBy) => {
const byStaff = new Map();
for (const p of rows.payment) {
if (p.eventId === event.id && p.recordedById && bucketForMethod(p.method) === 'cash') {
byStaff.set(p.recordedById, (byStaff.get(p.recordedById) || 0) + p.amount);
}
}
let total = 0;
let first = true;
for (const [userId, amount] of byStaff) {
const counted = round2(amount - (first ? shortBy : 0));
first = false;
total += counted;
await savePersonCashCount(event.id, userId, { denominations: toDenominations(counted), notes: null, enteredById: admin.id });
}
return byStaff.size > 0 ? round2(total) : null;
};
const dayAfter = (event, days) => {
const d = addTime(event.endDate, days * DAY);
d.setHours(int(9, 16), int(0, 59), 0, 0);
return d < NOW ? d : addTime(NOW, -HOUR);
};
const close = async (event, { lines, notes }, counted, when) => {
const fin = await computeEventFinancials(event.id);
const body = { notes: notes || null };
if (lines) body.lines = lines(fin.expectedCashByMethod, counted).filter(Boolean);
const cashup = await call(cashupController.closeEvent, { eventId: event.id }, body);
await prisma.eventCashup.update({ where: { id: cashup.id }, data: { createdAt: when } });
await prisma.event.update({ where: { id: event.id }, data: { closedAt: when } });
};
for (const { event, cashup } of pastEvents) {
const counted = await enterCounts(event, cashup.shortBy || 0);
if (cashup.close === 'none') continue;
if (cashup.close === 'quick') {
await close(event, { notes: cashup.notes }, counted, dayAfter(event, cashup.daysAfter));
} else if (cashup.close === 'reclose') {
await close(event, { lines: cashup.lines, notes: cashup.firstNotes }, counted, dayAfter(event, cashup.daysAfter));
const reopenedAt = dayAfter(event, cashup.reopenDaysAfter);
const reopened = await call(cashupController.reopenEvent, { eventId: event.id }, { notes: cashup.reopenNotes });
await prisma.eventCashup.update({ where: { id: reopened.id }, data: { createdAt: reopenedAt } });
await prisma.event.update({ where: { id: event.id }, data: { reopenedAt } });
const recounted = await enterCounts(event, 0);
await close(event, { lines: cashup.lines, notes: cashup.notes }, recounted, dayAfter(event, cashup.secondDaysAfter));
} else {
await close(event, cashup, counted, dayAfter(event, cashup.daysAfter));
}
if (cashup.later) {
const fin = await computeEventFinancials(event.id);
const r = fin.reconciled.byMethod[cashup.later.method];
const resolution = await call(cashupController.resolvePending, { lineId: r.lineId }, { outcome: cashup.later.outcome, amount: r.pendingOutstanding, notes: cashup.later.notes });
await prisma.eventCashupPendingResolution.update({ where: { id: resolution.id }, data: { createdAt: dayAfter(event, cashup.later.daysAfter) } });
}
}
// Each event's figures as the cashup screen shows them, for the summary.
const results = new Map();
for (const { event } of pastEvents) results.set(event.id, await computeEventFinancials(event.id));
return results;
}
// ─── Funnel analytics ────────────────────────────────────────────────────────
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
const DEVICES = { mobile: 72, desktop: 22, tablet: 6 };
function buildAnalytics() {
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
for (const event of rows.event) {
const raw = [];
const session = (stages, at) => {
const sessionId = uuidv4();
const source = weighted(SOURCES);
const device = weighted(DEVICES);
let t = at.getTime() - stages.length * int(2, 15) * MINUTE;
for (const stage of stages) {
raw.push({ id: uuidv4(), sessionId, eventId: event.id, stage, source, device, createdAt: new Date(Math.min(t, at.getTime())) });
t += int(1, 12) * MINUTE;
}
return sessionId;
};
// Sold at the door, not online.
const registrations = (rows.registration || []).filter(r => r.eventId === event.id && r.createdAt < addTime(event.startDate, -2 * HOUR));
for (const reg of registrations) {
// Most registrations arrive from a tracked session (the rest declined cookies).
if (chance(0.8)) reg.analyticsSessionId = session(['view', 'start', 'submit'], reg.createdAt);
}
const salesEnd = event.startDate < NOW ? event.startDate : NOW;
const browsers = registrations.length * int(3, 5);
for (let i = 0; i < browsers; i++) {
session(chance(0.3) ? ['view', 'start'] : ['view'], daytimeBetween(event.goLiveAt, salesEnd));
}
// As the app does nightly: sessions last active more than 90 days ago are rolled up into
// daily counts and their raw rows dropped.
const lastSeen = new Map();
for (const e of raw) lastSeen.set(e.sessionId, Math.max(lastSeen.get(e.sessionId) || 0, e.createdAt.getTime()));
const old = raw.filter(e => lastSeen.get(e.sessionId) < rawCutoff.getTime());
for (const e of raw) if (lastSeen.get(e.sessionId) >= rawCutoff.getTime()) add('analyticsEvent', e);
if (old.length > 0) {
const paid = new Set(registrations.filter(r => r.status === 'paid' && r.analyticsSessionId).map(r => r.analyticsSessionId));
for (const r of rollupRows(sessionsFrom(old, paid))) {
add('analyticsDaily', { id: uuidv4(), eventId: event.id, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions });
}
}
}
}
// ─── Clearing ────────────────────────────────────────────────────────────────
async function clearData(tx) {
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
if (toClear.length) {
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
}
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
return { tables: toClear.length, users: count };
}
function clearScheduledEmails() {
if (!fs.existsSync(SCHEDULED_EMAILS_PATH)) return;
fs.writeFileSync(SCHEDULED_EMAILS_PATH, JSON.stringify({ jobs: [] }, null, 2), 'utf-8');
}
// ─── Main ────────────────────────────────────────────────────────────────────
function describeDatabase() {
try {
const u = new URL(process.env.DATABASE_URL);
return `${u.pathname.replace(/^\//, '')} on ${u.hostname}${u.port ? `:${u.port}` : ''}`;
} catch {
return 'the configured database';
}
}
async function confirm(admin) {
if (SKIP_PROMPT) return true;
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise(resolve => rl.question('Type "demo" to continue: ', resolve));
rl.close();
return answer.trim().toLowerCase() === 'demo';
}
async function main() {
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
if (!admin) {
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
console.error('Set DEMO_ADMIN_EMAIL to the account you want to keep.');
process.exitCode = 1;
return;
}
if (admin.role !== 'admin') console.warn(`[warn] ${admin.email} has role "${admin.role}", not admin.`);
if (!(await confirm(admin))) {
console.log('Cancelled — nothing was changed.');
return;
}
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
const people = buildPeople();
const events = [
buildMensBreakfast(people),
buildFamilyFunDay(people),
buildWomensConference(people),
buildYouthCamp(people),
buildCarols(people),
buildMarriageWeekend(people),
];
buildPastEvents(people);
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
const result = await clearData(tx);
for (const model of INSERT_ORDER) {
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
}
return result;
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
clearScheduledEmails();
const pastFinancials = await closePastEvents();
const count = (model, where = () => true) => (rows[model] || []).filter(where).length;
console.log(`\nCleared ${cleared.tables} tables and ${cleared.users} users.`);
console.log('\nDemo events:');
for (const event of events) {
const regs = rows.registration.filter(r => r.eventId === event.id);
const places = rows.ticket.filter(t => t.eventId === event.id).reduce((s, t) => s + t.quantity, 0);
const byStatus = ['paid', 'partial_paid', 'pending', 'cancelled']
.map(s => [s, regs.filter(r => r.status === s).length]).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs.length).padStart(3)} registrations (${byStatus}), ${places} ticketed places`);
}
const CASHUP_LABELS = {
none: 'OPEN: cash counted, ready to cash up',
quick: 'quick closed',
reclose: 'closed, reopened, closed again',
full: 'closed with a cashup',
};
const money = (n) => `R${n.toFixed(2)}`;
const ticketEvent = new Map(rows.ticket.map(t => [t.id, t.eventId]));
console.log('\nPast events:');
for (const { event, cashup } of pastEvents) {
const regs = rows.registration.filter(r => r.eventId === event.id).length;
const scans = (rows.ticketUsage || []).filter(u => ticketEvent.get(u.ticketId) === event.id).length;
const f = pastFinancials.get(event.id);
const unaccounted = f.reconciled ? Object.values(f.reconciled.byMethod).reduce((s, r) => s + (r.unaccounted || 0), 0) : 0;
const notes = [
f.totalWriteOffs > 0 && `${money(f.totalWriteOffs)} written off`,
f.pendingOutstandingTotal > 0 && `${money(f.pendingOutstandingTotal)} pending`,
cashup.later && 'pending money received later',
Math.abs(unaccounted) >= 0.005 && `${money(unaccounted)} unaccounted`,
].filter(Boolean).join(', ');
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
console.log(`\nKept: site settings and ${admin.email}.`);
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
console.log(`Every other demo person also uses ${DEMO_PASSWORD}.`);
}
main()
.catch(err => { console.error(err); process.exitCode = 1; })
// The cashup code used by closePastEvents has its own client (src/config/db).
.finally(() => Promise.all([prisma.$disconnect(), require('../src/config/db').$disconnect()]));