From 763b66ff561a7e1514db0e88202f5b9187a770ba Mon Sep 17 00:00:00 2001 From: joshua Date: Wed, 21 Oct 2026 15:14:53 +0200 Subject: [PATCH] Add bundle deals Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500). Bundles apply automatically, as many times as a registration's tickets allow, and only when cheaper than the prices actually charged. Coupons come off afterwards. - EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount - Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices, receipts, emails, WhatsApp and Yoco checkout, Discounts report rows - Register form shows started bundles with "Complete bundle" and "+" buttons - Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 + backend/DEMO_DATA.md | 6 +- .../migration.sql | 83 ++++ backend/prisma/schema.prisma | 72 +++- backend/scripts/set-demo.js | 46 ++- backend/src/controllers/bundleController.js | 269 ++++++++++++ backend/src/controllers/couponController.js | 95 +++-- backend/src/controllers/eventController.js | 1 + backend/src/controllers/paymentController.js | 14 +- .../src/controllers/registrationController.js | 28 +- backend/src/index.js | 13 +- backend/src/routes/bundleRoutes.js | 14 + backend/src/utils/bundles.js | 160 +++++++ backend/src/utils/coupons.js | 67 +-- backend/src/utils/notifications.js | 25 +- backend/src/utils/pdfDocs.js | 42 +- backend/src/utils/pricing.js | 10 +- backend/src/utils/waMessages.js | 5 +- backend/tests/bundles.test.js | 108 +++++ .../app/dashboard/admin/audit-log/page.tsx | 6 + frontend/src/app/dashboard/admin/page.tsx | 1 + .../dashboard/supervisor/at-the-door/page.tsx | 13 +- .../app/dashboard/supervisor/bundles/page.tsx | 389 ++++++++++++++++++ .../app/dashboard/supervisor/manual/page.tsx | 18 +- .../src/app/dashboard/supervisor/page.tsx | 1 + frontend/src/app/dashboard/user/page.tsx | 6 +- frontend/src/app/events/[id]/page.tsx | 4 + .../app/register/[eventId]/RegisterForm.tsx | 30 +- frontend/src/app/self-service/page.tsx | 19 +- .../src/components/reports/ReportCatalog.ts | 2 +- frontend/src/components/reports/ReportsV2.tsx | 37 +- .../src/components/shared/BundleDeals.tsx | 201 +++++++++ frontend/src/content/help/registry.ts | 6 + .../src/content/help/supervisor-bundles.tsx | 66 +++ frontend/src/types/index.ts | 40 +- 35 files changed, 1766 insertions(+), 149 deletions(-) create mode 100644 backend/prisma/migrations/20261021124657_add_bundle_deals/migration.sql create mode 100644 backend/src/controllers/bundleController.js create mode 100644 backend/src/routes/bundleRoutes.js create mode 100644 backend/src/utils/bundles.js create mode 100644 backend/tests/bundles.test.js create mode 100644 frontend/src/app/dashboard/supervisor/bundles/page.tsx create mode 100644 frontend/src/components/shared/BundleDeals.tsx create mode 100644 frontend/src/content/help/supervisor-bundles.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6945eac..28ebe0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [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 diff --git a/backend/DEMO_DATA.md b/backend/DEMO_DATA.md index d9de784..a114cf9 100644 --- a/backend/DEMO_DATA.md +++ b/backend/DEMO_DATA.md @@ -68,7 +68,7 @@ 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** | @@ -173,7 +173,9 @@ 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 diff --git a/backend/prisma/migrations/20261021124657_add_bundle_deals/migration.sql b/backend/prisma/migrations/20261021124657_add_bundle_deals/migration.sql new file mode 100644 index 0000000..f7bce73 --- /dev/null +++ b/backend/prisma/migrations/20261021124657_add_bundle_deals/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 763a89a..68da141 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/scripts/set-demo.js b/backend/scripts/set-demo.js index ac95a1c..9528ec5 100644 --- a/backend/scripts/set-demo.js +++ b/backend/scripts/set-demo.js @@ -31,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(); @@ -161,8 +162,9 @@ const add = (model, row) => { const INSERT_ORDER = [ 'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption', - 'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'registration', 'registrationOption', - 'registrationOptionTranche', 'attendee', 'payment', 'ticket', 'ticketUsage', 'couponRedemption', + 'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem', + 'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket', + 'ticketUsage', 'couponRedemption', 'registrationBundle', 'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet', 'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField', 'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily', @@ -241,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 }); } @@ -281,12 +296,14 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method = 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', { @@ -307,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, @@ -320,9 +338,19 @@ 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); @@ -424,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, @@ -434,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 }) }); diff --git a/backend/src/controllers/bundleController.js b/backend/src/controllers/bundleController.js new file mode 100644 index 0000000..8014c5f --- /dev/null +++ b/backend/src/controllers/bundleController.js @@ -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 }; diff --git a/backend/src/controllers/couponController.js b/backend/src/controllers/couponController.js index 30bfcd4..4a54562 100644 --- a/backend/src/controllers/couponController.js +++ b/backend/src/controllers/couponController.js @@ -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 }; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 7cb4261..fc639df 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -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) { diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 5c23619..e0070c6 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -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 } diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 94d4ec7..851912a 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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)}.` }); diff --git a/backend/src/index.js b/backend/src/index.js index 828d282..b15ce0f 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }, diff --git a/backend/src/routes/bundleRoutes.js b/backend/src/routes/bundleRoutes.js new file mode 100644 index 0000000..bee58cb --- /dev/null +++ b/backend/src/routes/bundleRoutes.js @@ -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; diff --git a/backend/src/utils/bundles.js b/backend/src/utils/bundles.js new file mode 100644 index 0000000..3dcdce3 --- /dev/null +++ b/backend/src/utils/bundles.js @@ -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, +}; diff --git a/backend/src/utils/coupons.js b/backend/src/utils/coupons.js index e090bb4..23004a6 100644 --- a/backend/src/utils/coupons.js +++ b/backend/src/utils/coupons.js @@ -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, diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js index 51ff309..3315ad2 100644 --- a/backend/src/utils/notifications.js +++ b/backend/src/utils/notifications.js @@ -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 `

