diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d6308..6945eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/DEMO_DATA.md b/backend/DEMO_DATA.md index 1084439..d9de784 100644 --- a/backend/DEMO_DATA.md +++ b/backend/DEMO_DATA.md @@ -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. diff --git a/backend/scripts/set-demo.js b/backend/scripts/set-demo.js index b9bc31a..ac95a1c 100644 --- a/backend/scripts/set-demo.js +++ b/backend/scripts/set-demo.js @@ -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 "-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()]));