Merge branch 'chore/demo-history'

This commit is contained in:
2026-10-21 13:39:47 +02:00
3 changed files with 570 additions and 43 deletions
+6
View File
@@ -7,6 +7,12 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.27.1] - 2026-10-21
### Added
- `npm run set-demo` also stages ten completed events from the past year, with door sales recorded by staff, staff cash counts, check-ins, refunds, donations and post-event feedback. Their cashups go through every path: a quick close; full closes with card fees, a cash deposit fee, a written-off shortfall, cash kept on hand and an unaccounted amount; a close, reopen and re-close; pending EFT money that was later received and some still outstanding; and one event left open with the cash counted, ready for a live cashup. Funnel stats older than 90 days are stored as daily totals, the way the app rolls them up. See `backend/DEMO_DATA.md`.
## [1.27.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
+68 -14
View File
@@ -2,8 +2,9 @@
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, so every dashboard, report and check-in
screen has something to show.
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
@@ -26,7 +27,7 @@ over `.env`:
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
```
It takes a few seconds and is safe to re-run as often as you like. Each run wipes the
It takes about five seconds and is safe to re-run as often as you like. Each run wipes the
previous demo and creates it again.
### Before you run it
@@ -50,14 +51,17 @@ previous demo and creates it again.
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
The wipe and the new data are written in one database transaction. If anything fails,
nothing changes.
The wipe and the new data are written in one database transaction. If that fails,
nothing changes. The past events' cashups run straight afterwards, through the same
code as the cashup screen. If one of those fails, re-run the script.
---
## What it creates
All dates are worked out from the day you run it, so the events are always upcoming.
### Upcoming events
All dates are worked out from the day you run it, so these events are always upcoming.
Registrations are spread over the past month, as if sign-ups have been coming in.
The data is generated from a fixed seed, so every run looks the same.
@@ -77,19 +81,53 @@ Each event also gets:
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
(WhatsApp, Facebook, Instagram, email, direct) and device.
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
### Past events
Ten completed events spread over the past year, for showing cashups and the historical
reports. Each one has:
- Online sales in the five weeks before the event, with a few unpaid no-shows.
- **Door sales** on the day, recorded by the staff team (cash and card machine).
- **Check-ins**: about 90% of tickets scanned, with the odd group arriving short.
- **Staff cash counts** with note and coin denominations.
Some events also have refunds, donations and post-event feedback.
The cashups cover every path through the cashup screen:
| When | Event | Cashup to demo |
|---|---|---|
| ~11 months ago | **Church Family Picnic** | **Quick closed** (no settlement) |
| 24 Dec | **Christmas Eve Candlelight Service** | Free event with card and cash **donations**. Card settled net of **Yoco fees**, collection banked |
| Easter Sunday | **Easter Sunrise Service & Breakfast** | Cash banked less a **cash deposit fee** (bank charge), card fees, a **refund** |
| Mother's Day | **Mother's Day Brunch** | **Closed with R50 cash short → reopened → recounted → closed again.** The history shows all three steps |
| ~3½ months ago | **Youth Winter Camp** | Deposits and balances. **EFT pending** (POPs) at close, later **marked received** without reopening |
| ~2½ months ago | **Men's Golf Day** | Card-heavy, card fees written off, a refund |
| ~2 months ago | **Kids Holiday Club** | Cash-heavy door sales. **R20 cash shortfall written off**, **R500 kept on hand** as float |
| ~6 weeks ago | **Women's High Tea** | Closed with **R30 unaccounted**, shown in red |
| ~4 weeks ago | **Missions Fundraiser Dinner** | **R2 600 EFT still pending** (POP received, not cleared). Shows "Mark received" / "Write off" |
| ~10 days ago | **Worship Night** | **Still open, with the cash already counted.** Use it to do a cashup live in the demo |
Easter, the Youth Camp, the Holiday Club and the High Tea have **feedback forms** with
ratings and comments. Funnel stats older than 90 days are stored as daily totals, the
way the app rolls them up overnight, so the funnel report covers the whole year.
### Totals
A typical run gives roughly:
| | |
|---|---|
| Users | 130 (plus the admin) |
| Registrations | ~220 (paid, part-paid, pending and cancelled) |
| Payments | ~180, including donations and sponsorships |
| Tickets | ~245 |
| Registrations | ~700 (paid, part-paid, pending, cancelled and refunded) |
| Payments | ~640, including donations, sponsorships and refunds |
| Tickets | ~780, with ~490 check-ins |
| Named campers | ~45 |
| Funnel events | ~1 700 |
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
| Feedback responses | ~80 |
| Funnel rows | ~4 400 |
---
@@ -99,11 +137,14 @@ Every demo account uses the password **`Demo@1234`**.
| Role | Email | Good for showing |
|---|---|---|
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, cash-up, coupons |
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, coupons, viewing cashups |
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
| Staff | `anri.botha@example.com` | |
| Staff | `daniel.pillay@example.com` | |
Closing, reopening and settling pending money in a cashup are admin-only, so do the
live Worship Night cashup as your own admin account.
The other 126 people are ordinary members (role `user`), with emails like
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
registrations, tickets and payments. Any registrant's email from an event's attendee
@@ -133,3 +174,16 @@ Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
`main()` decides which ones run. To change an event's name, prices, ticket types or
number of registrations, edit its function. To add an event, write a new `build…()`
and add it to the list in `main()`.
The past events are in `buildPastEvents()`, one spec each: dates, ticket types, costs,
how many people bought online and at the door, and a `cashup` that says how it was
closed. Supported closes:
- `'quick'`
- `'full'`, with `lines` saying where the money ended up
- `'reclose'`
- `'none'`, which leaves the event open
A full close can add `later` to settle pending money after closing. The helpers
`cardLine`, `eftLine` and `cashLine` build the usual settlements: card net of Yoco fees,
EFT with an optional pending POP, and cash banked with an optional float, deposit fee or
shortfall.
+488 -21
View File
@@ -3,11 +3,12 @@
*
* 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.
* 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 events are always in the future whenever this is run.
* 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.
@@ -161,9 +162,10 @@ const add = (model, row) => {
const INSERT_ORDER = [
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'registration', 'registrationOption',
'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'couponRedemption',
'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'ticketUsage', 'couponRedemption',
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
'allocationGroup', 'allocationAssignment', 'eventCost', 'analyticsEvent',
'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).
@@ -268,9 +270,11 @@ const line = (option, quantity, extra = {}) => ({ option, quantity, ...extra });
/**
* One registration with its lines, payments and (once paid) tickets.
* pay: 'full' | 'split' (deposit then balance) | 'deposit' | 'none' | 'cancelled'
* 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 }) {
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,
@@ -324,29 +328,44 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
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: how === 'card' ? null : pick(staffTeam).id,
recordedById: online ? null : recorder.id,
registrationId: registration.id,
eventId: event.id,
status: how === 'card' ? 'completed' : null,
externalId: how === 'card' ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: at,
status: online ? 'completed' : null,
externalId: online ? `demo_${uuidv4().replace(/-/g, '')}` : null,
createdAt: payBy && at > payBy ? payBy : at,
}));
};
const firstPaidAt = after(createdAt, method === 'card' ? 2 : 600, method === 'card' ? 15 : 3 * 24 * 60);
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') pay1(total, firstPaidAt);
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') registration.status = 'cancelled';
if (pay === 'cancelled' || pay === 'refunded') registration.status = 'cancelled';
else if (paid >= total) registration.status = 'paid';
else if (paid > 0) registration.status = 'partial_paid';
@@ -735,32 +754,455 @@ function buildMarriageWeekend(people) {
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 session = (eventId, stages, at) => {
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) {
add('analyticsEvent', { id: uuidv4(), sessionId, eventId, stage, source, device, createdAt: new Date(Math.min(t, at.getTime())) });
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;
};
for (const event of rows.event) {
const registrations = (rows.registration || []).filter(r => r.eventId === event.id);
// 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(event.id, ['view', 'start', 'submit'], reg.createdAt);
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(event.id, chance(0.3) ? ['view', 'start'] : ['view'], pastDate(30));
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 });
}
}
}
}
@@ -830,6 +1272,7 @@ async function main() {
buildCarols(people),
buildMarriageWeekend(people),
];
buildPastEvents(people);
buildAnalytics();
const cleared = await prisma.$transaction(async (tx) => {
@@ -840,6 +1283,7 @@ async function main() {
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.`);
@@ -851,7 +1295,29 @@ async function main() {
.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`);
}
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('attendee')} named campers, ${count('analyticsEvent')} funnel events.`);
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}`);
@@ -860,4 +1326,5 @@ async function main() {
main()
.catch(err => { console.error(err); process.exitCode = 1; })
.finally(() => prisma.$disconnect());
// The cashup code used by closePastEvents has its own client (src/config/db).
.finally(() => Promise.all([prisma.$disconnect(), require('../src/config/db').$disconnect()]));