Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b186b3481 | ||
|
|
7ba40163db | ||
|
|
428b438251 | ||
|
|
6ce59af0ea | ||
|
|
53ed72d032 | ||
|
|
35d02c243b | ||
|
|
e2e6ec0e97 | ||
|
|
92ab8065ef | ||
|
|
084e3c6679 | ||
|
|
de196e34f2 | ||
|
|
763b66ff56 | ||
|
|
8f92a8c3e9 | ||
|
|
6fc6c35214 | ||
|
|
be1d6f2e31 |
@@ -7,6 +7,53 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.3] - 2026-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
|
||||
|
||||
## [1.28.2] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
|
||||
|
||||
## [1.28.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
|
||||
|
||||
### Fixed
|
||||
|
||||
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
|
||||
- At the door's "less than already paid" warning shows the total after bundle deals.
|
||||
|
||||
## [1.28.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
### Added
|
||||
|
||||
- **Bundle deals**: sell a set of tickets together for one price, e.g. 5 Adult + 3 Child for R500 instead of R550. Supervisors set them up per event under Dashboard → **Bundle deals**: the tickets and quantities (one variant or any variant), the price, optional dates, and on/off.
|
||||
- Bundles apply automatically, with no code, as many times as a registration's tickets allow; leftover tickets are charged as usual. A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included. When several compete, the one that saves the most is used first. This works for online registration, manual registration, at the door and on the kiosk.
|
||||
- The event page and the register form list the event's deals, and every basket shows the saving live. On the register form, once a ticket from a bundle is picked, the bundle appears with what's missing and a **Complete bundle** button that tops up the ticket numbers. Once it's complete, **+** adds another full set. Invoices, receipts, emails, WhatsApp messages and the Yoco payment page show it as its own line, e.g. "Bundle: Family deal ×2".
|
||||
- The Discounts report lists bundle deals next to coupon codes. Each bundle shows how often it's been used and the total saving given.
|
||||
- `npm run set-demo`: Family Fun Day sells a **Family deal** bundle (2 adults + 3 kids for R220) in place of the old Family pass ticket.
|
||||
|
||||
### Changed
|
||||
|
||||
- Coupon codes now work out after bundle deals, on what's left. Registration.discountTotal now holds bundle and coupon discounts together, with the bundle part in the new `bundleDiscount`.
|
||||
- Removing a code from a registration keeps its bundle deal. When an early-bird tier expires before payment and prices change, bundle deals are worked out again and coupon amounts are kept.
|
||||
- Registrations keep a bundle they already have, even after it's turned off or its dates pass. Deleting a bundle that has been used turns it off instead.
|
||||
|
||||
## [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.
|
||||
|
||||
+72
-16
@@ -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,21 +51,24 @@ 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.
|
||||
|
||||
| When (from today) | Event | Shows off |
|
||||
|---|---|---|
|
||||
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
|
||||
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Several ticket types (adult, child, family pass), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
|
||||
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
|
||||
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
|
||||
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
|
||||
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
|
||||
@@ -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
|
||||
@@ -132,4 +173,19 @@ Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
|
||||
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
|
||||
`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()`.
|
||||
and add it to the list in `main()`. `addBundle(event, { name, price }, [[option, quantity], …])`
|
||||
adds a bundle deal. Registrations added after it get the deal automatically, worked out
|
||||
the same way as the app does it.
|
||||
|
||||
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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "bundleDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventBundle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"validFrom" TIMESTAMP(3),
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventBundle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventBundleItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bundleId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "EventBundleItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationBundle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"bundleId" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"discount" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegistrationBundle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundle_eventId_idx" ON "EventBundle"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_bundleId_idx" ON "EventBundleItem"("bundleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_eventOptionId_idx" ON "EventBundleItem"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_variantId_idx" ON "EventBundleItem"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationBundle_registrationId_idx" ON "RegistrationBundle"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationBundle_bundleId_idx" ON "RegistrationBundle"("bundleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundle" ADD CONSTRAINT "EventBundle_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -197,6 +197,7 @@ model Event {
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
bundles EventBundle[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -220,6 +221,7 @@ model EventOption {
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -255,6 +257,7 @@ model OptionVariant {
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -265,10 +268,13 @@ model Registration {
|
||||
eventId String
|
||||
status RegistrationStatus @default(pending)
|
||||
checkoutId String? @unique
|
||||
// Sum of this registration's active coupon discounts, kept in step with
|
||||
// couponRedemptions. Stored here so every total-due calculation sees it no matter
|
||||
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
|
||||
// Sum of this registration's discounts: bundle deals plus active coupon discounts,
|
||||
// kept in step with bundleApplications and couponRedemptions. Stored here so every
|
||||
// total-due calculation sees it no matter which relations the caller loaded (see
|
||||
// utils/pricing.js computeRegistrationTotalDue).
|
||||
discountTotal Float @default(0)
|
||||
// The bundle-deal part of discountTotal (the rest is coupons).
|
||||
bundleDiscount Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
analyticsSessionId String?
|
||||
@@ -281,6 +287,7 @@ model Registration {
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@ -495,6 +502,9 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
bundle_created
|
||||
bundle_updated
|
||||
bundle_deleted
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
@@ -902,6 +912,62 @@ model CouponRedemption {
|
||||
@@index([appliedById])
|
||||
}
|
||||
|
||||
// ─── Bundle deals ─────────────────────────────────────────────────────────────
|
||||
|
||||
// A set of tickets sold together for one price, e.g. 5 Adult + 3 Child for R500.
|
||||
// Applied automatically, as many times as a registration's items allow, and only
|
||||
// when it beats what the items would cost anyway (see utils/bundles.js).
|
||||
model EventBundle {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
description String?
|
||||
price Float
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
items EventBundleItem[]
|
||||
uses RegistrationBundle[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
// One line of a bundle. variantId null = any variant of the option.
|
||||
model EventBundleItem {
|
||||
id String @id @default(uuid())
|
||||
bundleId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
quantity Int
|
||||
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([bundleId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
// How many times a bundle applies to a registration and what it saved. Rebuilt each
|
||||
// time the registration's discounts are recalculated.
|
||||
model RegistrationBundle {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
bundleId String
|
||||
count Int
|
||||
discount Float
|
||||
createdAt DateTime @default(now())
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([bundleId])
|
||||
}
|
||||
|
||||
// ─── Group tickets ────────────────────────────────────────────────────────────
|
||||
|
||||
enum TicketDelivery {
|
||||
|
||||
+533
-36
@@ -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.
|
||||
@@ -30,6 +31,7 @@ require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const { applyBundles } = require('../src/utils/bundles');
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
@@ -160,10 +162,12 @@ 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',
|
||||
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
|
||||
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
|
||||
'ticketUsage', 'couponRedemption', 'registrationBundle',
|
||||
'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).
|
||||
@@ -239,6 +243,19 @@ function addSection(event, name, options) {
|
||||
return section;
|
||||
}
|
||||
|
||||
/** A bundle deal: items are [option, quantity] pairs. */
|
||||
function addBundle(event, data, items) {
|
||||
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
|
||||
for (const [option, quantity] of items) add('eventBundleItem', { id: uuidv4(), bundleId: bundle.id, eventOptionId: option.id, variantId: null, quantity });
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** The event's bundles with their items, in the shape utils/bundles.js expects. */
|
||||
function bundlesFor(event) {
|
||||
return (rows.eventBundle || []).filter(b => b.eventId === event.id)
|
||||
.map(b => ({ ...b, items: (rows.eventBundleItem || []).filter(i => i.bundleId === b.id) }));
|
||||
}
|
||||
|
||||
function addCost(event, label, costType, amount, option = null, notes = null) {
|
||||
add('eventCost', { id: uuidv4(), eventId: event.id, eventOptionId: option ? option.id : null, label, costType, amount, paidFromMethod: null, notes });
|
||||
}
|
||||
@@ -268,21 +285,25 @@ 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,
|
||||
eventId: event.id,
|
||||
status: 'pending',
|
||||
discountTotal: 0,
|
||||
bundleDiscount: 0,
|
||||
ticketDelivery: 'payer',
|
||||
analyticsSessionId: null,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
let subtotal = 0;
|
||||
const bundleLines = [];
|
||||
const regLines = lines.filter(l => l.quantity > 0).map(l => {
|
||||
const { price, tier } = priceAt(l.option, l.variant || null, createdAt, l.quantity);
|
||||
const ro = add('registrationOption', {
|
||||
@@ -303,6 +324,7 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
|
||||
createdAt,
|
||||
});
|
||||
subtotal += l.quantity * price;
|
||||
bundleLines.push({ eventOptionId: l.option.id, variantId: l.variant ? l.variant.id : null, quantity: l.quantity, unitPrice: price, basePrice: price });
|
||||
const attendees = (l.attendees || []).map((a, order) => add('attendee', {
|
||||
id: uuidv4(),
|
||||
registrationId: registration.id,
|
||||
@@ -316,37 +338,62 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
|
||||
return { ro, option: l.option, attendees };
|
||||
});
|
||||
|
||||
// Bundle deals first, the same way the app applies them; then the coupon on what's left.
|
||||
const deals = applyBundles(bundlesFor(event), bundleLines);
|
||||
if (deals.discount > 0) {
|
||||
registration.bundleDiscount = deals.discount;
|
||||
registration.discountTotal = deals.discount;
|
||||
for (const a of deals.applications) {
|
||||
add('registrationBundle', { id: uuidv4(), registrationId: registration.id, bundleId: a.bundleId, count: a.count, discount: a.discount, createdAt });
|
||||
}
|
||||
}
|
||||
if (coupon && pay !== 'cancelled') {
|
||||
const discount = coupon.type === 'percent' ? round2(subtotal * coupon.value / 100) : Math.min(subtotal, coupon.value);
|
||||
registration.discountTotal = discount;
|
||||
const base = subtotal - registration.bundleDiscount;
|
||||
const discount = coupon.type === 'percent' ? round2(base * coupon.value / 100) : Math.min(base, coupon.value);
|
||||
registration.discountTotal = round2(registration.discountTotal + discount);
|
||||
add('couponRedemption', { id: uuidv4(), couponId: coupon.id, registrationId: registration.id, userId: user.id, discountAmount: discount, createdAt });
|
||||
}
|
||||
const total = round2(subtotal - registration.discountTotal);
|
||||
|
||||
const payments = [];
|
||||
const online = method === 'card' && !atDoor;
|
||||
const recorder = pick(staffTeam);
|
||||
const pay1 = (amount, at, how = method) => {
|
||||
payments.push(add('payment', {
|
||||
id: uuidv4(),
|
||||
amount: round2(amount),
|
||||
method: how,
|
||||
userId: user.id,
|
||||
recordedById: 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';
|
||||
|
||||
@@ -405,8 +452,8 @@ function buildFamilyFunDay(people) {
|
||||
});
|
||||
const adult = addOption(event, { name: 'Adult', price: 80 });
|
||||
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
|
||||
const family = addOption(event, { name: 'Family pass (2 adults + 3 kids)', price: 220 });
|
||||
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
|
||||
addBundle(event, { name: 'Family deal', price: 220, description: 'Two adults and three kids, R60 less than buying them separately.' }, [[adult, 2], [child, 3]]);
|
||||
|
||||
add('coupon', {
|
||||
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
|
||||
@@ -415,15 +462,17 @@ function buildFamilyFunDay(people) {
|
||||
});
|
||||
add('couponEvent', { id: uuidv4(), couponId: rows.coupon[rows.coupon.length - 1].id, eventId: event.id });
|
||||
|
||||
addSection(event, 'Main gate', [adult, child, family]);
|
||||
addSection(event, 'Main gate', [adult, child]);
|
||||
addSection(event, 'Food stall', [lunch]);
|
||||
addCost(event, 'Jumping castles & games hire', 'once_off', 4500);
|
||||
addCost(event, 'Face painter', 'once_off', 1200);
|
||||
addCost(event, 'Lunch packs', 'per_item', 38, lunch, 'Supplied by the catering team');
|
||||
|
||||
for (const user of shuffle(people.everyone).slice(0, 30)) {
|
||||
const lines = chance(0.35)
|
||||
? [line(family, 1)]
|
||||
// About a third come as a family of 2 + 3 and get the bundle; a few bring two families.
|
||||
const families = chance(0.35) ? (chance(0.15) ? 2 : 1) : 0;
|
||||
const lines = families
|
||||
? [line(adult, 2 * families), line(child, 3 * families)]
|
||||
: [line(adult, int(1, 2)), line(child, Number(weighted({ 0: 2, 1: 3, 2: 3, 3: 1 })))];
|
||||
lines.push(line(lunch, chance(0.6) ? int(1, 4) : 0));
|
||||
addRegistration(event, user, lines, { createdAt: pastDate(30), ...payPlan({ card: 0.72, eft: 0.12, none: 0.16 }) });
|
||||
@@ -735,32 +784,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 +1302,7 @@ async function main() {
|
||||
buildCarols(people),
|
||||
buildMarriageWeekend(people),
|
||||
];
|
||||
buildPastEvents(people);
|
||||
buildAnalytics();
|
||||
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
@@ -840,6 +1313,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 +1325,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 +1356,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()]));
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { describeBundleItems, isBundleAvailable } = require('../utils/bundles');
|
||||
const { previewBundles } = require('../utils/coupons');
|
||||
const { basketBuyer, priceBasket } = require('./couponController');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
/** What the items would cost separately at the standard (variant or option) price. */
|
||||
function standardValue(bundle) {
|
||||
return round2((bundle.items || []).reduce((s, i) => {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price;
|
||||
return s + Number(price || 0) * Number(i.quantity || 0);
|
||||
}, 0));
|
||||
}
|
||||
|
||||
// Items with prices loaded, so the standard value can be shown next to the bundle price.
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function shape(bundle, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return {
|
||||
...bundle,
|
||||
items,
|
||||
summary: describeBundleItems(bundle),
|
||||
standardValue: standardValue(bundle),
|
||||
available: isBundleAvailable(bundle, now),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the item list. On update, fields
|
||||
* left out of the body are left alone. Items must belong to the bundle's event.
|
||||
*/
|
||||
async function parseBundleBody(body, eventId, { isCreate }) {
|
||||
const data = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw new Error('Give the bundle a name');
|
||||
if (name.length > 80) throw new Error('Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (isCreate || body.price !== undefined) {
|
||||
const price = Number(body.price);
|
||||
if (!(price >= 0) || body.price === '' || body.price === null) throw new Error('Enter the bundle price (zero or more)');
|
||||
data.price = round2(price);
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
|
||||
|
||||
let items;
|
||||
if (isCreate || body.items !== undefined) {
|
||||
const raw = Array.isArray(body.items) ? body.items : [];
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map();
|
||||
for (const it of raw) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = Number(it.quantity);
|
||||
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each item needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: (prev?.quantity || 0) + quantity });
|
||||
}
|
||||
items = [...merged.values()];
|
||||
if (items.length === 0) throw new Error('Add at least one ticket to the bundle');
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
|
||||
|
||||
const options = await prisma.eventOption.findMany({
|
||||
where: { id: { in: items.map(i => i.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
}
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function eventOr404(eventId, res) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
return event;
|
||||
}
|
||||
|
||||
// @desc An event's bundle deals with how often each has been used
|
||||
// @route GET /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const listBundles = async (req, res) => {
|
||||
try {
|
||||
await eventOr404(req.params.eventId, res);
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
res.json(bundles.map(b => ({
|
||||
...shape(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a bundle deal
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const createBundle = async (req, res) => {
|
||||
try {
|
||||
const event = await eventOr404(req.params.eventId, res);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.create({
|
||||
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, price: bundle.price, items: describeBundleItems(bundle) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Edit a bundle deal (including switching it off). Registrations that already
|
||||
// have it are only re-worked when their own items change.
|
||||
// @route PUT /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const updateBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(shape(bundle));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
|
||||
// registrations that got it keep their price.
|
||||
// @route DELETE /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) {
|
||||
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
} else {
|
||||
await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
|
||||
targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(used > 0
|
||||
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true, message: 'Bundle deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The bundle deals a basket gets, before any coupon code
|
||||
// @route POST /api/bundles/quote
|
||||
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
const quoteBundles = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, forUserId } = req.body || {};
|
||||
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { buyer } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
res.json({
|
||||
subtotal,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
total: round2(Math.max(0, subtotal - bundles.discount)),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** The deals to show on an event's page: available bundles, without internal fields. */
|
||||
async function publicBundlesForEvent(eventId) {
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId, isActive: true },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const now = new Date();
|
||||
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
|
||||
const s = shape(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, price: s.price, validUntil: s.validUntil,
|
||||
summary: s.summary, standardValue: s.standardValue,
|
||||
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
|
||||
@@ -4,6 +4,7 @@ const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
const { describeBundleItems } = require('../utils/bundles');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
@@ -231,6 +232,39 @@ const updateCoupon = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
async function basketBuyer(req, forUserId) {
|
||||
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
const registrationOptions = [];
|
||||
for (const o of options || []) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
return buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
// @desc Check a code against the items someone is about to register for
|
||||
// @route POST /api/coupons/validate
|
||||
// @access Public (login needed for restricted codes)
|
||||
@@ -249,33 +283,12 @@ const validateCode = async (req, res) => {
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
|
||||
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
|
||||
// Price the basket the same way registration will.
|
||||
const registrationOptions = [];
|
||||
for (const o of options) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
const lines = buildCouponLines({ registrationOptions });
|
||||
const { buyer, isStaff } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount } = await previewCoupon({
|
||||
const { coupon, discount, bundles } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
@@ -287,8 +300,10 @@ const validateCode = async (req, res) => {
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - discount),
|
||||
total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
@@ -299,7 +314,7 @@ const validateCode = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Coupon uses for the Discounts report
|
||||
// @desc Coupon and bundle-deal uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
const listRedemptions = async (req, res) => {
|
||||
@@ -322,10 +337,34 @@ const listRedemptions = async (req, res) => {
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
|
||||
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json([
|
||||
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id,
|
||||
kind: 'bundle',
|
||||
uses: b.count,
|
||||
discountAmount: b.discount,
|
||||
createdAt: b.registration.createdAt,
|
||||
summary: `${describeBundleItems(b.bundle)} for R${Number(b.bundle.price).toFixed(2)}`,
|
||||
coupon: { id: b.bundleId, code: b.bundle.name },
|
||||
user: b.registration.user,
|
||||
appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
]);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
|
||||
|
||||
@@ -457,6 +457,7 @@ const getEventById = async (req, res) => {
|
||||
url: toAbsoluteUrl(req, att.url)
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
};
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
|
||||
@@ -917,18 +917,18 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
/**
|
||||
* Yoco line items for a registration: one per item at each price actually charged
|
||||
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
|
||||
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
|
||||
* bundle saving and coupon discount as negative lines. Previous/partial payment lines are
|
||||
* added by callers.
|
||||
*/
|
||||
function checkoutLineItems(registration) {
|
||||
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
|
||||
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
|
||||
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
||||
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
||||
quantity: row.quantity,
|
||||
pricingDetails: { price: Math.round(row.unitPrice * 100) },
|
||||
}));
|
||||
const coupon = couponDiscountLine(registration);
|
||||
if (coupon) {
|
||||
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
|
||||
for (const line of discountLines(registration)) {
|
||||
items.push({ displayName: line.label, quantity: 1, pricingDetails: { price: -Math.round(line.amount * 100) } });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -947,7 +947,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -1067,7 +1067,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
@@ -429,9 +430,9 @@ const createRegistration = async (req, res) => {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Apply the coupon (or re-work an existing one over the merged items).
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
||||
@@ -441,7 +442,7 @@ const createRegistration = async (req, res) => {
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
@@ -449,6 +450,8 @@ const createRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
@@ -522,6 +525,8 @@ const getRegistrations = async (req, res) => {
|
||||
try {
|
||||
const include = {
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
@@ -601,6 +606,8 @@ const getUserRegistrations = async (req, res) => {
|
||||
}
|
||||
},
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
formResponses: { select: { id: true, attendeeId: true } },
|
||||
// Nest the event's form so the frontend can tell whether attendee forms are
|
||||
@@ -651,6 +658,8 @@ const getRegistrationById = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
formResponses: { include: { answers: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
};
|
||||
|
||||
@@ -894,6 +903,8 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
@@ -1225,6 +1236,8 @@ const createManualRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
registration.status = newExistingStatus;
|
||||
@@ -1262,6 +1275,7 @@ const createManualRegistration = async (req, res) => {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
},
|
||||
});
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
|
||||
@@ -1269,6 +1283,8 @@ const createManualRegistration = async (req, res) => {
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || hasDiscount) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
@@ -1276,6 +1292,8 @@ const createManualRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1717,6 +1735,8 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: true,
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
@@ -2123,6 +2143,8 @@ const applyCoupon = async (req, res) => {
|
||||
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
|
||||
|
||||
+12
-1
@@ -178,6 +178,7 @@ app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/bundles', require('./routes/bundleRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
@@ -754,7 +755,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
|
||||
{ status:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], subtotal:900, total:450 }},
|
||||
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
|
||||
@@ -766,6 +767,16 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
|
||||
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
|
||||
]},
|
||||
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
|
||||
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
|
||||
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
|
||||
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
|
||||
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant).',
|
||||
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
|
||||
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
|
||||
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
|
||||
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, createBundle);
|
||||
router.put('/:id', protect, supervisor, updateBundle);
|
||||
router.delete('/:id', protect, supervisor, deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bundle deals — a set of tickets sold together for one price, e.g. 5 Adult + 3 Child
|
||||
* for R500.
|
||||
*
|
||||
* Rules:
|
||||
* - Applied automatically: no code needed. A bundle applies as many times as the
|
||||
* registration's items allow; leftover items are charged as usual.
|
||||
* - Best price wins: each application only counts when the bundle price beats what its
|
||||
* items cost at the prices actually charged (early-bird, staff price), so a bundle
|
||||
* never makes anything dearer. The most expensive matching units go into a bundle
|
||||
* first, and when several bundles compete the one saving the most is applied first.
|
||||
* - A bundle item with no variant matches any variant of its option.
|
||||
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
|
||||
* to the coupon code at their share of the bundle price (see utils/coupons.js).
|
||||
* - The discount is stored on Registration.bundleDiscount (and included in
|
||||
* discountTotal), with one RegistrationBundle row per bundle used.
|
||||
* - A registration keeps any bundle it already has, even after the bundle is switched
|
||||
* off or its dates pass, so editing a registration never takes a deal away.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
|
||||
/** Whether a bundle can be offered right now (switched on, within its dates, has items). */
|
||||
function isBundleAvailable(bundle, now = new Date()) {
|
||||
if (!bundle || !bundle.isActive) return false;
|
||||
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
|
||||
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
|
||||
return (bundle.items || []).some(i => Number(i.quantity) > 0);
|
||||
}
|
||||
|
||||
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
|
||||
|
||||
/**
|
||||
* Works out one application of `bundle` against what's left in `pool`, without changing
|
||||
* it. Returns { takes: [{ index, quantity }], charged, saving } or null if the items
|
||||
* aren't there.
|
||||
*/
|
||||
function tryBundle(bundle, pool) {
|
||||
const left = pool.map(p => p.remaining);
|
||||
const takes = [];
|
||||
let charged = 0;
|
||||
// Variant-specific items first, so an "any variant" item can't use up the units a
|
||||
// specific one needs.
|
||||
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
|
||||
for (const item of items) {
|
||||
let need = Number(item.quantity);
|
||||
const candidates = pool
|
||||
.map((p, index) => ({ p, index }))
|
||||
.filter(({ p, index }) => left[index] > 0 && itemMatches(item, p.line))
|
||||
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
|
||||
for (const { p, index } of candidates) {
|
||||
if (need === 0) break;
|
||||
const q = Math.min(need, left[index]);
|
||||
left[index] -= q;
|
||||
need -= q;
|
||||
charged += q * p.line.unitPrice;
|
||||
takes.push({ index, quantity: q });
|
||||
}
|
||||
if (need > 0) return null;
|
||||
}
|
||||
return { takes, charged, saving: charged - Number(bundle.price || 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
|
||||
*
|
||||
* @param {Array} bundles - [{ id, name, price, items: [{ eventOptionId, variantId?, quantity }] }]
|
||||
* already filtered to the ones this registration may use
|
||||
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
|
||||
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
|
||||
* `lines` are the input lines with bundled units split out at their share of the
|
||||
* bundle price (flagged `bundled: true`), ready for the coupon calculation.
|
||||
*/
|
||||
function applyBundles(bundles, lines) {
|
||||
const usable = (bundles || []).filter(b => (b.items || []).some(i => Number(i.quantity) > 0) && Number(b.price) >= 0);
|
||||
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
|
||||
const byBundle = new Map();
|
||||
|
||||
// Each round applies the single best-saving bundle once; stops when nothing saves.
|
||||
const maxRounds = pool.reduce((s, p) => s + p.remaining, 0);
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
let best = null;
|
||||
for (const bundle of usable) {
|
||||
const attempt = tryBundle(bundle, pool);
|
||||
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
|
||||
}
|
||||
if (!best) break;
|
||||
const { bundle, attempt } = best;
|
||||
const ratio = attempt.charged > 0 ? Number(bundle.price) / attempt.charged : 0;
|
||||
for (const t of attempt.takes) {
|
||||
pool[t.index].remaining -= t.quantity;
|
||||
pool[t.index].bundled.push({ quantity: t.quantity, ratio });
|
||||
}
|
||||
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
|
||||
entry.count += 1;
|
||||
entry.discount += attempt.saving;
|
||||
byBundle.set(bundle.id, entry);
|
||||
}
|
||||
|
||||
const applications = [...byBundle.values()].map(a => ({ ...a, discount: round2(a.discount) }));
|
||||
const outLines = [];
|
||||
for (const p of pool) {
|
||||
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
|
||||
for (const b of p.bundled) {
|
||||
const unitPrice = p.line.unitPrice * b.ratio;
|
||||
outLines.push({ ...p.line, quantity: b.quantity, unitPrice, basePrice: unitPrice, bundled: true });
|
||||
}
|
||||
}
|
||||
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
|
||||
}
|
||||
|
||||
/** "5 × Adult + 3 × Child (Large)" — needs items[].eventOption / variant names loaded. */
|
||||
function describeBundleItems(bundle) {
|
||||
return (bundle?.items || [])
|
||||
.map(i => `${i.quantity} × ${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`)
|
||||
.join(' + ');
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLE_INCLUDE = {
|
||||
items: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } } } },
|
||||
};
|
||||
|
||||
// A registration's bundle deals, for API responses and receipts.
|
||||
const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, discount: true, bundle: { select: { id: true, name: true } } } };
|
||||
|
||||
/**
|
||||
* Bundles a registration on this event may use: the ones available now, plus any in
|
||||
* `keepIds` (bundles the registration already has).
|
||||
*/
|
||||
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
|
||||
if (!eventId) return [];
|
||||
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && (b.items || []).length > 0));
|
||||
}
|
||||
|
||||
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
|
||||
async function saveBundleApplications(registrationId, existing, applications, db = prisma) {
|
||||
const key = (list) => JSON.stringify([...list].map(a => [a.bundleId, a.count, round2(a.discount)]).sort());
|
||||
if (key(existing || []) === key(applications)) return;
|
||||
await db.registrationBundle.deleteMany({ where: { registrationId } });
|
||||
if (applications.length > 0) {
|
||||
await db.registrationBundle.createMany({
|
||||
data: applications.map(a => ({ registrationId, bundleId: a.bundleId, count: a.count, discount: a.discount })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isBundleAvailable,
|
||||
applyBundles,
|
||||
describeBundleItems,
|
||||
loadBundlesFor,
|
||||
saveBundleApplications,
|
||||
BUNDLE_INCLUDE,
|
||||
BUNDLE_APPS_INCLUDE,
|
||||
};
|
||||
@@ -15,9 +15,12 @@
|
||||
* standard price, and only the part that beats the early-bird price is discounted.
|
||||
* - Restricted codes (users, roles, email domain) need a known account, so guests
|
||||
* can't use them; a match on any one restriction is enough.
|
||||
* - Bundle deals (utils/bundles.js) come off first; the coupon is worked out on what's
|
||||
* left. discountTotal = bundle discount + coupon discounts.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { applyBundles, loadBundlesFor, saveBundleApplications } = require('./bundles');
|
||||
|
||||
function normalizeCode(code) {
|
||||
return String(code || '').trim().toUpperCase();
|
||||
@@ -166,9 +169,15 @@ const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
|
||||
const REGISTRATION_INCLUDE = {
|
||||
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
|
||||
bundleApplications: true,
|
||||
user: { select: { id: true, role: true, email: true } },
|
||||
};
|
||||
|
||||
/** Bundle deals a basket gets before any coupon (nothing saved). */
|
||||
async function previewBundles(eventId, lines, db = prisma) {
|
||||
return applyBundles(await loadBundlesFor(eventId, {}, db), lines);
|
||||
}
|
||||
|
||||
async function findCouponByCode(code, db = prisma) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) return null;
|
||||
@@ -187,14 +196,16 @@ async function countCouponUses(couponId, userId, db = prisma) {
|
||||
|
||||
/**
|
||||
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
|
||||
* would give. Throws a coupon error with a user-facing message when it can't be used.
|
||||
* would give after the basket's bundle deals (returned as `bundles`). Throws a coupon
|
||||
* error with a user-facing message when it can't be used.
|
||||
*/
|
||||
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
|
||||
const coupon = await findCouponByCode(code);
|
||||
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
|
||||
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
const check = validateCoupon(coupon, { eventId, user, lines: bundles.lines, deferRestrictions, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
return { coupon, discount: computeCouponDiscount(coupon, lines) };
|
||||
return { coupon, discount: computeCouponDiscount(coupon, bundles.lines), bundles };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +230,7 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
|
||||
: 'A code has already been applied to this registration.');
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
const { lines } = await registrationBundles(registration, tx);
|
||||
const uses = await countCouponUses(coupon.id, registration.userId, tx);
|
||||
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
@@ -228,31 +239,37 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
|
||||
const redemption = await tx.couponRedemption.create({
|
||||
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
|
||||
});
|
||||
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
|
||||
await recalculateRegistrationDiscount(registrationId, tx);
|
||||
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
|
||||
return { coupon, discount, redemption };
|
||||
});
|
||||
}
|
||||
|
||||
/** A loaded registration's bundle deals (bundles it already has stay usable). */
|
||||
async function registrationBundles(registration, db = prisma) {
|
||||
const bundles = await loadBundlesFor(registration.eventId, { keepIds: (registration.bundleApplications || []).map(a => a.bundleId) }, db);
|
||||
return applyBundles(bundles, buildCouponLines(registration));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-works active discounts after a registration's items change: percentage and comp
|
||||
* codes are recalculated over the new items, fixed codes keep their amount (capped at
|
||||
* what's still in scope). Pass `tx` to run inside the caller's transaction.
|
||||
* Re-works a registration's discounts: bundle deals first, then the coupon on what's
|
||||
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
|
||||
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
|
||||
* coupon's amount (capped the same way): used when only prices changed, e.g. an
|
||||
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
|
||||
* discountTotal. Pass `tx` to run inside the caller's transaction.
|
||||
*/
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
|
||||
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!registration) return 0;
|
||||
if (registration.couponRedemptions.length === 0) {
|
||||
if (registration.discountTotal !== 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
let total = 0;
|
||||
const bundles = await registrationBundles(registration, db);
|
||||
await saveBundleApplications(registrationId, registration.bundleApplications, bundles.applications, db);
|
||||
const lines = bundles.lines;
|
||||
let total = bundles.discount;
|
||||
for (const r of registration.couponRedemptions) {
|
||||
let amount;
|
||||
if (r.coupon.type === 'fixed') {
|
||||
if (r.coupon.type === 'fixed' || keepCouponAmounts) {
|
||||
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
amount = round2(Math.min(r.discountAmount, inScope));
|
||||
} else {
|
||||
@@ -264,21 +281,22 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
total += amount;
|
||||
}
|
||||
total = round2(total);
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
|
||||
/**
|
||||
* Releases a registration's coupon uses so they can be claimed again (cancelled, or the
|
||||
* code removed). Bundle deals stay.
|
||||
*/
|
||||
async function releaseRegistrationCoupons(registrationId, db = prisma) {
|
||||
const released = await db.couponRedemption.updateMany({
|
||||
where: { registrationId, releasedAt: null },
|
||||
data: { releasedAt: new Date() },
|
||||
});
|
||||
if (released.count > 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
if (released.count > 0) await recalculateRegistrationDiscount(registrationId, db);
|
||||
return released.count;
|
||||
}
|
||||
|
||||
@@ -292,6 +310,7 @@ module.exports = {
|
||||
findCouponByCode,
|
||||
countCouponUses,
|
||||
previewCoupon,
|
||||
previewBundles,
|
||||
applyCouponToRegistration,
|
||||
recalculateRegistrationDiscount,
|
||||
releaseRegistrationCoupons,
|
||||
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const { discountLines } = require('./pdfDocs');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -90,7 +90,7 @@ async function loadRegistrationFull(registrationId) {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -137,7 +137,7 @@ async function loadPaymentFull(paymentId) {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -149,8 +149,8 @@ async function loadPaymentFull(paymentId) {
|
||||
|
||||
// ─── Shared building blocks ───────────────────────────────────────────────────
|
||||
|
||||
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
|
||||
function selectionsTable(registrationOptions, couponLine = null) {
|
||||
/** Renders the selections summary table, with each discount (bundle, coupon) as its own row. */
|
||||
function selectionsTable(registrationOptions, discounts = []) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
@@ -164,10 +164,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
|
||||
|
||||
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||
|
||||
if (couponLine) {
|
||||
for (const line of discounts || []) {
|
||||
rows.push(`<tr>
|
||||
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
|
||||
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${line.label}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(line.amount)}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
@@ -181,11 +181,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Plain-text selections, one bullet per item plus the coupon discount. */
|
||||
/** Plain-text selections, one bullet per item plus each discount. */
|
||||
function selectionsText(reg) {
|
||||
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
|
||||
for (const d of discountLines(reg)) lines.push(` • ${d.label} — -${fmtAmount(d.amount)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -317,7 +316,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${selectionsTable(reg.registrationOptions, discountLines(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
@@ -358,7 +357,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${selectionsTable(reg.registrationOptions, discountLines(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
|
||||
@@ -95,16 +95,34 @@ function buildLineItems(registrationOptions) {
|
||||
|
||||
/**
|
||||
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
|
||||
* Registration.discountTotal, which is always present.
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
|
||||
* Registration.discountTotal less the bundle part, both always present.
|
||||
*/
|
||||
function couponDiscountLine(registration) {
|
||||
const amount = Number(registration?.discountTotal || 0);
|
||||
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
|
||||
if (!(amount > 0.001)) return null;
|
||||
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
|
||||
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration's bundle-deal saving, labelled with the bundle names when
|
||||
* `bundleApplications[].bundle.name` is loaded, e.g. "Bundle: Family deal ×2".
|
||||
*/
|
||||
function bundleDiscountLine(registration) {
|
||||
const amount = Number(registration?.bundleDiscount || 0);
|
||||
if (!(amount > 0.001)) return null;
|
||||
const names = (registration.bundleApplications || [])
|
||||
.map(a => a.bundle?.name ? `${a.bundle.name}${a.count > 1 ? ` ×${a.count}` : ''}` : null)
|
||||
.filter(Boolean);
|
||||
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
|
||||
}
|
||||
|
||||
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
|
||||
function discountLines(registration) {
|
||||
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
|
||||
const colDesc = x;
|
||||
@@ -210,13 +228,13 @@ async function generateReceiptPdf(payment) {
|
||||
if (rows.length > 0) {
|
||||
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
|
||||
}
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) {
|
||||
for (const line of discountLines(reg)) {
|
||||
y += 8;
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#059669')
|
||||
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||||
y += 14;
|
||||
.text(`${line.label}: -${fmtAmount(line.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||||
y += 6;
|
||||
}
|
||||
if (discountLines(reg).length > 0) y += 8;
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
@@ -259,7 +277,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
|
||||
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
|
||||
const discount = Math.max(0, fullPriceTotal - chargedTotal);
|
||||
const coupon = couponDiscountLine(registration);
|
||||
const extraDiscounts = discountLines(registration);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
@@ -323,10 +341,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
|
||||
y += (opts.size || 10) + 10;
|
||||
};
|
||||
if (discount > 0.01 || coupon) {
|
||||
if (discount > 0.01 || extraDiscounts.length > 0) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
|
||||
if (discount > 0.01) totalLine(extraDiscounts.length ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
for (const line of extraDiscounts) totalLine(line.label.toUpperCase(), `-${fmtAmount(line.amount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
@@ -369,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
|
||||
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
|
||||
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
|
||||
* never touched.
|
||||
* never touched. When a price changes, bundle deals are re-worked (they depend on the
|
||||
* prices charged) and coupon amounts are kept.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
@@ -269,7 +270,12 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
return false;
|
||||
}));
|
||||
|
||||
return { changed: results.some(Boolean) };
|
||||
const changed = results.some(Boolean);
|
||||
if (changed) {
|
||||
const { recalculateRegistrationDiscount } = require('./coupons');
|
||||
await recalculateRegistrationDiscount(registrationId, prisma, { keepCouponAmounts: true });
|
||||
}
|
||||
return { changed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,11 +47,10 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const coupon = couponDiscountLine(reg);
|
||||
const { discountLines } = require('./pdfDocs');
|
||||
const items = [
|
||||
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
|
||||
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
|
||||
...discountLines(reg).map(d => `- ${d.label} — -${fmtAmount(d.amount)}`),
|
||||
].join('\n');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const { applyBundles, isBundleAvailable } = require('../src/utils/bundles');
|
||||
const { computeCouponDiscount } = require('../src/utils/coupons');
|
||||
|
||||
const line = (eventOptionId, quantity, unitPrice, extra = {}) => ({ eventOptionId, variantId: null, quantity, unitPrice, basePrice: unitPrice, ...extra });
|
||||
const family = { id: 'b1', name: 'Family deal', price: 500, items: [{ eventOptionId: 'adult', quantity: 5 }, { eventOptionId: 'child', quantity: 3 }] };
|
||||
const charged = (lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
describe('applyBundles', () => {
|
||||
test('5 adults + 3 children for R500 instead of R550', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
expect(r.discount).toBe(50);
|
||||
expect(r.applications).toEqual([{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 }]);
|
||||
expect(charged(r.lines)).toBeCloseTo(500, 6);
|
||||
expect(r.lines.every(l => l.bundled)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies as many times as fits, leftovers at full price', () => {
|
||||
const r = applyBundles([family], [line('adult', 11, 80), line('child', 7, 50)]);
|
||||
expect(r.applications[0].count).toBe(2);
|
||||
expect(r.discount).toBe(100);
|
||||
// 1 adult and 1 child left over at full price.
|
||||
expect(charged(r.lines)).toBeCloseTo(1000 + 80 + 50, 6);
|
||||
expect(r.lines.filter(l => !l.bundled).map(l => [l.eventOptionId, l.quantity])).toEqual([['adult', 1], ['child', 1]]);
|
||||
});
|
||||
|
||||
test('nothing applies when the items are not all there', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 2, 50)]);
|
||||
expect(r.discount).toBe(0);
|
||||
expect(r.applications).toEqual([]);
|
||||
expect(charged(r.lines)).toBe(500);
|
||||
});
|
||||
|
||||
test('best price wins: no discount when early-bird prices already beat the bundle', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 60, { basePrice: 80 }), line('child', 3, 40, { basePrice: 50 })]);
|
||||
expect(r.discount).toBe(0);
|
||||
expect(charged(r.lines)).toBe(420);
|
||||
});
|
||||
|
||||
test('the most expensive units go into the bundle first', () => {
|
||||
// Two adults bought at early-bird R60, four at R80: the bundle should take the R80 ones.
|
||||
const r = applyBundles([family], [line('adult', 2, 60), line('adult', 4, 80), line('child', 3, 50)]);
|
||||
// 4×80 + 1×60 + 3×50 = 530 → R30 saving.
|
||||
expect(r.discount).toBe(30);
|
||||
expect(r.lines.find(l => !l.bundled)).toMatchObject({ eventOptionId: 'adult', quantity: 1, unitPrice: 60 });
|
||||
});
|
||||
|
||||
test('when bundles compete, the bigger saving wins', () => {
|
||||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||||
const r = applyBundles([family, pair], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
// Family saves 50 once; pairs save 10 each (two of them = 20), and would block the family deal.
|
||||
expect(r.applications.map(a => a.bundleId)).toEqual(['b1']);
|
||||
expect(r.discount).toBe(50);
|
||||
});
|
||||
|
||||
test('different bundles can stack on different items', () => {
|
||||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||||
const r = applyBundles([family, pair], [line('adult', 7, 80), line('child', 3, 50)]);
|
||||
expect(r.applications).toEqual([
|
||||
{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 },
|
||||
{ bundleId: 'b2', name: 'Pair', count: 1, discount: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a variant-specific item only takes that variant; an any-variant item takes the rest', () => {
|
||||
const shirts = { id: 'b3', name: 'Shirts', price: 300, items: [{ eventOptionId: 'shirt', variantId: 'L', quantity: 1 }, { eventOptionId: 'shirt', quantity: 2 }] };
|
||||
const lines = [
|
||||
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 150, basePrice: 150 },
|
||||
{ eventOptionId: 'shirt', variantId: 'S', quantity: 2, unitPrice: 120, basePrice: 120 },
|
||||
];
|
||||
const r = applyBundles([shirts], lines);
|
||||
expect(r.discount).toBe(90);
|
||||
const noLarge = applyBundles([shirts], [{ ...lines[1], quantity: 3 }]);
|
||||
expect(noLarge.discount).toBe(0);
|
||||
});
|
||||
|
||||
test('a coupon after the bundle works out on the bundle price', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50), line('lunch', 2, 40)]);
|
||||
const tenPercent = { type: 'percent', value: 10, stackEarlyBird: true, options: [] };
|
||||
// (500 + 80) × 10% = 58
|
||||
expect(computeCouponDiscount(tenPercent, r.lines)).toBe(58);
|
||||
// Best-price-wins coupons also see the bundle price as the base, so it still gives 10%.
|
||||
expect(computeCouponDiscount({ ...tenPercent, stackEarlyBird: false }, r.lines)).toBe(58);
|
||||
const childOnly = { ...tenPercent, options: [{ eventOptionId: 'child' }] };
|
||||
// The 3 children cost 150 of 550 → 136.36 of the R500 bundle → 13.64 off.
|
||||
expect(computeCouponDiscount(childOnly, r.lines)).toBe(13.64);
|
||||
});
|
||||
|
||||
test('a free bundle (R0) still works', () => {
|
||||
const r = applyBundles([{ ...family, price: 0 }], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
expect(r.discount).toBe(550);
|
||||
expect(charged(r.lines)).toBe(0);
|
||||
});
|
||||
|
||||
test('bundles with no items are ignored', () => {
|
||||
expect(applyBundles([{ id: 'x', name: 'x', price: 1, items: [] }], [line('adult', 3, 80)]).discount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBundleAvailable', () => {
|
||||
const now = new Date('2026-06-01T10:00:00Z');
|
||||
test('switched off, not started, ended and empty bundles are not available', () => {
|
||||
expect(isBundleAvailable({ ...family, isActive: true }, now)).toBe(true);
|
||||
expect(isBundleAvailable({ ...family, isActive: false }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, validFrom: '2026-06-02' }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, validUntil: '2026-05-31' }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, items: [] }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -24,6 +24,9 @@ const ACTIONS = [
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"bundle_created",
|
||||
"bundle_updated",
|
||||
"bundle_deleted",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
@@ -53,6 +56,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
coupon_updated: "Coupon edited",
|
||||
coupon_disabled: "Coupon turned off",
|
||||
coupon_applied_manual: "Coupon applied/removed by staff",
|
||||
bundle_created: "Bundle deal created",
|
||||
bundle_updated: "Bundle deal edited",
|
||||
bundle_deleted: "Bundle deal deleted",
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
|
||||
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
|
||||
|
||||
@@ -7,12 +7,14 @@ import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -104,6 +106,7 @@ export default function AtTheDoorPage() {
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
||||
const [doorBundles, setDoorBundles] = useState<PublicBundle[]>([]);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
@@ -163,8 +166,10 @@ export default function AtTheDoorPage() {
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
setDoorCollectGender(!!ev.collectGender);
|
||||
setDoorBundles(ev.bundles || []);
|
||||
} catch {
|
||||
setEventOptions([]);
|
||||
setDoorBundles([]);
|
||||
}
|
||||
})();
|
||||
}, [eventId, token]);
|
||||
@@ -394,7 +399,10 @@ export default function AtTheDoorPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<DoorOpen className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||||
@@ -506,6 +514,7 @@ export default function AtTheDoorPage() {
|
||||
setCoupon={setDoorCoupon}
|
||||
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
|
||||
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
|
||||
bundles={doorBundles}
|
||||
names={doorAllowsNames && !pendingEditReg ? {
|
||||
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
@@ -1225,19 +1234,22 @@ type OptionsModalProps = {
|
||||
setCoupon: (c: AppliedCoupon | null) => void;
|
||||
couponForUserId: string | null;
|
||||
existingCode: string | null;
|
||||
bundles: PublicBundle[];
|
||||
// Named-attendee editor settings, or null when the event doesn't take names.
|
||||
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
|
||||
};
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: OptionsModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
|
||||
const basket = Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
});
|
||||
// Bundle deals apply to the whole registration, so this holds for edits too.
|
||||
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const safeOptions: EventOption[] = options || [];
|
||||
|
||||
@@ -1249,7 +1261,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
}, 0);
|
||||
|
||||
const belowPaid = isEdit && total < totalPaid;
|
||||
const afterBundles = bundleQuote ? bundleQuote.total : total;
|
||||
const belowPaid = isEdit && afterBundles < totalPaid;
|
||||
const belowIssued = isEdit && Object.entries(minQuantities).some(
|
||||
([key, minQty]) => minQty > 0 && (quantities[key] || 0) < minQty
|
||||
);
|
||||
@@ -1275,6 +1288,13 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
|
||||
<BundleOffers bundles={bundles} />
|
||||
<BundleBuilder
|
||||
bundles={bundles}
|
||||
options={safeOptions}
|
||||
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
|
||||
/>
|
||||
{safeOptions.map((opt) => {
|
||||
const hasVariants = (opt.variants || []).length > 0;
|
||||
if (hasVariants) {
|
||||
@@ -1335,7 +1355,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<div className="p-4 border-t flex-shrink-0 bg-white">
|
||||
{belowPaid && (
|
||||
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
|
||||
New total (R {afterBundles.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
|
||||
</div>
|
||||
)}
|
||||
{belowIssued && (
|
||||
@@ -1365,7 +1385,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
{coupon && coupon.discount > 0 && !isEdit && (
|
||||
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
|
||||
<BundleSavings applications={bundleQuote?.bundles} className="text-xs" />
|
||||
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : afterBundles).toFixed(2)}</div>
|
||||
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import type { EventBundle } from "@/types";
|
||||
|
||||
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
|
||||
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
|
||||
|
||||
type ItemDraft = { eventOptionId: string; variantId: string | null; quantity: string };
|
||||
type Draft = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
items: ItemDraft[];
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const blankDraft = (options: OptionLite[]): Draft => ({
|
||||
name: "", description: "", price: "",
|
||||
items: options.slice(0, 2).map(o => ({ eventOptionId: o.id, variantId: null, quantity: "1" })),
|
||||
validFrom: "", validUntil: "", isActive: true,
|
||||
});
|
||||
|
||||
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
|
||||
const toLocalInput = (iso: string | null | undefined) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const draftFromBundle = (b: EventBundle): Draft => ({
|
||||
name: b.name, description: b.description || "", price: String(b.price),
|
||||
items: b.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId || null, quantity: String(i.quantity) })),
|
||||
validFrom: toLocalInput(b.validFrom), validUntil: toLocalInput(b.validUntil), isActive: b.isActive,
|
||||
});
|
||||
|
||||
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
|
||||
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
// fieldCls has no width, for fields sized by their row (inputCls is full width).
|
||||
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const inputCls = `w-full ${fieldCls}`;
|
||||
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
|
||||
|
||||
/** Standard price of one unit of an item (variant price, else the option's). */
|
||||
function unitPrice(options: OptionLite[], item: { eventOptionId: string; variantId: string | null }) {
|
||||
const o = options.find(x => x.id === item.eventOptionId);
|
||||
if (!o) return 0;
|
||||
const v = item.variantId ? (o.variants || []).find(x => x.id === item.variantId) : null;
|
||||
return Number(v && v.price !== null && v.price !== undefined ? v.price : o.price) || 0;
|
||||
}
|
||||
|
||||
function bundleStatus(b: EventBundle): { label: string; cls: string } {
|
||||
const now = Date.now();
|
||||
if (!b.isActive) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
|
||||
if (b.validUntil && new Date(b.validUntil).getTime() < now) return { label: "Ended", cls: "bg-gray-100 text-gray-600" };
|
||||
if (b.validFrom && new Date(b.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
|
||||
return { label: "On sale", cls: "bg-green-100 text-green-800" };
|
||||
}
|
||||
|
||||
export default function BundlesPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [bundles, setBundles] = useState<EventBundle[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(list => setAllEvents(list || []))
|
||||
.catch(e => setError(errorMessage(e) || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
|
||||
const events = useMemo(() => {
|
||||
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
return showPast ? [...upcoming, ...past] : upcoming;
|
||||
}, [allEvents, showPast]);
|
||||
|
||||
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
|
||||
useEffect(() => {
|
||||
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
|
||||
}, [events, eventId]);
|
||||
|
||||
const event = events.find(e => e.id === eventId) || null;
|
||||
const options = event?.eventOptions || [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) { setBundles([]); return; }
|
||||
setFetching(true);
|
||||
try {
|
||||
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load bundles");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const toggleActive = async (b: EventBundle) => {
|
||||
try {
|
||||
await apiFetch(`/api/bundles/${b.id}`, { method: "PUT", authToken: token!, body: { isActive: !b.isActive } });
|
||||
setInfo(`${b.name} turned ${b.isActive ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update bundle");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (b: EventBundle) => {
|
||||
if (!window.confirm(`Delete "${b.name}"?`)) return;
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/bundles/${b.id}`, { method: "DELETE", authToken: token! });
|
||||
setInfo(res?.message || "Bundle deleted");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete bundle");
|
||||
}
|
||||
};
|
||||
|
||||
const startNew = () => setEditing({ id: null, draft: blankDraft(options) });
|
||||
const canAdd = !!event && options.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Gift className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Bundle deals</h1>
|
||||
<p className="text-sm text-gray-500">Tickets sold together for one price</p>
|
||||
</div>
|
||||
</div>
|
||||
{canView && (
|
||||
<button
|
||||
onClick={startNew}
|
||||
disabled={!canAdd}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New bundle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage bundle deals.</div>
|
||||
)}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
|
||||
<div className="w-full sm:w-auto">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{event && options.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-6 text-center">This event has no tickets yet. Add ticket options to the event first.</p>
|
||||
)}
|
||||
|
||||
{event && options.length > 0 && !fetching && bundles.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-50 flex items-center justify-center mx-auto mb-3">
|
||||
<Gift className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">No bundle deals for this event yet</p>
|
||||
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. "Family deal: 2 adults + 3 kids for R220". Buyers get it automatically when their tickets match.</p>
|
||||
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
<Plus className="w-4 h-4" /> Create a bundle
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundles.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{bundles.map(b => (
|
||||
<BundleCard
|
||||
key={b.id}
|
||||
bundle={b}
|
||||
onEdit={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}
|
||||
onToggle={() => toggleActive(b)}
|
||||
onDelete={() => remove(b)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Bundles apply automatically, as many times as someone's tickets allow, and only when cheaper than the tickets at the prices
|
||||
actually charged (early-bird and staff prices included). Coupon codes come off afterwards.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && event && (
|
||||
<BundleModal
|
||||
token={token!}
|
||||
event={event}
|
||||
bundleId={editing.id}
|
||||
initial={editing.draft}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
|
||||
const status = bundleStatus(b);
|
||||
const save = b.standardValue - b.price;
|
||||
const dates = b.validFrom || b.validUntil
|
||||
? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end date"}`
|
||||
: null;
|
||||
return (
|
||||
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${b.isActive ? "bg-white" : "bg-gray-50"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900 break-words">{b.name}</div>
|
||||
{b.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{b.description}</div>}
|
||||
</div>
|
||||
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{b.items.map(i => (
|
||||
<span key={i.id || `${i.eventOptionId}-${i.variantId}`} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-100 px-2.5 py-1 text-xs">
|
||||
<span className="font-semibold">{i.quantity} ×</span> {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span className="text-xl font-semibold text-gray-900">{fmtRand(b.price)}</span>
|
||||
<span className="text-sm text-gray-400 line-through">{fmtRand(b.standardValue)}</span>
|
||||
<span className={`text-xs font-medium ${save > 0 ? "text-green-700" : "text-amber-700"}`}>
|
||||
{save > 0 ? `saves ${fmtRand(save)}` : "no saving at the standard price"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 space-y-0.5">
|
||||
<div>
|
||||
Used <strong>{b.uses || 0}×</strong> on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given
|
||||
</div>
|
||||
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5" /> {dates}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
|
||||
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 ${b.isActive ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
|
||||
<Power className="w-3.5 h-3.5" /> {b.isActive ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
<button onClick={onDelete} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border text-red-700 border-red-200 hover:bg-red-50 mt-2 sm:ml-auto">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
|
||||
token: string;
|
||||
event: EventLite;
|
||||
bundleId: string | null;
|
||||
initial: Draft;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
|
||||
const options = event.eventOptions || [];
|
||||
|
||||
const setItem = (i: number, patch: Partial<ItemDraft>) => set("items", d.items.map((it, j) => (j === i ? { ...it, ...patch } : it)));
|
||||
const standardValue = d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0);
|
||||
const price = Number(d.price) || 0;
|
||||
const saving_ = standardValue - price;
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: d.name.trim(),
|
||||
description: d.description.trim() || null,
|
||||
price: Number(d.price),
|
||||
items: d.items.map(it => ({ eventOptionId: it.eventOptionId, variantId: it.variantId, quantity: Number(it.quantity) })),
|
||||
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
|
||||
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
|
||||
isActive: d.isActive,
|
||||
};
|
||||
if (bundleId) {
|
||||
await apiFetch(`/api/bundles/${bundleId}`, { method: "PUT", authToken: token, body });
|
||||
onSaved(`${body.name} saved.`);
|
||||
} else {
|
||||
await apiFetch(`/api/bundles/event/${event.id}`, { method: "POST", authToken: token, body });
|
||||
onSaved(`${body.name} created.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
|
||||
<form onSubmit={save} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-3 p-4 border-b">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"}</h2>
|
||||
<p className="text-xs text-gray-500 truncate">{event.title}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5 overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input className={inputCls} value={d.name} onChange={e => set("name", e.target.value)} placeholder="Family deal" required maxLength={80} />
|
||||
<p className="text-xs text-gray-500 mt-1">Shown to buyers and on receipts.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Bring the whole family" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Tickets in the bundle</label>
|
||||
<div className="space-y-2">
|
||||
{d.items.map((it, i) => {
|
||||
const opt = options.find(o => o.id === it.eventOptionId);
|
||||
const hasVariants = (opt?.variants || []).length > 0;
|
||||
return (
|
||||
<div key={i} className="rounded-lg border bg-gray-50 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" min="1" step="1" inputMode="numeric" className={`${fieldCls} w-16 shrink-0 px-2 bg-white text-center`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
|
||||
<span className="text-sm text-gray-500 shrink-0">×</span>
|
||||
<select className={`${fieldCls} min-w-0 flex-1 bg-white`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })} aria-label="Ticket">
|
||||
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
|
||||
className="p-2 shrink-0 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 disabled:opacity-30 disabled:hover:bg-transparent" aria-label="Remove ticket">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{hasVariants && (
|
||||
<select className={`${inputCls} mt-2 bg-white`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
|
||||
<option value="">Any {opt?.name.toLowerCase()} variant</option>
|
||||
{(opt?.variants || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="button" onClick={() => set("items", [...d.items, { eventOptionId: options[0]?.id || "", variantId: null, quantity: "1" }])}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline">
|
||||
<Plus className="w-4 h-4" /> Add a ticket type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bundle price (R)</label>
|
||||
<input type="number" min="0" step="0.01" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">Separately</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{fmtRand(standardValue)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">Bundle</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{d.price === "" ? "—" : fmtRand(price)}</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-2 ${d.price !== "" && saving_ <= 0 ? "bg-amber-50" : "bg-emerald-50"}`}>
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">Saving</div>
|
||||
<div className={`text-sm font-semibold ${d.price !== "" && saving_ <= 0 ? "text-amber-700" : "text-emerald-700"}`}>{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}</div>
|
||||
</div>
|
||||
</div>
|
||||
{d.price !== "" && saving_ <= 0 && <p className="text-xs text-amber-700 mt-1">The bundle isn't cheaper than the standard prices, so it won't apply unless prices change.</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Available from <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Available until <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} />
|
||||
<span>On sale <span className="block text-xs text-gray-500">Registrations that already have this bundle keep it if you turn it off later.</span></span>
|
||||
</label>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{saving ? "Saving…" : bundleId ? "Save changes" : "Create bundle"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, RegistrationOption, User } from "@/types";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
@@ -59,6 +60,7 @@ export default function ManualRegistrationPage() {
|
||||
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
const [bundles, setBundles] = useState<PublicBundle[]>([]);
|
||||
|
||||
// User search state
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
@@ -266,6 +268,16 @@ export default function ManualRegistrationPage() {
|
||||
}
|
||||
}, [selectedEventId, events]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setBundles([]); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
|
||||
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setBundles([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
const totalDue = useMemo(() => {
|
||||
return options.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
@@ -275,6 +287,13 @@ export default function ManualRegistrationPage() {
|
||||
}, 0);
|
||||
}, [options, quantities]);
|
||||
|
||||
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
}), [quantities]);
|
||||
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
@@ -527,6 +546,7 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
|
||||
{options.length > 0 && <BundleOffers bundles={bundles} className="mb-3" />}
|
||||
{options.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">Select an event to view options.</div>
|
||||
) : (
|
||||
@@ -590,6 +610,13 @@ export default function ManualRegistrationPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<BundleBuilder
|
||||
className="mt-3"
|
||||
bundles={bundles}
|
||||
options={options}
|
||||
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedEventAllowsNames && (
|
||||
@@ -614,10 +641,7 @@ export default function ManualRegistrationPage() {
|
||||
<div className="mt-6 max-w-md">
|
||||
<CouponCodeField
|
||||
eventId={selectedEventId}
|
||||
basket={Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
})}
|
||||
basket={basket}
|
||||
token={token}
|
||||
forUserId={pickedUserId}
|
||||
applied={coupon}
|
||||
@@ -628,8 +652,9 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="text-sm">
|
||||
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
|
||||
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
|
||||
<BundleSavings applications={bundleQuote?.bundles} />
|
||||
Total due: <span className="font-semibold">R {payable.toFixed(2)}</span>
|
||||
{payable < totalDue - 0.005 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
|
||||
</div>
|
||||
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TransferTicketModal, canOfferTransfer } from "@/components/shared/Trans
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { BundleSavings } from "@/components/shared/BundleDeals";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
// Helper formatters
|
||||
@@ -1096,9 +1097,10 @@ export default function UserDashboardPage() {
|
||||
<div>
|
||||
{renderGroup('Early bird', earlyBirdRows)}
|
||||
{renderGroup('Standard price', standardRows)}
|
||||
{Number(activeReg.discountTotal || 0) > 0 && (
|
||||
<BundleSavings applications={activeReg.bundleApplications} className="mt-2" />
|
||||
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
|
||||
<div className="text-sm text-green-700 mt-2">
|
||||
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
|
||||
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
|
||||
</div>
|
||||
)}
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
|
||||
|
||||
@@ -45,11 +45,14 @@ type Event = {
|
||||
location?: string | null;
|
||||
waitlistEnabled?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
import type { PublicBundle } from "@/types";
|
||||
|
||||
// Tiered low-stock threshold: larger events use a smaller percentage.
|
||||
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
|
||||
@@ -262,6 +265,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BundleOffers bundles={event.bundles} />
|
||||
{options.map((opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import type { PublicBundle } from "@/types";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
@@ -49,6 +51,7 @@ export type RegisterEvent = {
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
};
|
||||
|
||||
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
|
||||
@@ -359,8 +362,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
}, [event]);
|
||||
|
||||
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// The server prices the basket (staff price, tiers) when checking a code, so its total wins.
|
||||
const payable = coupon ? coupon.total : total;
|
||||
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
|
||||
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
|
||||
// a code, so its total wins.
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
|
||||
|
||||
function buildItems() {
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
|
||||
@@ -544,6 +549,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{(event.eventOptions || []).map((opt) => (
|
||||
<OptionCard
|
||||
@@ -556,6 +563,16 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!eventSoldOut && (
|
||||
<BundleBuilder
|
||||
className="mt-3"
|
||||
bundles={event.bundles}
|
||||
options={event.eventOptions || []}
|
||||
qty={qtyFor}
|
||||
onSetQuantity={(optionId, variantId, quantity) => handleQtyChange(variantId ? `${optionId}:${variantId}` : optionId, quantity)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
// Collect all upcoming early-bird tiers across all options
|
||||
const now = new Date();
|
||||
@@ -599,11 +616,12 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
{(bundleQuote || (coupon && coupon.discount > 0)) && (
|
||||
<div className="text-sm text-gray-500 line-through">R{(coupon?.subtotal ?? bundleQuote?.subtotal ?? total).toFixed(2)}</div>
|
||||
)}
|
||||
<BundleSavings applications={bundleQuote?.bundles} />
|
||||
{coupon && coupon.discount > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
|
||||
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-green-700">Code {coupon.code} −R{coupon.discount.toFixed(2)}</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import type { PublicBundle, Registration } from "@/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -143,6 +144,7 @@ export default function SelfServicePage() {
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [kioskBundles, setKioskBundles] = useState<PublicBundle[]>([]);
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
@@ -217,6 +219,22 @@ export default function SelfServicePage() {
|
||||
}, 0);
|
||||
}, [selectedEvent, quantities]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setKioskBundles([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, supervisorToken]);
|
||||
|
||||
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
}), [quantities]);
|
||||
const bundleQuote = useBundleQuote({ eventId: selectedEventId || "", basket: kioskBasket, token: supervisorToken, forUserId: matchedAccount?.id ?? null });
|
||||
|
||||
// Does the selected event have a required form?
|
||||
const eventHasRequiredForm = useMemo(() => {
|
||||
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement'));
|
||||
@@ -939,6 +957,7 @@ export default function SelfServicePage() {
|
||||
{(selectedEvent.eventOptions || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
|
||||
<BundleOffers bundles={kioskBundles} className="mb-3" />
|
||||
<div className="space-y-3">
|
||||
{(selectedEvent.eventOptions || []).map((opt) => {
|
||||
const hasVariants = (opt.variants || []).length > 0;
|
||||
@@ -1014,6 +1033,13 @@ export default function SelfServicePage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<BundleBuilder
|
||||
className="mt-3"
|
||||
bundles={kioskBundles}
|
||||
options={selectedEvent.eventOptions || []}
|
||||
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
|
||||
/>
|
||||
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
|
||||
<div className="mt-4">
|
||||
<AttendeeNamesEditor
|
||||
@@ -1034,10 +1060,7 @@ export default function SelfServicePage() {
|
||||
<div className="mt-4">
|
||||
<CouponCodeField
|
||||
eventId={selectedEventId}
|
||||
basket={Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
})}
|
||||
basket={kioskBasket}
|
||||
token={supervisorToken}
|
||||
forUserId={matchedAccount?.id ?? null}
|
||||
applied={kioskCoupon}
|
||||
@@ -1047,10 +1070,11 @@ export default function SelfServicePage() {
|
||||
)}
|
||||
{total > 0 && (
|
||||
<div className="mt-3 text-right text-base font-semibold text-gray-800">
|
||||
{kioskCoupon && kioskCoupon.discount > 0 && (
|
||||
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
|
||||
<BundleSavings applications={bundleQuote?.bundles} className="flex flex-col items-end font-normal" />
|
||||
{((kioskCoupon && kioskCoupon.discount > 0) || bundleQuote) && (
|
||||
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon?.subtotal ?? bundleQuote?.subtotal ?? total)}</span>
|
||||
)}
|
||||
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
|
||||
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : bundleQuote ? bundleQuote.total : total)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const REPORTS = [
|
||||
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
|
||||
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
|
||||
@@ -95,9 +95,12 @@ type AuditRow = EventCashup & {
|
||||
_previousCloseAt?: string;
|
||||
};
|
||||
|
||||
// GET /api/coupons/redemptions — active coupon uses, with a readable summary of the code.
|
||||
// GET /api/coupons/redemptions — active coupon uses and bundle deals, with a readable summary.
|
||||
// For a bundle, coupon.code is the bundle name and uses is how many times it applied.
|
||||
type DiscountRow = {
|
||||
id: string;
|
||||
kind?: "coupon" | "bundle";
|
||||
uses?: number;
|
||||
discountAmount: number;
|
||||
createdAt: string;
|
||||
summary?: string;
|
||||
@@ -411,12 +414,14 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
|
||||
// Discounts report: one row per code per event.
|
||||
const discountSummary = useMemo(() => {
|
||||
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
|
||||
const byKey = new Map<string, { code: string; isBundle: boolean; summary: string; event: string; uses: number; total: number }>();
|
||||
for (const r of discountRows) {
|
||||
const event = r.registration?.event?.title || "—";
|
||||
const key = `${r.coupon?.code}__${event}`;
|
||||
const row = byKey.get(key) || { code: r.coupon?.code || "?", summary: r.summary || "", event, uses: 0, total: 0 };
|
||||
row.uses += 1;
|
||||
const isBundle = r.kind === "bundle";
|
||||
const code = isBundle ? `Bundle: ${r.coupon?.code || "?"}` : r.coupon?.code || "?";
|
||||
const key = `${code}__${event}`;
|
||||
const row = byKey.get(key) || { code, isBundle, summary: r.summary || "", event, uses: 0, total: 0 };
|
||||
row.uses += r.uses ?? 1;
|
||||
row.total += Number(r.discountAmount || 0);
|
||||
byKey.set(key, row);
|
||||
}
|
||||
@@ -1151,16 +1156,16 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
}
|
||||
if (report === "discounts") {
|
||||
return {
|
||||
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
|
||||
title: 'Discounts (codes and bundles)', subtitle, kind: 'table', orientation: 'portrait',
|
||||
stats: [
|
||||
{ label: 'Value given away', value: `R ${discountSummary.totalValue.toFixed(2)}`, tone: 'rose' },
|
||||
{ label: 'Uses', value: String(discountSummary.totalUses), tone: 'violet' },
|
||||
{ label: 'Codes used', value: String(discountSummary.codes), tone: 'blue' },
|
||||
{ label: 'Codes & bundles used', value: String(discountSummary.codes), tone: 'blue' },
|
||||
],
|
||||
chart: { title: 'Value given away, by code', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
|
||||
note: 'Counts codes on registrations that are not cancelled. Cancelling a registration frees its code.',
|
||||
chart: { title: 'Value given away, by code or bundle', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
|
||||
note: 'Counts codes and bundle deals on registrations that are not cancelled. Cancelling a registration frees its code. Uses for a bundle are how many times it applied.',
|
||||
table: {
|
||||
columns: ["Code", "Discount", "Event", "Uses", "Value (R)"],
|
||||
columns: ["Code / bundle", "Discount", "Event", "Uses", "Value (R)"],
|
||||
rows: [
|
||||
...discountSummary.rows.map(r => [r.code, r.summary, r.event, r.uses, Number(r.total.toFixed(2))]),
|
||||
["Total", "", "", discountSummary.totalUses, Number(discountSummary.totalValue.toFixed(2))],
|
||||
@@ -1219,7 +1224,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
// as profit" — no need to repeat it here.
|
||||
rows.push([title, "Sales", "What was sold", ""]);
|
||||
(f.salesByOption || []).forEach((s) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
|
||||
if ((f.discountsTotal ?? 0) > 0) rows.push([title, "", "Less discount codes", -Number((f.discountsTotal ?? 0).toFixed(2))]);
|
||||
if ((f.discountsTotal ?? 0) > 0) rows.push([title, "", "Less discounts (codes & bundles)", -Number((f.discountsTotal ?? 0).toFixed(2))]);
|
||||
rows.push([title, "Paid by", "", ""]);
|
||||
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
|
||||
rows.push([title, "Cashup comparison", "", ""]);
|
||||
@@ -1943,23 +1948,23 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
{report === "discounts" && (
|
||||
<div>
|
||||
{discountSummary.rows.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
|
||||
<div className="text-sm text-gray-500">No discount codes or bundle deals used for the selected events and dates.</div>
|
||||
) : (
|
||||
<>
|
||||
<StatTileRow>
|
||||
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
|
||||
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
|
||||
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
|
||||
<StatTile icon={Gift} label="Codes & bundles used" value={String(discountSummary.codes)} tone="blue" />
|
||||
</StatTileRow>
|
||||
<div className="mt-4 border border-gray-100 rounded-xl p-4">
|
||||
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
|
||||
<div className="text-xs text-gray-500 mb-3">Value given away, by code or bundle</div>
|
||||
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
|
||||
</div>
|
||||
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
|
||||
<table className="min-w-[640px] w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
|
||||
<th className="py-2 px-3">Code</th>
|
||||
<th className="py-2 px-3">Code / bundle</th>
|
||||
<th className="py-2 px-3">Discount</th>
|
||||
<th className="py-2 px-3">Event</th>
|
||||
<th className="py-2 px-3 text-right">Uses</th>
|
||||
@@ -1969,7 +1974,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
<tbody>
|
||||
{discountSummary.rows.map(row => (
|
||||
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
|
||||
<td className="py-2 px-3 font-mono">{row.code}</td>
|
||||
<td className={`py-2 px-3 ${row.isBundle ? "" : "font-mono"}`}>{row.code}</td>
|
||||
<td className="py-2 px-3">{row.summary}</td>
|
||||
<td className="py-2 px-3">{row.event}</td>
|
||||
<td className="py-2 px-3 text-right">{row.uses}</td>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Gift } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { BundleItem, BundleQuote, PublicBundle } from "@/types";
|
||||
import type { BasketItem } from "@/components/shared/CouponCodeField";
|
||||
|
||||
const fmt = (n: number) => `R${n.toFixed(2)}`;
|
||||
|
||||
/**
|
||||
* The bundle deals a basket gets, from POST /api/bundles/quote (the server prices the
|
||||
* basket the same way registration will). Null while there's nothing to show. Pass
|
||||
* `enabled={false}` when the event has no bundles to skip the request.
|
||||
*
|
||||
* Staff pricing on someone's behalf pass `forUserId` (the attendee), or `null` for a
|
||||
* walk-in with no account yet.
|
||||
*/
|
||||
export function useBundleQuote({ eventId, basket, token, forUserId, enabled = true }: {
|
||||
eventId: string;
|
||||
basket: BasketItem[];
|
||||
token?: string | null;
|
||||
forUserId?: string | null;
|
||||
enabled?: boolean;
|
||||
}): BundleQuote | null {
|
||||
const [quote, setQuote] = useState<BundleQuote | null>(null);
|
||||
const items = basket.filter(b => b.quantity > 0);
|
||||
const key = JSON.stringify(items);
|
||||
|
||||
useEffect(() => {
|
||||
const options = JSON.parse(key) as BasketItem[];
|
||||
if (!enabled || !eventId || options.length === 0) { setQuote(null); return; }
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const body: Record<string, unknown> = { eventId, options };
|
||||
if (forUserId !== undefined) body.forUserId = forUserId;
|
||||
const res = await apiFetch<BundleQuote>("/api/bundles/quote", { method: "POST", authToken: token || undefined, body });
|
||||
if (!cancelled) setQuote(res.bundleDiscount > 0 ? res : null);
|
||||
} catch {
|
||||
if (!cancelled) setQuote(null);
|
||||
}
|
||||
}, 300);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, [eventId, key, token, forUserId, enabled]);
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** The event's deals, so buyers know what to pick. Applied automatically. */
|
||||
export function BundleOffers({ bundles, className = "" }: { bundles?: PublicBundle[] | null; className?: string }) {
|
||||
if (!bundles || bundles.length === 0) return null;
|
||||
return (
|
||||
<div className={`rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900 ${className}`}>
|
||||
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4" /> Bundle deals</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{bundles.map(b => {
|
||||
const save = b.standardValue - b.price;
|
||||
return (
|
||||
<li key={b.id}>
|
||||
<span className="font-medium">{b.name}:</span> {b.summary} for <span className="font-semibold">{fmt(b.price)}</span>
|
||||
{save > 0.005 && <span className="text-emerald-700"> (save {fmt(save)})</span>}
|
||||
{b.description && <span className="block text-xs text-emerald-800/80">{b.description}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-xs text-emerald-800/80">Picked automatically when your tickets match, as many times as they fit.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Family deal ×2 −R100.00" lines for a quote or a saved registration's bundles. */
|
||||
export function BundleSavings({ applications, className = "" }: {
|
||||
applications?: { bundleId: string; name?: string; count: number; discount: number; bundle?: { name: string } | null }[] | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const rows = (applications || []).filter(a => a.discount > 0);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className={`text-sm text-emerald-700 ${className}`}>
|
||||
{rows.map(a => (
|
||||
<div key={a.bundleId} className="flex items-center gap-1.5">
|
||||
<Gift className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{a.name || a.bundle?.name || "Bundle deal"}{a.count > 1 ? ` ×${a.count}` : ""}</span>
|
||||
<span className="font-medium">−{fmt(a.discount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Just what the builder reads, so pages with their own option types can pass them in.
|
||||
type BuilderOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
stockLimit?: number | null;
|
||||
availableCount?: number | null;
|
||||
variants?: { id: string; name: string; stockLimit?: number | null; availableCount?: number | null }[] | null;
|
||||
};
|
||||
|
||||
/** Tickets of this item already in the basket. An "any variant" item counts every variant. */
|
||||
function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: (optionId: string, variantId: string | null) => number) {
|
||||
if (!opt) return 0;
|
||||
if (item.variantId) return qty(opt.id, item.variantId);
|
||||
const variants = opt.variants || [];
|
||||
return variants.length > 0 ? variants.reduce((s, v) => s + qty(opt.id, v.id), 0) : qty(opt.id, null);
|
||||
}
|
||||
|
||||
const leftOf = (stockLimit?: number | null, availableCount?: number | null) => ((stockLimit ?? 0) > 0 && availableCount !== undefined && availableCount !== null ? availableCount : Infinity);
|
||||
|
||||
/**
|
||||
* Quantities that make up `sets` full bundles, or null when there aren't enough tickets
|
||||
* left. Only ever raises quantities. An "any variant" item tops up the variant already
|
||||
* picked most (or the first one with places left).
|
||||
*/
|
||||
function quantitiesForSets(bundle: PublicBundle, options: BuilderOption[], qty: (o: string, v: string | null) => number, sets: number) {
|
||||
const changes: { optionId: string; variantId: string | null; quantity: number }[] = [];
|
||||
for (const item of bundle.items) {
|
||||
const opt = options.find(o => o.id === item.eventOptionId);
|
||||
if (!opt) return null;
|
||||
const short = sets * item.quantity - itemHave(item, opt, qty);
|
||||
if (short <= 0) continue;
|
||||
let variantId = item.variantId || null;
|
||||
if (!variantId && (opt.variants || []).length > 0) {
|
||||
const withRoom = (opt.variants || []).filter(v => leftOf(v.stockLimit, v.availableCount) - qty(opt.id, v.id) >= short);
|
||||
const pick = withRoom.sort((a, b) => qty(opt.id, b.id) - qty(opt.id, a.id))[0];
|
||||
if (!pick) return null;
|
||||
variantId = pick.id;
|
||||
}
|
||||
const current = qty(opt.id, variantId);
|
||||
const variant = variantId ? (opt.variants || []).find(v => v.id === variantId) : null;
|
||||
if (current + short > Math.min(leftOf(opt.stockLimit, opt.availableCount), variant ? leftOf(variant.stockLimit, variant.availableCount) : Infinity)) return null;
|
||||
changes.push({ optionId: opt.id, variantId, quantity: current + short });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* On the register form: each bundle the basket has started on, with what's missing and a
|
||||
* "Complete bundle" button, then "+" to add another full set. Buttons set the ticket
|
||||
* quantities through `onSetQuantity`.
|
||||
*/
|
||||
export function BundleBuilder({ bundles, options, qty, onSetQuantity, className = "" }: {
|
||||
bundles?: PublicBundle[] | null;
|
||||
options: BuilderOption[];
|
||||
qty: (optionId: string, variantId: string | null) => number;
|
||||
onSetQuantity: (optionId: string, variantId: string | null, quantity: number) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const rows = (bundles || []).map(bundle => {
|
||||
const have = bundle.items.map(item => ({ item, opt: options.find(o => o.id === item.eventOptionId), n: itemHave(item, options.find(o => o.id === item.eventOptionId), qty) }));
|
||||
const sets = Math.min(...have.map(h => Math.floor(h.n / h.item.quantity)));
|
||||
const touched = have.some(h => h.n > 0);
|
||||
// Tickets beyond the full sets mean the next set has been started.
|
||||
const partial = have.some(h => h.n > sets * h.item.quantity);
|
||||
const missing = partial ? have.filter(h => h.n < (sets + 1) * h.item.quantity).map(h => ({ ...h, short: (sets + 1) * h.item.quantity - h.n })) : [];
|
||||
const next = quantitiesForSets(bundle, options, qty, sets + 1);
|
||||
return { bundle, touched, sets, partial, missing, next };
|
||||
}).filter(r => r.touched);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const apply = (changes: NonNullable<ReturnType<typeof quantitiesForSets>>) => changes.forEach(c => onSetQuantity(c.optionId, c.variantId, c.quantity));
|
||||
const itemName = (item: BundleItem, opt?: BuilderOption) => {
|
||||
const v = item.variantId ? (opt?.variants || []).find(x => x.id === item.variantId) : null;
|
||||
return `${opt?.name || "ticket"}${v ? ` (${v.name})` : ""}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{rows.map(({ bundle, sets, partial, missing, next }) => {
|
||||
const save = bundle.standardValue - bundle.price;
|
||||
return (
|
||||
<div key={bundle.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4 shrink-0" /> {bundle.name}</div>
|
||||
<div className="text-xs text-emerald-800">
|
||||
{bundle.summary} for {fmt(bundle.price)}{save > 0.005 ? ` (save ${fmt(save)})` : ""}
|
||||
</div>
|
||||
{partial ? (
|
||||
<div className="text-xs text-emerald-800 mt-0.5">
|
||||
Add {missing.map(m => `${m.short} × ${itemName(m.item, m.opt)}`).join(" and ")} to {sets > 0 ? "complete another bundle" : "complete it"}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs font-medium text-emerald-700 mt-0.5">✓ {sets > 1 ? `${sets} bundles` : "Bundle"} in your basket</div>
|
||||
)}
|
||||
{!next && <div className="text-xs text-amber-700 mt-0.5">Not enough tickets left for {partial ? "this bundle" : "another one"}.</div>}
|
||||
</div>
|
||||
{partial ? (
|
||||
<button type="button" onClick={() => next && apply(next)} disabled={!next}
|
||||
className="shrink-0 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50">
|
||||
Complete bundle
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs font-medium">×{sets}</span>
|
||||
<button type="button" onClick={() => next && apply(next)} disabled={!next} aria-label={`Add another ${bundle.name}`} title="Add another bundle"
|
||||
className="h-7 w-7 rounded border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-100 disabled:opacity-50">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
|
||||
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
|
||||
import { supervisorFormsHelpContent } from "./supervisor-forms";
|
||||
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorBundlesHelpContent } from "./supervisor-bundles";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
@@ -182,6 +183,11 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/coupons",
|
||||
content: supervisorCouponsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-bundles",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/bundles",
|
||||
content: supervisorBundlesHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-forms",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/forms" || pathname === "/dashboard/admin/forms",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Gift, Plus, Layers, BadgePercent, CalendarClock, TicketPercent, Repeat, BarChart2, Lock } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/bundles. */
|
||||
export const supervisorBundlesHelpContent: HelpContent = {
|
||||
title: "Bundle deals",
|
||||
subtitle: "Sell a set of tickets together for one price.",
|
||||
tabs: [
|
||||
{
|
||||
key: "create",
|
||||
label: "Creating bundles",
|
||||
icon: Plus,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Gift} title="What's in it" tone="brand">
|
||||
Pick the event, then add each ticket type and how many, e.g. 5 × Adult + 3 × Child, and the price for the lot. For options with variants (sizes, room types) choose one variant or "Any variant".
|
||||
</GuideItem>
|
||||
<GuideItem icon={BadgePercent} title="The saving" tone="green">
|
||||
The form shows what the tickets cost separately at the standard price and how much the bundle saves. Buyers see the deal on the event page and when they register.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarClock} title="Dates" tone="amber">
|
||||
Optional "Available from" and "Available until" dates, e.g. for a launch-week deal. "Turn off" stops it straight away.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "how",
|
||||
label: "How bundles work",
|
||||
icon: Layers,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Repeat} title="Automatic, as many times as fits" tone="brand">
|
||||
No code needed. 10 adults + 6 children gets the 5 + 3 deal twice; anything left over is charged as usual. This works the same online, for manual registrations, at the door and on the kiosk.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BadgePercent} title="Best price wins" tone="green">
|
||||
A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included, so it never makes anything dearer. If an event has several bundles, the one that saves the most is used first.
|
||||
</GuideItem>
|
||||
<GuideItem icon={TicketPercent} title="Coupon codes" tone="violet">
|
||||
A code still works with a bundle: the bundle comes off first, then the code is worked out on what's left.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Lock} title="Changes later" tone="amber">
|
||||
Registrations that already have a bundle keep it, even after it's turned off or its dates pass. It's worked out again only when the registration's own tickets change. A bundle that has been used can't be deleted; deleting it turns it off instead.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tracking",
|
||||
label: "Tracking",
|
||||
icon: BarChart2,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Each bundle shows how many times it's been used, on how many registrations, and the total saving given. The saving shows as its own line on invoices, receipts, emails and the payment page.</p>
|
||||
<p>The <strong>Discounts</strong> report (Reports → Financial) lists bundles next to coupon codes, and the Finance report shows sales after discounts.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -141,6 +141,8 @@ export type Event = {
|
||||
eventOptions?: EventOption[];
|
||||
attachments?: EventAttachment[];
|
||||
form?: EventForm | null;
|
||||
// Bundle deals on sale now (single-event responses only).
|
||||
bundles?: PublicBundle[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
cashupStatus?: "open" | "closed";
|
||||
@@ -150,6 +152,39 @@ export type Event = {
|
||||
reopenedById?: string | null;
|
||||
};
|
||||
|
||||
export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null };
|
||||
|
||||
// A bundle deal as shown to buyers on the event page.
|
||||
export type PublicBundle = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price: number;
|
||||
validUntil?: string | null;
|
||||
summary: string;
|
||||
// What the items cost separately at the standard price.
|
||||
standardValue: number;
|
||||
items: BundleItem[];
|
||||
};
|
||||
|
||||
// A bundle deal as supervisors manage it.
|
||||
export type EventBundle = PublicBundle & {
|
||||
eventId: string;
|
||||
isActive: boolean;
|
||||
validFrom?: string | null;
|
||||
order: number;
|
||||
available: boolean;
|
||||
uses?: number;
|
||||
registrations?: number;
|
||||
totalDiscount?: number;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
// One bundle applied to a basket or registration.
|
||||
export type BundleApplication = { id?: string; bundleId: string; name?: string; count: number; discount: number; bundle?: { id: string; name: string } | null };
|
||||
|
||||
export type BundleQuote = { subtotal: number; bundleDiscount: number; bundles: BundleApplication[]; total: number };
|
||||
|
||||
export type EventCostType = "once_off" | "per_item";
|
||||
export type CashupMethod = "cash" | "card" | "eft" | "other";
|
||||
|
||||
@@ -301,7 +336,7 @@ export type EventFinancials = {
|
||||
totalWriteOffs: number;
|
||||
pendingOutstandingTotal: number;
|
||||
netProfit: number;
|
||||
// Coupon discounts given on the event's registrations.
|
||||
// Bundle and coupon discounts given on the event's registrations.
|
||||
discountsTotal?: number;
|
||||
unallocatedDonations: Payment[];
|
||||
unallocatedDonationsTotal: number;
|
||||
@@ -349,7 +384,10 @@ export type Registration = {
|
||||
userId: string;
|
||||
eventId: string;
|
||||
status: RegistrationStatus;
|
||||
// All discounts: bundle deals (bundleDiscount) plus the coupon.
|
||||
discountTotal?: number;
|
||||
bundleDiscount?: number;
|
||||
bundleApplications?: BundleApplication[];
|
||||
ticketDelivery?: "payer" | "each";
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.3",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user