Merge branch 'chore/demo-history'
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
+496
-29
@@ -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 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())) });
|
||||
t += int(1, 12) * MINUTE;
|
||||
}
|
||||
return sessionId;
|
||||
};
|
||||
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
|
||||
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
|
||||
|
||||
for (const event of rows.event) {
|
||||
const registrations = (rows.registration || []).filter(r => r.eventId === event.id);
|
||||
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(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()]));
|
||||
|
||||
Reference in New Issue
Block a user