No items

`; - if (couponLine) { + for (const line of discounts || []) { rows.push(` - ${couponLine.label} - -${fmtAmount(couponLine.amount)} + ${line.label} + -${fmtAmount(line.amount)} `); } @@ -181,11 +181,10 @@ function selectionsTable(registrationOptions, couponLine = null) { `; } -/** 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 } = {}) {

${subtext}${eventDate ? ` — ${eventDate}` : ''}

Your registration

- ${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

${subtext}${eventDate ? ` The event takes place on ${eventDate}.` : ''}

Your registration

- ${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))} + ${selectionsTable(reg.registrationOptions, discountLines(reg))} ${financialSummary(totalDue, totalPaid, balance)} ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} diff --git a/backend/src/utils/pdfDocs.js b/backend/src/utils/pdfDocs.js index e12b506..075d520 100644 --- a/backend/src/utils/pdfDocs.js +++ b/backend/src/utils/pdfDocs.js @@ -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 }; diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js index 8493c1d..48c3cd4 100644 --- a/backend/src/utils/pricing.js +++ b/backend/src/utils/pricing.js @@ -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 }; } /** diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js index 208c46c..b526374 100644 --- a/backend/src/utils/waMessages.js +++ b/backend/src/utils/waMessages.js @@ -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); diff --git a/backend/tests/bundles.test.js b/backend/tests/bundles.test.js new file mode 100644 index 0000000..4ee0a9a --- /dev/null +++ b/backend/tests/bundles.test.js @@ -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); + }); +}); diff --git a/frontend/src/app/dashboard/admin/audit-log/page.tsx b/frontend/src/app/dashboard/admin/audit-log/page.tsx index 9a3c331..0164726 100644 --- a/frontend/src/app/dashboard/admin/audit-log/page.tsx +++ b/frontend/src/app/dashboard/admin/audit-log/page.tsx @@ -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 = { 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", diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx index bdf5092..22f4db0 100644 --- a/frontend/src/app/dashboard/admin/page.tsx +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -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 }, diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 2ae89fc..dd086b6 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; +import { BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals"; 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"; @@ -1230,14 +1231,16 @@ type OptionsModalProps = { }; 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; - 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 +1252,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 ); @@ -1365,7 +1369,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu {coupon && coupon.discount > 0 && !isEdit && (
R {coupon.subtotal.toFixed(2)} −R {coupon.discount.toFixed(2)}
)} -
Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}
+ +
Total: R {(coupon && !isEdit ? coupon.total : afterBundles).toFixed(2)}
{coupon && isEdit &&
Code {coupon.code} is applied when you save
}
diff --git a/frontend/src/app/dashboard/supervisor/bundles/page.tsx b/frontend/src/app/dashboard/supervisor/bundles/page.tsx new file mode 100644 index 0000000..352d18a --- /dev/null +++ b/frontend/src/app/dashboard/supervisor/bundles/page.tsx @@ -0,0 +1,389 @@ +"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 } from "lucide-react"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +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, +}); + +// 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)}`; +const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"; + +/** 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 [events, setEvents] = useState([]); + const [eventId, setEventId] = useState(""); + const [bundles, setBundles] = useState([]); + const [fetching, setFetching] = useState(false); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); + const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null); + + useEffect(() => { + if (!token || !canView) return; + apiFetch("/api/events/all?includePast=true", { authToken: token }) + .then(list => { + const sorted = (list || []).slice().sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); + setEvents(sorted); + // Start on the next upcoming event, the one most likely to need a deal. + const upcoming = sorted.filter(e => new Date(e.endDate || e.startDate).getTime() >= Date.now()); + setEventId(prev => prev || (upcoming[upcoming.length - 1] || sorted[0])?.id || ""); + }) + .catch(e => setError(errorMessage(e) || "Failed to load events")); + }, [token, canView, setError]); + + const event = events.find(e => e.id === eventId) || null; + + const load = useCallback(async () => { + if (!token || !eventId) return; + setFetching(true); + try { + setBundles(await apiFetch(`/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"); + } + }; + + return ( +
+
+
+
+ +
+
+ +

Bundle deals

+

Sell a set of tickets together for one price, e.g. 5 adults + 3 children for R500

+
+
+ {canView && event && ( + + )} +
+ + {!canView && ( +
You need supervisor access to manage bundle deals.
+ )} + {error &&
{error}
} + {info &&
{info}
} + + {canView && ( +
+
+ + +
+

+ Bundles apply automatically, as many times as someone's tickets allow, and only when they're cheaper than the tickets + at today's prices (early-bird or staff prices included). Coupon codes come off afterwards. +

+ +
+ + + + Bundle + Price + Used + Available + Status + Actions + + + + {bundles.map(b => { + const status = bundleStatus(b); + const save = b.standardValue - b.price; + return ( + + +
{b.name}
+
{b.summary}
+ {b.description &&
{b.description}
} +
+ + {fmtRand(b.price)} +
0 ? "text-green-700" : "text-amber-700"}`}> + {save > 0 ? `Saves ${fmtRand(save)} on ${fmtRand(b.standardValue)}` : `No saving on ${fmtRand(b.standardValue)}`} +
+
+ + {b.uses || 0}× on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} +
{fmtRand(b.totalDiscount || 0)} given
+
+ + {b.validFrom || b.validUntil ? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end"}` : "Always"} + + {status.label} + + + + + +
+ ); + })} + {!fetching && bundles.length === 0 && ( + {event ? "No bundle deals for this event yet." : "Choose an event."} + )} +
+
+
+
+ )} + + {editing && event && ( + setEditing(null)} + onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }} + /> + )} +
+ ); +} + +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(initial); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const set = (key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value })); + const options = event.eventOptions || []; + + const setItem = (i: number, patch: Partial) => 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 saving_ = standardValue - (Number(d.price) || 0); + + 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 ( +
+
e.stopPropagation()}> +
+

{bundleId ? "Edit bundle" : "New bundle deal"} · {event.title}

+ +
+ +
+
+
+ + set("name", e.target.value)} placeholder="Family deal" required maxLength={80} /> +

Shown to buyers and on receipts.

+
+
+ + set("description", e.target.value)} placeholder="Bring the whole family" /> +
+
+ +
+ +
+ {d.items.map((it, i) => { + const opt = options.find(o => o.id === it.eventOptionId); + return ( +
+ setItem(i, { quantity: e.target.value })} aria-label="Quantity" required /> + × + + {(opt?.variants || []).length > 0 && ( + + )} + +
+ ); + })} +
+ +
+ +
+
+ + set("price", e.target.value)} required /> +
+
+
0 ? "text-green-700" : "text-amber-700"}`}> + Separately: {fmtRand(standardValue)} + {d.price !== "" && (saving_ > 0 ? ` · saves ${fmtRand(saving_)}` : " · no saving at the standard price")} +
+
+
+ +
+
+ + set("validFrom", e.target.value)} /> +
+
+ + set("validUntil", e.target.value)} /> +
+
+ + +

Registrations that already have this bundle keep it if you turn it off or change it later.

+ + {error &&
{error}
} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/supervisor/manual/page.tsx b/frontend/src/app/dashboard/supervisor/manual/page.tsx index 1981d30..837fef2 100644 --- a/frontend/src/app/dashboard/supervisor/manual/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual/page.tsx @@ -7,6 +7,7 @@ import { apiFetch, fetchAllUsers } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; +import { 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"; @@ -275,6 +276,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); @@ -614,10 +622,7 @@ export default function ManualRegistrationPage() {
{ - const [eventOptionId, variantId] = key.split("::"); - return { eventOptionId, quantity, variantId: variantId || null }; - })} + basket={basket} token={token} forUserId={pickedUserId} applied={coupon} @@ -628,8 +633,9 @@ export default function ManualRegistrationPage() {
- Total due: R {(coupon ? coupon.total : totalDue).toFixed(2)} - {coupon && coupon.discount > 0 && R {totalDue.toFixed(2)}} + + Total due: R {payable.toFixed(2)} + {payable < totalDue - 0.005 && R {totalDue.toFixed(2)}}
diff --git a/frontend/src/app/dashboard/supervisor/page.tsx b/frontend/src/app/dashboard/supervisor/page.tsx index ea66b07..891ca7b 100644 --- a/frontend/src/app/dashboard/supervisor/page.tsx +++ b/frontend/src/app/dashboard/supervisor/page.tsx @@ -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 }, diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 036b890..a531647 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -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() {
{renderGroup('Early bird', earlyBirdRows)} {renderGroup('Standard price', standardRows)} - {Number(activeReg.discountTotal || 0) > 0 && ( + + {Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
- Discount code{activeCoupon?.coupon?.code ? <> {activeCoupon.coupon.code} : null} — −{formatRand(Number(activeReg.discountTotal))} + Discount code{activeCoupon?.coupon?.code ? <> {activeCoupon.coupon.code} : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
)} {canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && ( diff --git a/frontend/src/app/events/[id]/page.tsx b/frontend/src/app/events/[id]/page.tsx index 7f05a10..5132076 100644 --- a/frontend/src/app/events/[id]/page.tsx +++ b/frontend/src/app/events/[id]/page.tsx @@ -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 (
+ {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; diff --git a/frontend/src/app/register/[eventId]/RegisterForm.tsx b/frontend/src/app/register/[eventId]/RegisterForm.tsx index eddf378..f4a209d 100644 --- a/frontend/src/app/register/[eventId]/RegisterForm.tsx +++ b/frontend/src/app/register/[eventId]/RegisterForm.tsx @@ -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
)} + {!eventSoldOut && } +
{(event.eventOptions || []).map((opt) => ( + {!eventSoldOut && ( + 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
+ {(bundleQuote || (coupon && coupon.discount > 0)) && ( +
R{(coupon?.subtotal ?? bundleQuote?.subtotal ?? total).toFixed(2)}
+ )} + {coupon && coupon.discount > 0 && ( -
- R{coupon.subtotal.toFixed(2)} - −R{coupon.discount.toFixed(2)} -
+
Code {coupon.code} −R{coupon.discount.toFixed(2)}
)}
Total: R{payable.toFixed(2)}
diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index 1672bf5..41f9dd1 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -5,6 +5,7 @@ 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 { 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"; @@ -217,6 +218,12 @@ export default function SelfServicePage() { }, 0); }, [selectedEvent, quantities]); + 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')); @@ -1034,10 +1041,7 @@ export default function SelfServicePage() {
{ - const [eventOptionId, variantId] = key.split("::"); - return { eventOptionId, quantity, variantId: variantId || null }; - })} + basket={kioskBasket} token={supervisorToken} forUserId={matchedAccount?.id ?? null} applied={kioskCoupon} @@ -1047,10 +1051,11 @@ export default function SelfServicePage() { )} {total > 0 && (
- {kioskCoupon && kioskCoupon.discount > 0 && ( - {fmtCurrency(kioskCoupon.subtotal)} + + {((kioskCoupon && kioskCoupon.discount > 0) || bundleQuote) && ( + {fmtCurrency(kioskCoupon?.subtotal ?? bundleQuote?.subtotal ?? total)} )} - Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)} + Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : bundleQuote ? bundleQuote.total : total)}
)}
diff --git a/frontend/src/components/reports/ReportCatalog.ts b/frontend/src/components/reports/ReportCatalog.ts index 979cc79..0adf779 100644 --- a/frontend/src/components/reports/ReportCatalog.ts +++ b/frontend/src/components/reports/ReportCatalog.ts @@ -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; diff --git a/frontend/src/components/reports/ReportsV2.tsx b/frontend/src/components/reports/ReportsV2.tsx index 8ae483e..b987902 100644 --- a/frontend/src/components/reports/ReportsV2.tsx +++ b/frontend/src/components/reports/ReportsV2.tsx @@ -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(); + const byKey = new Map(); 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" && (
{discountSummary.rows.length === 0 ? ( -
No discount codes used for the selected events and dates.
+
No discount codes or bundle deals used for the selected events and dates.
) : ( <> - +
-
Value given away, by code
+
Value given away, by code or bundle
`R ${v.toFixed(2)}`} />
- + @@ -1969,7 +1974,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) { {discountSummary.rows.map(row => ( - + diff --git a/frontend/src/components/shared/BundleDeals.tsx b/frontend/src/components/shared/BundleDeals.tsx new file mode 100644 index 0000000..017ef69 --- /dev/null +++ b/frontend/src/components/shared/BundleDeals.tsx @@ -0,0 +1,201 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Gift } from "lucide-react"; +import { apiFetch } from "@/lib/api"; +import type { BundleItem, BundleQuote, EventOption, 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(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 = { eventId, options }; + if (forUserId !== undefined) body.forUserId = forUserId; + const res = await apiFetch("/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 ( +
+
Bundle deals
+
    + {bundles.map(b => { + const save = b.standardValue - b.price; + return ( +
  • + {b.name}: {b.summary} for {fmt(b.price)} + {save > 0.005 && (save {fmt(save)})} + {b.description && {b.description}} +
  • + ); + })} +
+

Picked automatically when your tickets match, as many times as they fit.

+
+ ); +} + +/** "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 ( +
+ {rows.map(a => ( +
+ + {a.name || a.bundle?.name || "Bundle deal"}{a.count > 1 ? ` ×${a.count}` : ""} + −{fmt(a.discount)} +
+ ))} +
+ ); +} + +type BuilderOption = Pick; + +/** 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, availableCount?: number) => ((stockLimit ?? 0) > 0 && availableCount !== undefined ? 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>) => 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 ( +
+ {rows.map(({ bundle, sets, partial, missing, next }) => { + const save = bundle.standardValue - bundle.price; + return ( +
+
+
{bundle.name}
+
+ {bundle.summary} for {fmt(bundle.price)}{save > 0.005 ? ` (save ${fmt(save)})` : ""} +
+ {partial ? ( +
+ Add {missing.map(m => `${m.short} × ${itemName(m.item, m.opt)}`).join(" and ")} to {sets > 0 ? "complete another bundle" : "complete it"}. +
+ ) : ( +
✓ {sets > 1 ? `${sets} bundles` : "Bundle"} in your basket
+ )} + {!next &&
Not enough tickets left for {partial ? "this bundle" : "another one"}.
} +
+ {partial ? ( + + ) : ( +
+ ×{sets} + +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/content/help/registry.ts b/frontend/src/content/help/registry.ts index 48969a2..cc39d28 100644 --- a/frontend/src/content/help/registry.ts +++ b/frontend/src/content/help/registry.ts @@ -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", diff --git a/frontend/src/content/help/supervisor-bundles.tsx b/frontend/src/content/help/supervisor-bundles.tsx new file mode 100644 index 0000000..181f810 --- /dev/null +++ b/frontend/src/content/help/supervisor-bundles.tsx @@ -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: ( +
+
+ + 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". + + + 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. + + + Optional "Available from" and "Available until" dates, e.g. for a launch-week deal. "Turn off" stops it straight away. + +
+
+ ), + }, + { + key: "how", + label: "How bundles work", + icon: Layers, + content: ( +
+
+ + 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. + + + 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. + + + A code still works with a bundle: the bundle comes off first, then the code is worked out on what's left. + + + 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. + +
+
+ ), + }, + { + key: "tracking", + label: "Tracking", + icon: BarChart2, + content: ( +
+

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.

+

The Discounts report (Reports → Financial) lists bundles next to coupon codes, and the Finance report shows sales after discounts.

+
+ ), + }, + ], +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 01ac112..50eb6b3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;
CodeCode / bundle Discount Event Uses
{row.code}{row.code} {row.summary} {row.event} {row.uses}