Compare commits

..
6 Commits
Author SHA1 Message Date
joshua f1e0b62c73 Merge branch 'feat/group-tickets' 2026-10-19 12:12:52 +02:00
joshuaandClaude Opus 5.5 72ecf500c8 Bump to 1.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:44 +02:00
joshuaandClaude Opus 5.5 df0bf09a0e Add group tickets with named attendees
Events can take a name (and optional email/phone) for each main-ticket
place. Each named person gets their own quantity-1 ticket and attendee
form; unnamed places share one ticket. Tickets go to the payer or to each
person, attendees matching one existing account are linked to it, and
the payer can edit names and send form links from My Events. Ticket
generation is now planned per line (utils/attendees.js), free
registrations use the same generator, and guest registration matches
accounts by phone too, refusing conflicting email/phone pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:33 +02:00
joshua 4b413788ac Merge branch 'feat/coupon-codes' 2026-10-19 11:43:36 +02:00
joshuaandClaude Opus 5.5 95dc3998ea Bump to 1.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:26 +02:00
joshuaandClaude Opus 5.5 709ea38c10 Add coupon codes and staff pricing
Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:10 +02:00
57 changed files with 4176 additions and 319 deletions
+48
View File
@@ -7,6 +7,54 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.14.0] - 2026-10-19
### Added
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
- The payer stays the account owner for billing, invoices and payments.
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
- Requires the `add_named_attendees` database migration.
### Changed
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
### Security
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
## [1.13.0] - 2026-10-19
### Added
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
- Registration responses now include `discountTotal` and `subtotalComputed`.
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
### Fixed
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
## [1.12.0] - 2026-10-19
### Added
+26 -3
View File
@@ -126,6 +126,24 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
creating an account if needed. Includes a "Record Payment" tab.
- **Duplicate-account protection** — if an email and phone belong to two
different accounts, the registration is blocked instead of merging them.
- **Group tickets** — on events with "Named attendees" turned on, someone booking
several places can name each person (with optional email/phone). Each named person
gets their own ticket and their own attendee form; unnamed places share one ticket.
Tickets go all to the payer, or each to the person (falling back to the payer).
Attendees matching an existing account see their ticket in My Events. Names can be
added or changed later from My Events, and attendees can be sent a link to fill in
their own form. Available on the registration page, manual registration, At the
door and the kiosk.
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
"Have a code?" on the registration page, in My Events (for unpaid registrations),
manual registration, At the door and the kiosk. Codes can be limited to events,
ticket options or variants, dates, total and per-person uses, a minimum quantity,
and specific people, roles or an email domain (restricted codes need a login).
Early-bird prices either stack or the better price wins. The discount is locked in
when applied and shows on invoices, receipts, emails and the Yoco checkout;
cancelling frees the use. Managed from Supervisor → Coupon codes.
- **Staff price** — a ticket option or variant can have a staff price that Staff and
above get automatically, without a code.
---
@@ -160,6 +178,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
- **Quantity tickets** — one ticket can cover several people and be redeemed
partially (e.g. check in 2 of 3).
- **Named tickets** — group bookings can give each person their own ticket, shown
with their name on the PDF, the scanner and the staff ticket list.
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
for the selected event/section, then a confirmation screen shows holder, type
and remaining quantity.
@@ -263,6 +283,8 @@ logout controls. Disconnected sessions are recovered automatically.
payment.
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
Reconcile (Yoco transactions), and Payment Link.
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
discount codes, and see who used each one.
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
- **Forms** — view, filter, edit and print form responses.
- **Reports** — see [Reports](#reports).
@@ -282,7 +304,8 @@ logout controls. Disconnected sessions are recovered automatically.
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, and settings changes.
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
- **Site settings** — see below.
---
@@ -314,7 +337,7 @@ a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs) · Profit report |
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event) |
@@ -423,5 +446,5 @@ falling back to a general guide.
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+36 -1
View File
@@ -18,6 +18,7 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments)
- [Registrations](#registrations)
- [Coupons](#coupons)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -227,7 +228,11 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| GET | `/api/registrations` | staff+ | All registrations |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
### POST /api/registrations
```json
@@ -239,6 +244,16 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
]
}
```
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
- Form responses can carry `attendeeId`.
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
For unauthenticated (guest) registration on events with `requiresAuth: false`:
```json
{
@@ -261,6 +276,26 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Coupons
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
| POST | `/api/coupons` | supervisor+ | Create a code |
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
## Payments
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.12.0",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.12.0",
"version": "1.14.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.12.0",
"version": "1.14.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,164 @@
-- CreateEnum
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Coupon" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"type" "CouponType" NOT NULL,
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"maxUses" INTEGER,
"maxUsesPerUser" INTEGER DEFAULT 1,
"minQuantity" INTEGER,
"restrictRoles" "UserRole"[],
"restrictDomain" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponEvent" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponOption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponUser" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponRedemption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"discountAmount" DOUBLE PRECISION NOT NULL,
"appliedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"releasedAt" TIMESTAMP(3),
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
-- CreateIndex
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
-- CreateIndex
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
-- CreateIndex
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
-- CreateIndex
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
-- CreateIndex
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
-- CreateIndex
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
-- CreateIndex
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
-- CreateIndex
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
-- AddForeignKey
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
ADD COLUMN "holderUserId" TEXT;
-- CreateTable
CREATE TABLE "Attendee" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"registrationOptionId" TEXT,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"userId" TEXT,
"dateOfBirth" TIMESTAMP(3),
"gender" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
-- CreateIndex
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
-- CreateIndex
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
-- CreateIndex
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+207 -37
View File
@@ -91,12 +91,20 @@ model User {
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
couponsCreated Coupon[] @relation("CouponCreatedBy")
couponAccess CouponUser[]
couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser")
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
attendeeProfiles Attendee[]
heldTickets Ticket[] @relation("TicketHolder")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
}
@@ -115,6 +123,8 @@ model Event {
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
// Lets a payer name each person on a group registration (one named ticket each).
allowNamedAttendees Boolean @default(false)
contactName String?
contactPhone String?
contactEmail String?
@@ -132,21 +142,22 @@ model Event {
form EventForm?
Section Section[]
cashupStatus CashupStatus @default(open)
cashupDraft Json?
closedAt DateTime?
closedById String?
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
reopenedAt DateTime?
reopenedById String?
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
cashupStatus CashupStatus @default(open)
cashupDraft Json?
closedAt DateTime?
closedById String?
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
reopenedAt DateTime?
reopenedById String?
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
notifyRecipients User[] @relation("EventNotifyRecipients")
notifyRecipients User[] @relation("EventNotifyRecipients")
coupons CouponEvent[]
@@index([createdById])
@@index([closedById])
@@ -160,26 +171,29 @@ model EventOption {
price Float
isMainTicket Boolean
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
SectionOption SectionOption[]
variants OptionVariant[]
costs EventCost[]
couponScopes CouponOption[]
@@index([eventId])
}
model EarlyBirdTier {
id String @id @default(uuid())
id String @id @default(uuid())
eventOptionId String
variantId String?
deadline DateTime
price Float
order Int @default(0)
stockLimit Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
order Int @default(0)
stockLimit Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@ -193,10 +207,13 @@ model OptionVariant {
name String
price Float?
stockLimit Int @default(0)
// Staff price override for this variant (falls back to the option's staffPrice).
staffPrice Float?
order Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
couponScopes CouponOption[]
@@index([eventOptionId])
}
@@ -207,6 +224,12 @@ 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).
discountTotal Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -215,6 +238,8 @@ model Registration {
payments Payment[]
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
attendees Attendee[]
@@index([userId])
@@index([eventId])
@@ -224,19 +249,20 @@ model Registration {
}
model RegistrationOption {
id String @id @default(uuid())
id String @id @default(uuid())
registrationId String
eventOptionId String
quantity Int @default(1)
quantity Int @default(1)
variantId String?
appliedTierId String?
priceSnapshot Float?
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
@@index([registrationId])
@@index([eventOptionId])
@@ -252,14 +278,14 @@ model RegistrationOption {
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
@@ -303,6 +329,12 @@ model Ticket {
quantity Int @default(1)
isUsed Boolean @default(false)
emailSent Boolean @default(false)
// Set on a named (group) ticket: the person it's for, and their account when it
// matched one. userId stays the payer.
attendeeId String? @unique
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
holderUserId String?
holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict)
@@ -310,6 +342,7 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
@@index([holderUserId])
@@index([eventId])
@@index([userId])
@@index([registrationOptionId])
@@ -401,6 +434,10 @@ enum AdminAuditAction {
event_updated
event_deleted
settings_updated
coupon_created
coupon_updated
coupon_disabled
coupon_applied_manual
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -408,16 +445,16 @@ enum AdminAuditAction {
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
// survive account close/anonymization.
model AdminAuditLog {
id String @id @default(uuid())
id String @id @default(uuid())
actorId String?
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
actorRole String
action AdminAuditAction
targetType String
targetId String?
metadata Json?
ip String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([actorId, createdAt])
@@index([action, createdAt])
@@ -478,6 +515,8 @@ model FormResponse {
registrationId String
createdAt DateTime @default(now())
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
attendeeId String?
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
answers FormAnswer[]
@@index([registrationId])
@@ -642,11 +681,11 @@ model EventCashupPersonCount {
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
@@ -806,3 +845,134 @@ model EventCashupPersonCountDenomination {
// @@index([createdAt])
// @@index([method])
// }
// ─── Coupon codes ─────────────────────────────────────────────────────────────
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
// The discount is locked into a CouponRedemption when applied, like early-bird
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
enum CouponType {
percent
fixed
comp
}
model Coupon {
id String @id @default(uuid())
code String @unique // stored uppercase; matched case-insensitively
description String?
type CouponType
value Float @default(0) // percent (0-100) or rand amount; ignored for comp
stackEarlyBird Boolean @default(false) // true: discount the early-bird price; false: best price wins
validFrom DateTime?
validUntil DateTime?
maxUses Int?
maxUsesPerUser Int? @default(1)
minQuantity Int?
restrictRoles UserRole[]
restrictDomain String?
active Boolean @default(true)
createdById String?
createdBy User? @relation("CouponCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events CouponEvent[] // none = valid for every event
options CouponOption[] // none = whole event
allowedUsers CouponUser[]
redemptions CouponRedemption[]
@@index([createdById])
}
model CouponEvent {
id String @id @default(uuid())
couponId String
eventId String
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([couponId, eventId])
@@index([eventId])
}
// Limits a coupon to specific ticket options, or to one variant of an option.
model CouponOption {
id String @id @default(uuid())
couponId String
eventOptionId String
variantId String?
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@index([couponId])
@@index([eventOptionId])
@@index([variantId])
}
model CouponUser {
id String @id @default(uuid())
couponId String
userId String
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([couponId, userId])
@@index([userId])
}
model CouponRedemption {
id String @id @default(uuid())
couponId String
registrationId String
userId String
discountAmount Float // locked at apply time
appliedById String? // staff member, when applied on someone's behalf
createdAt DateTime @default(now())
releasedAt DateTime? // set when the registration is cancelled — frees the use
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
user User @relation("CouponRedemptionUser", fields: [userId], references: [id], onDelete: Restrict)
appliedBy User? @relation("CouponRedemptionAppliedBy", fields: [appliedById], references: [id], onDelete: SetNull)
@@index([couponId, releasedAt])
@@index([registrationId])
@@index([userId])
@@index([appliedById])
}
// ─── Group tickets ────────────────────────────────────────────────────────────
enum TicketDelivery {
payer // every ticket goes to the person who registered
each // each named attendee gets their own (falls back to the payer)
}
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
model Attendee {
id String @id @default(uuid())
registrationId String
registrationOptionId String?
eventOptionId String
variantId String?
name String
email String?
phone String?
userId String? // existing account matched by email/phone
dateOfBirth DateTime?
gender String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
ticket Ticket?
formResponses FormResponse[]
@@index([registrationId])
@@index([registrationOptionId])
@@index([userId])
}
+331
View File
@@ -0,0 +1,331 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
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 TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
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;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @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)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, 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 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 subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
subtotal,
total: Math.max(0, subtotal - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Coupon uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
+24 -7
View File
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -62,6 +62,7 @@ const createEvent = async (req, res) => {
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true',
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
@@ -537,7 +538,7 @@ const updateEvent = async (req, res) => {
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -552,6 +553,7 @@ const updateEvent = async (req, res) => {
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
allowNamedAttendees: allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees,
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
@@ -718,6 +720,15 @@ const deleteEvent = async (req, res) => {
}
};
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
function parseStaffPrice(value, current = null) {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(value);
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
return n;
}
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
@@ -734,7 +745,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -744,6 +755,7 @@ const createEventOption = async (req, res) => {
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
}
});
@@ -770,7 +782,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -779,6 +791,7 @@ const updateEventOption = async (req, res) => {
price: price !== undefined ? parseFloat(price) : eventOption.price,
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
}
});
@@ -847,10 +860,11 @@ const updateEventOption = async (req, res) => {
if (!v.name) continue;
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
const variantStaffPrice = parseStaffPrice(v.staffPrice);
if (v.id) {
await prisma.optionVariant.update({
where: { id: v.id },
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
});
} else {
await prisma.optionVariant.create({
@@ -860,6 +874,7 @@ const updateEventOption = async (req, res) => {
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
@@ -937,7 +952,7 @@ const createOptionVariant = async (req, res) => {
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
await assertEventOpen(eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
if (!name) { res.status(400); throw new Error('Variant name is required'); }
const variant = await prisma.optionVariant.create({
data: {
@@ -946,6 +961,7 @@ const createOptionVariant = async (req, res) => {
name,
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
order: order !== undefined ? parseInt(order, 10) : 0,
}
});
@@ -963,11 +979,12 @@ const updateOptionVariant = async (req, res) => {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
const updated = await prisma.optionVariant.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : variant.name,
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
order: order !== undefined ? parseInt(order, 10) : variant.order,
+23 -35
View File
@@ -913,6 +913,25 @@ 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.
*/
function checkoutLineItems(registration) {
const { buildLineItems, couponDiscountLine } = 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) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -927,6 +946,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -942,26 +962,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
const amountInCents = Math.round(remainingAmount * 100);
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
// Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: {
price: Math.round(unitPrice * 100)
}
};
});
const lineItems = checkoutLineItems(registration);
// Show prior payments as a deduction line
if (totalPaid > 0) {
@@ -1065,6 +1066,7 @@ const createYocoCheckout = async (req, res) => {
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -1078,21 +1080,7 @@ const createYocoCheckout = async (req, res) => {
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
const charge = Math.min(requested, remainingAmount);
const amountInCents = Math.round(charge * 100);
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: { price: Math.round(unitPrice * 100) }
};
});
const lineItems = checkoutLineItems(registration);
// Previous payments deduction
if (totalPaid > 0) {
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
+511 -102
View File
@@ -7,6 +7,70 @@ const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
const { findAccountByContact } = require('../utils/accountMatch');
// Named attendees for API responses, with whether their ticket has been issued/used.
const ATTENDEES_INCLUDE = {
orderBy: { order: 'asc' },
include: { ticket: { select: { id: true, isUsed: true } } },
};
const TICKET_DELIVERY = ['payer', 'each'];
/**
* Places each line will have once `requested` is added to `existingLines` — used to
* check attendee names against what's actually being bought.
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
return out;
}
// Active coupon on a registration, for API responses (the discount itself is
// Registration.discountTotal).
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 } } },
};
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
* before anything is created. Mirrors buildCouponLines for one tranche per option.
*/
function pendingCouponLines(event, resolvedOptions) {
return buildCouponLines({
registrationOptions: resolvedOptions.map(o => ({
eventOptionId: o.eventOptionId,
variantId: o.variantId || null,
quantity: o.quantity,
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
})),
});
}
/**
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
*/
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
try {
const resolved = variantId
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
: await resolveOptionPrice(eventOption, qty, buyer);
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
} catch (e) {
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
return { priceSnapshot: Number(price || 0), appliedTierId: null };
}
}
/**
* Check overall stock availability for an EventOption.
@@ -44,7 +108,7 @@ async function checkVariantStock(variant, requestedQty) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -103,7 +167,12 @@ const createRegistration = async (req, res) => {
res.status(400);
throw new Error('Name and email are required for unauthenticated registration');
}
const existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } });
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
if (match.conflict) {
res.status(409);
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
}
const existingGuest = match.user;
if (existingGuest) {
userId = existingGuest.id;
} else {
@@ -186,30 +255,12 @@ const createRegistration = async (req, res) => {
variantPrice = variant.price; // null = use option price
}
// Resolve price: variant-level tiers take priority, then option-level tiers, then base prices
// Resolve price: variant-level tiers take priority, then option-level tiers, then
// base prices, then the staff price for Staff and above.
let priceSnapshot = null;
let appliedTierId = null;
if (canIncludeTiers) {
try {
if (variantId) {
// Try variant-specific early-bird tier first
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, qty);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
// No variant tier — use the variant's own price (may be null → falls back to option price below)
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
} else {
// No variant — use option-level early-bird tier
const resolved = await resolveOptionPrice(eventOption, qty);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : eventOption.price;
}
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, req.user));
} else {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
@@ -218,12 +269,32 @@ const createRegistration = async (req, res) => {
return { ...option, variantId, appliedTierId, priceSnapshot };
}));
// Check a coupon code before anything is saved, so a bad code doesn't leave a
// half-made registration behind. It's applied (and re-checked under a lock) below.
if (couponCode) {
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
}
// Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true }
});
// Attendee names are checked against the places the registration will have after
// this purchase, counting names already given on an existing registration.
const cleanAttendees = validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedOptions),
attendees,
existingReg ? await namedPlacesByKey(existingReg.id) : {}
);
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400);
throw new Error("ticketDelivery must be 'payer' or 'each'");
}
let registration;
let isNewRegistration = false;
if (existingReg) {
@@ -328,10 +399,40 @@ const createRegistration = async (req, res) => {
});
}
if (cleanAttendees.length > 0) await createAttendees(registration.id, cleanAttendees);
if (ticketDelivery) {
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
}
// Apply the coupon (or re-work an existing one over the merged items).
let couponError = null;
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
} catch (e) {
// Lost a race for the last use since the check above — keep the registration.
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
}
// If total due is zero, mark as paid and generate/update tickets if no required form
let freeTicketMockReq = null, freeTicketMockRes = null;
try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
// Mark paid (no-op if already paid)
if (registration.status !== 'paid') {
@@ -344,31 +445,9 @@ const createRegistration = async (req, res) => {
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (!hasRequiredForm) {
// Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential)
const optionIds = registration.registrationOptions.map(o => o.id);
const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } });
const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t]));
await Promise.all(registration.registrationOptions.map(option => {
const exists = ticketByOptionId[option.id];
if (exists) {
const newQty = option.quantity || 1;
if (newQty > (exists.quantity || 1)) {
return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } });
}
return Promise.resolve();
}
return prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: option.quantity || 1,
updatedAt: new Date(),
}
});
}));
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registration.id);
// Capture for chained email — tickets sent after registration confirmation
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
@@ -399,7 +478,7 @@ const createRegistration = async (req, res) => {
}
})();
res.status(201).json(attachComputedTotals(registration));
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -411,6 +490,8 @@ const createRegistration = async (req, res) => {
const getRegistrations = async (req, res) => {
try {
const include = {
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
@@ -488,6 +569,9 @@ const getUserRegistrations = async (req, res) => {
tranches: true,
}
},
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
formResponses: { select: { id: true, attendeeId: true } },
// Nest the event's form so the frontend can tell whether attendee forms are
// required without a separate GET /api/events/:id per registration.
event: { include: { form: { include: { fields: true } } } },
@@ -534,7 +618,9 @@ const getRegistrationById = async (req, res) => {
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
formResponses: { include: { answers: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
};
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
@@ -552,11 +638,17 @@ const getRegistrationById = async (req, res) => {
}
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
// Authenticated users must be the owner or staff+
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
// Authenticated users must be the owner, staff+, or a named attendee on it
const isOwnerOrStaffViewer = req.user && (registration.userId === req.user.id || ['admin', 'supervisor', 'staff'].includes(req.user.role));
const isAttendeeViewer = req.user && (registration.attendees || []).some(a => a.userId === req.user.id);
if (req.user && !isOwnerOrStaffViewer && !isAttendeeViewer) {
res.status(403);
throw new Error('Not authorized to view this registration');
}
// Only the payer and staff see attendees' contact details.
if (!isOwnerOrStaffViewer) {
registration.attendees = (registration.attendees || []).map(a => ({ ...a, email: null, phone: null }));
}
res.json(attachComputedTotals(registration));
} catch (error) {
@@ -631,6 +723,7 @@ const updateRegistrationStatus = async (req, res) => {
// staff-initiated cancellation — log it the same way DELETE /:id does, so both
// paths land under the one 'registration_cancelled' filter in the audit log.
if (status === 'cancelled' && registration.status !== 'cancelled') {
await releaseRegistrationCoupons(req.params.id);
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
@@ -715,6 +808,7 @@ const cancelRegistration = async (req, res) => {
updatedAt: new Date()
}
});
await releaseRegistrationCoupons(req.params.id);
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
@@ -765,7 +859,9 @@ 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 } }
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
},
orderBy: { createdAt: 'asc' }
});
@@ -791,7 +887,7 @@ const getRegistrationsByEvent = async (req, res) => {
const createManualRegistration = async (req, res) => {
let userRecord;
try {
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode, attendees, ticketDelivery } = req.body;
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
res.status(400);
@@ -822,8 +918,7 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Resolve pricing for each option (appliedTierId + priceSnapshot)
const resolvedManualOptions = [];
// Validate each option up front, before any account is created or changed.
for (const option of options) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
@@ -834,34 +929,17 @@ const createManualRegistration = async (req, res) => {
res.status(400);
throw new Error('Quantity must be at least 1');
}
const variantId = option.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, option.quantity);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const variant = (eventOption.variants || []).find(v => v.id === variantId);
priceSnapshot = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(eventOption.price || 0);
}
} else {
const resolved = await resolveOptionPrice(eventOption, option.quantity);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption.price || 0);
}
resolvedManualOptions.push({ ...option, variantId, appliedTierId, priceSnapshot });
}
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
return { ...option, variantId, ...priced };
}));
// Resolve or create a user
let userId;
@@ -992,12 +1070,32 @@ const createManualRegistration = async (req, res) => {
}
}
let couponError = null;
const attendee = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, role: true, email: true } });
const resolvedManualOptions = await priceManualOptions(attendee);
// Check the coupon against this attendee before saving anything.
if (couponCode) {
await previewCoupon({ code: couponCode, eventId, user: attendee, lines: pendingCouponLines(event, resolvedManualOptions) });
}
// Merge into existing non-cancelled registration if one exists, otherwise create new
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true }
});
const cleanAttendees = validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedManualOptions),
attendees,
existingReg ? await namedPlacesByKey(existingReg.id) : {}
);
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400);
throw new Error("ticketDelivery must be 'payer' or 'each'");
}
let registration;
let isNewRegistration = false;
@@ -1050,6 +1148,18 @@ const createManualRegistration = async (req, res) => {
}
});
}));
// Re-work any coupon over the merged items, and apply a new one, before the status
// is worked out from the totals.
await recalculateRegistrationDiscount(existingReg.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: existingReg.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
// Recompute status based on totals across ALL current options and payments
const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id },
@@ -1072,6 +1182,7 @@ const createManualRegistration = async (req, res) => {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
registration.status = newExistingStatus;
@@ -1109,10 +1220,42 @@ const createManualRegistration = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
});
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
}
}
const registrationId = registration.id;
if (cleanAttendees.length > 0) await createAttendees(registrationId, cleanAttendees);
if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } });
if (couponCode && !couponError) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registrationId,
metadata: { code: String(couponCode).trim().toUpperCase(), discount: registration.discountTotal, forUserId: userId },
ip: getClientIp(req),
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
@@ -1122,7 +1265,7 @@ const createManualRegistration = async (req, res) => {
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
registration.status = 'paid';
@@ -1185,7 +1328,7 @@ const createManualRegistration = async (req, res) => {
ip: getClientIp(req),
});
return res.status(201).json(attachComputedTotals(registration));
return res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
console.error(error);
return res.status(400).json({message: error.message});
@@ -1219,6 +1362,7 @@ const updateRegistrationOptions = async (req, res) => {
},
event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } },
payments: true,
user: { select: { id: true, role: true, email: true } },
}
});
@@ -1347,19 +1491,7 @@ const updateRegistrationOptions = async (req, res) => {
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
@@ -1491,9 +1623,28 @@ const updateRegistrationOptions = async (req, res) => {
}
}
// Names on places that no longer exist go too (newest first). Tickets for them
// are tidied up by generateTicketsForRegistration; the floor check above already
// stops anything going below tickets issued.
await tx.attendee.deleteMany({ where: { registrationId, registrationOptionId: null } });
const lines = await tx.registrationOption.findMany({ where: { registrationId }, include: { attendees: { orderBy: { order: 'desc' } } } });
for (const line of lines) {
const extra = (line.attendees || []).length - (line.quantity || 0);
if (extra > 0) {
await tx.attendee.deleteMany({ where: { id: { in: line.attendees.slice(0, extra).map(a => a.id) } } });
}
}
// Coupon: percentage/comp codes follow the new items, fixed codes keep their amount.
const discount = await recalculateRegistrationDiscount(registrationId, tx);
const dueAfterDiscount = Math.max(0, newTotalDue - discount);
if (dueAfterDiscount < totalPaid - 0.001) {
throw new Error('Cannot reduce items below the amount already paid');
}
// Update updatedAt and potentially status based on payments vs due
let newStatus = registration.status;
if (totalPaid >= newTotalDue) newStatus = 'paid';
if (totalPaid >= dueAfterDiscount) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
else newStatus = 'pending';
@@ -1511,6 +1662,8 @@ const updateRegistrationOptions = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
@@ -1555,8 +1708,11 @@ const submitFormResponses = async (req, res) => {
}
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be owner or staff+
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) {
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be
// owner, staff+, or submitting their own form as a named attendee
const ownAttendeeOnly = Array.isArray(responses) && responses.length > 0 && req.user
&& (await prisma.attendee.count({ where: { registrationId, userId, id: { in: responses.map(r => r?.attendeeId).filter(Boolean) } } })) === responses.length;
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role) && !ownAttendeeOnly) {
res.status(403); throw new Error('Not authorized');
}
@@ -1578,11 +1734,20 @@ const submitFormResponses = async (req, res) => {
if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); }
if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); }
// A response can be for a named attendee (group tickets); each attendee answers once.
const attendeeIds = toSubmit.map(r => r?.attendeeId).filter(Boolean);
if (attendeeIds.length > 0) {
const known = await prisma.attendee.findMany({ where: { id: { in: attendeeIds }, registrationId }, include: { formResponses: { select: { id: true } } } });
if (known.length !== new Set(attendeeIds).size) { res.status(400); throw new Error('Unknown attendee for this registration'); }
const answered = known.find(a => a.formResponses.length > 0);
if (answered) { res.status(400); throw new Error(`${answered.name}'s form has already been submitted`); }
}
// Create responses and answers
const created = [];
for (const resp of toSubmit) {
const respId = uuidv4();
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } });
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId, attendeeId: resp?.attendeeId || null } });
const ansMap = resp?.answers || {};
for (const f of answerableFields) {
const raw = ansMap[f.id];
@@ -1818,7 +1983,251 @@ const sendInvoice = async (req, res) => {
}
};
/**
* After a discount changes, bring the status in line with the new total. A registration
* that a coupon has made fully paid gets its tickets, as any paid registration does.
*/
async function syncStatusAfterDiscount(registrationId) {
const reg = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: true, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(reg, new Date());
const paid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const status = paid >= due - 0.001 ? 'paid' : paid > 0 ? 'partial_paid' : 'pending';
if (status !== reg.status) {
await prisma.registration.update({ where: { id: registrationId }, data: { status, updatedAt: new Date() } });
}
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
} catch (e) {
console.error('[coupon] Failed to issue tickets after discount:', e?.message || e);
}
})();
}
return status;
}
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
// @desc Apply a coupon code to an existing registration
// @route POST /api/registrations/:id/coupon
// @access Private (owner, or staff applying it on someone's behalf)
const applyCoupon = async (req, res) => {
try {
const { code } = req.body || {};
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: { event: { select: { endDate: true, cashupStatus: true } } },
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
const byStaff = registration.userId !== req.user.id;
if (byStaff && !isStaffRole(req.user.role)) { res.status(403); throw new Error('Not authorized to change this registration'); }
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
res.status(400); throw new Error('This event has already ended.');
}
const { coupon, discount } = await applyCouponToRegistration({
registrationId: registration.id,
code,
appliedById: byStaff ? req.user.id : null,
});
await syncStatusAfterDiscount(registration.id);
if (byStaff) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { code: coupon.code, discount, forUserId: registration.userId },
ip: getClientIp(req),
});
}
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
sendRegistrationUpdatedEmails(registration.id).catch(e => console.error('Failed to send registration updated emails:', e));
const updated = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Remove the coupon from a registration (the use is freed)
// @route DELETE /api/registrations/:id/coupon
// @access Private/Staff
const removeCoupon = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({ where: { id: req.params.id } });
if (!registration) { res.status(404); throw new Error('Registration not found'); }
await assertEventOpen(registration.eventId, res);
// Removing a discount raises the total, which can take a paid registration back to
// unpaid. Same rule as a staff status change: refuse once a ticket has been scanned,
// otherwise withdraw the unused tickets until it's paid again.
const tickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
include: { usages: true },
});
if (registration.status === 'paid' && tickets.some(t => t.isUsed || t.usages.length > 0)) {
res.status(400);
throw new Error('A ticket on this registration has already been scanned, so its code can\'t be removed.');
}
const released = await releaseRegistrationCoupons(registration.id);
if (!released) { res.status(400); throw new Error('No code is applied to this registration.'); }
const status = await syncStatusAfterDiscount(registration.id);
if (registration.status === 'paid' && status !== 'paid') {
await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: registration.id }, isUsed: false } });
}
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { removed: true, forUserId: registration.userId },
ip: getClientIp(req),
});
res.json({ message: 'Code removed.' });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
const isOwnerOrStaff = (registration, user) =>
registration.userId === user.id || ['admin', 'supervisor', 'staff'].includes(user.role);
// @desc Replace the named attendees on a registration (and who gets the tickets)
// @route PUT /api/registrations/:id/attendees
// @access Private (owner or staff)
// Body: { attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }
// Attendees left out are removed — refused if their ticket has been scanned.
const updateAttendees = async (req, res) => {
try {
const { attendees = [], ticketDelivery } = req.body || {};
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: {
registrationOptions: true,
attendees: { include: { ticket: { include: { usages: true } } } },
event: { include: { eventOptions: true } },
},
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
if (!isOwnerOrStaff(registration, req.user)) { res.status(403); throw new Error('Not authorized to edit this registration'); }
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
res.status(400); throw new Error('This event has already ended.');
}
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400); throw new Error("ticketDelivery must be 'payer' or 'each'");
}
const clean = validateAttendees(registration.event, placesAfter(registration.registrationOptions, []), attendees);
const keepIds = new Set(attendees.map(a => a?.id).filter(Boolean));
const existingById = new Map(registration.attendees.map(a => [a.id, a]));
for (const id of keepIds) if (!existingById.has(id)) { res.status(400); throw new Error('Unknown attendee for this registration'); }
const removed = registration.attendees.filter(a => !keepIds.has(a.id));
const usedRemoved = removed.find(a => a.ticket && (a.ticket.isUsed || a.ticket.usages.length > 0));
if (usedRemoved) { res.status(400); throw new Error(`${usedRemoved.name}'s ticket has already been scanned, so they can't be removed.`); }
const toCreate = [];
await prisma.$transaction(async (tx) => {
if (removed.length) await tx.attendee.deleteMany({ where: { id: { in: removed.map(a => a.id) } } });
for (let i = 0; i < attendees.length; i++) {
const a = attendees[i];
const c = clean[i];
if (a.id) {
const prev = existingById.get(a.id);
const contactChanged = prev.email !== c.email || prev.phone !== c.phone;
const match = contactChanged && (c.email || c.phone) ? await findAccountByContact({ email: c.email, phone: c.phone }, tx) : null;
await tx.attendee.update({
where: { id: a.id },
data: {
name: c.name, email: c.email, phone: c.phone,
...(contactChanged ? { userId: match?.user?.id || null } : {}),
},
});
} else {
toCreate.push(c);
}
}
if (toCreate.length) await createAttendees(registration.id, toCreate, tx);
if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
});
// Paid registrations: re-sync tickets and send any new ones.
if (registration.status === 'paid') {
const created = await generateTicketsForRegistration(registration.id);
if (created.length > 0) {
const mockReq = { user: { id: registration.userId }, body: { ticketIds: created.map(t => t.id) } };
const mockRes = { status: () => mockRes, json: () => {} };
emailTickets(mockReq, mockRes).catch(e => console.error('[attendees] Failed to send new tickets:', e?.message || e));
}
}
const updated = await prisma.registration.findUnique({
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Send an attendee a link to fill in their own attendee form
// @route POST /api/registrations/:id/attendees/:attendeeId/send-form-link
// @access Private (owner or staff)
const sendAttendeeFormLink = async (req, res) => {
try {
const attendee = await prisma.attendee.findFirst({
where: { id: req.params.attendeeId, registrationId: req.params.id },
include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } },
});
if (!attendee) { res.status(404); throw new Error('Attendee not found'); }
if (!isOwnerOrStaff(attendee.registration, req.user)) { res.status(403); throw new Error('Not authorized'); }
if (!attendee.email && !attendee.phone) { res.status(400); throw new Error(`Add an email or phone number for ${attendee.name} first.`); }
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const sent = await sendAttendeeFormLinkMessage(attendee);
res.json({ message: sent.length ? `Form link sent to ${attendee.name} by ${sent.join(' and ')}.` : 'The form link could not be sent.' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
module.exports = {
updateAttendees,
sendAttendeeFormLink,
applyCoupon,
removeCoupon,
createRegistration,
getRegistrations,
getUserRegistrations,
+54 -9
View File
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
// @access Private
const getUserTickets = async (req, res) => {
try {
// Tickets this user paid for, plus named tickets someone else bought for them.
const tickets = await prisma.ticket.findMany({
where: {
userId: req.user.id,
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
@@ -94,6 +95,8 @@ const getUserTickets = async (req, res) => {
registration: true
}
},
attendee: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: true,
usages: {
include: {
@@ -261,6 +264,7 @@ const getScanPreview = async (req, res) => {
e.title AS "eventTitle",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
@@ -276,9 +280,10 @@ const getScanPreview = async (req, res) => {
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
@@ -299,6 +304,7 @@ const getScanPreview = async (req, res) => {
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages: r.usages || []
});
} catch (error) {
@@ -432,6 +438,7 @@ const getTicketsByEvent = async (req, res) => {
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -660,6 +667,13 @@ async function emailTicketsInternal(req, res, userOverride) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const ticketInclude = {
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: true,
user: { select: { name: true } },
usages: true,
};
let tickets;
if (registrationId) {
const registration = await prisma.registration.findUnique({
@@ -669,22 +683,53 @@ async function emailTicketsInternal(req, res, userOverride) {
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
tickets = await prisma.ticket.findMany({
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
} else {
// The payer, or the named holder of a group ticket, can have it sent.
tickets = await prisma.ticket.findMany({
where: { id: { in: ticketIds }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
}
// Dedup by registrationOptionId
// Group tickets set to "send each person their own": attendees with contact details
// get theirs directly; the rest (and attendees without contact details) go to the
// payer below. Not when the caller asked for a specific recipient or channel.
if (!userOverride && !req._noSplit) {
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
&& t.registrationOption?.registration?.ticketDelivery === 'each');
for (const t of own) {
const a = t.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
const silent = { status: () => silent, json: () => silent };
try {
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
} catch (e) {
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
}
}
const sentIds = new Set(own.map(t => t.id));
tickets = tickets.filter(t => !sentIds.has(t.id));
if (tickets.length === 0 && own.length > 0) {
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
}
}
// Dedup by registrationOptionId — except named tickets, which are one per person.
{
const seen = new Map(); const deduped = [];
for (const t of tickets) {
const key = t.registrationOptionId;
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
else {
const existing = seen.get(key);
@@ -726,10 +771,10 @@ async function emailTicketsInternal(req, res, userOverride) {
doc.font('Helvetica').fontSize(10);
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
const eventDate = new Date(ticket.event.startDate);
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
+21
View File
@@ -175,6 +175,7 @@ app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
@@ -609,6 +610,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -627,6 +632,22 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Coupons', base: '/api/coupons', endpoints: [
{ 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: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',
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
{ 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: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.get('/', protect, supervisor, listCoupons);
router.post('/', protect, supervisor, createCoupon);
router.get('/redemptions', protect, supervisor, listRedemptions);
router.get('/:id', protect, supervisor, getCoupon);
router.put('/:id', protect, supervisor, updateCoupon);
module.exports = router;
+6
View File
@@ -13,6 +13,8 @@ const {
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
applyCoupon, removeCoupon,
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -22,6 +24,10 @@ router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
router.post('/:id/coupon', protect, applyCoupon);
router.delete('/:id/coupon', protect, staff, removeCoupon);
router.put('/:id/attendees', protect, updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
+36
View File
@@ -0,0 +1,36 @@
/**
* Finding an existing account from an email and/or phone number.
*
* Email and phone are looked up separately rather than with one OR query, so an email
* belonging to one account and a phone belonging to another can never be silently
* collapsed into whichever matches first — that would let a registration hijack or
* corrupt someone else's account. Phones are tried in both 27xxx and 0xxx forms.
*/
const prisma = require('../config/db');
function hasValidEmail(email) {
return !!(email && typeof email === 'string' && email.includes('@'));
}
/**
* @returns {Promise<{ phone: string, emailUser: object|null, phoneUser: object|null,
* conflict: boolean, user: object|null }>}
* `user` is the single matched account (null when none, or when email and phone point
* at two different accounts — `conflict` is then true).
*/
async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
const { normalizeZAPhone } = require('./whatsapp');
const phone = normalizeZAPhone(rawPhone) || String(rawPhone || '').replace(/\D/g, '');
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const [emailUser, phoneUser] = await Promise.all([
hasValidEmail(email) ? db.user.findUnique({ where: { email: String(email).trim() } }) : null,
phone ? db.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } }) : null,
]);
const conflict = !!(emailUser && phoneUser && emailUser.id !== phoneUser.id);
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
}
module.exports = { findAccountByContact, hasValidEmail };
+166
View File
@@ -0,0 +1,166 @@
/**
* Group tickets — named attendees on a registration.
*
* Rules:
* - Only main-ticket places can be named, and only on events with
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
* places left unnamed share one quantity ticket, as before.
* - The payer stays the registration owner (billing, invoices, payments). An attendee
* whose email/phone matches an existing account is linked to it, so the ticket also
* shows in that person's dashboard — unless email and phone point at two different
* accounts, in which case nothing is linked.
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
* email/WhatsApp; attendees with no contact details fall back to the payer.
*/
const prisma = require('../config/db');
const { findAccountByContact } = require('./accountMatch');
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
function attendeeError(message) {
const err = new Error(message);
err.statusCode = 400;
return err;
}
/**
* Checks attendees against the event and the number of places on each line. Pure.
* Returns the cleaned list (trimmed strings, nulls for blanks).
*
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
* @param {object} [namedByKey] - places on those lines already named (and being kept)
*/
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
if (!attendees || attendees.length === 0) return [];
if (!event.allowNamedAttendees) throw attendeeError('This event doesn\'t take attendee names.');
if (!Array.isArray(attendees)) throw attendeeError('attendees must be a list');
const countByKey = {};
return attendees.map((a, i) => {
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
const name = String(a.name || '').trim();
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
const email = String(a.email || '').trim() || null;
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
const phone = String(a.phone || '').trim() || null;
const key = lineKey(a.eventOptionId, a.variantId);
countByKey[key] = (countByKey[key] || 0) + 1;
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
throw attendeeError(`More names than "${option.name}" tickets.`);
}
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone };
});
}
/**
* Works out the tickets one registration line should have, given its named attendees.
* Pure — returns operations for the caller to run.
*
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
* below what's been redeemed.
* - Tickets for attendees that no longer exist are removed unless already scanned.
*
* @param {object} line - { quantity }
* @param {Array} attendees - [{ id, userId }]
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
*/
function planLineTickets(line, attendees, tickets) {
const scanned = (t) => (t.usages || []).length > 0;
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
const ops = { create: [], update: [], remove: [] };
const attendeeIds = new Set(attendees.map(a => a.id));
const byAttendee = new Map();
const orphanNamed = [];
const shared = [];
for (const t of tickets) {
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
else if (t.attendeeId) orphanNamed.push(t);
else shared.push(t);
}
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
let keptScannedOrphans = 0;
for (const t of orphanNamed) {
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
else ops.remove.push(t.id);
}
for (const a of attendees) {
const t = byAttendee.get(a.id);
if (!t) {
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
}
}
// Unnamed places share one ticket.
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
const [primary, ...extras] = sorted;
if (primary) {
const floor = scanned(primary) ? redeemed(primary) : 0;
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
else {
const qty = Math.max(remainder, floor);
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
}
} else if (remainder > 0) {
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
}
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
return ops;
}
/**
* Saves new attendees on a registration, linking each to its registration line and to
* a matching account when there is exactly one.
*/
async function createAttendees(registrationId, attendees, db = prisma) {
if (!attendees || attendees.length === 0) return [];
const lines = await db.registrationOption.findMany({ where: { registrationId } });
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
const orderByLine = {};
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
const created = [];
for (const a of attendees) {
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
const order = orderByLine[line?.id] || 0;
orderByLine[line?.id] = order + 1;
created.push(await db.attendee.create({
data: {
registrationId,
registrationOptionId: line?.id || null,
eventOptionId: a.eventOptionId,
variantId: a.variantId || null,
name: a.name,
email: a.email,
phone: a.phone,
userId: match.user?.id || null,
order,
},
}));
}
return created;
}
/** Named places already on a registration, per line key — for validating additions. */
async function namedPlacesByKey(registrationId, db = prisma) {
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
const out = {};
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
return out;
}
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
+7 -2
View File
@@ -72,7 +72,7 @@ async function computeEventFinancials(eventId) {
throw new Error('Event not found');
}
const [payments, costs, tickets, salesRows, history] = await Promise.all([
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] }
}),
@@ -92,7 +92,9 @@ async function computeEventFinancials(eventId) {
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
})
}),
// Coupon discounts on the same paid registrations salesByOption counts.
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
]);
// Ticket quantity sold per EventOption (used to price per-item costs)
@@ -204,6 +206,8 @@ async function computeEventFinancials(eventId) {
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
}
const salesByOption = Object.values(salesByOptionMap);
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
return {
event,
@@ -222,6 +226,7 @@ async function computeEventFinancials(eventId) {
unallocatedDonationsTotal,
totalDonations,
salesByOption,
discountsTotal,
history
};
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations.
*
* Rules:
* - A registration can carry one active coupon. Its discount is stored as a
* CouponRedemption (amount locked at apply time) and mirrored as the sum of active
* redemptions on Registration.discountTotal, which computeRegistrationTotalDue
* subtracts. Early-bird pricing refreshes never touch it.
* - Editing a registration's items recalculates percentage and comp codes over the new
* items; fixed-amount codes keep their amount (capped at what's in scope).
* - Cancelling a registration releases its redemption, freeing the use.
* - Scope: no CouponOption rows = the whole event; otherwise only matching options
* (or one variant of an option).
* - stackEarlyBird=false means "best price wins": the coupon is worked out against the
* 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.
*/
const prisma = require('../config/db');
function normalizeCode(code) {
return String(code || '').trim().toUpperCase();
}
function couponError(message) {
const err = new Error(message);
err.isCouponError = true;
err.statusCode = 400;
return err;
}
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
/**
* Discountable lines from a registration — one per price tranche, so tickets bought at
* different prices are each discounted at what was actually charged.
* `basePrice` is the standard (non-early-bird) price, used for "best price wins".
*/
function buildCouponLines(registration) {
const lines = [];
for (const ro of registration?.registrationOptions || []) {
const eo = ro.eventOption || {};
const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null;
const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0);
const tranches = ro.tranches && ro.tranches.length > 0
? ro.tranches
: [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot ?? standard);
lines.push({
eventOptionId: ro.eventOptionId,
variantId: ro.variantId || null,
quantity: Number(t.quantity || 0),
unitPrice,
// Only an early-bird tranche has a "standard" price above what was charged; a
// staff price or plain price is already the base the coupon works from.
basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice,
});
}
}
return lines;
}
function lineInScope(line, scopes) {
if (!scopes || scopes.length === 0) return true;
return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId));
}
function scopedLines(coupon, lines) {
return (lines || []).filter(l => lineInScope(l, coupon.options));
}
/**
* The discount a coupon gives on these lines, in rand, never more than what the in-scope
* lines cost. Pure — used for previews, applying, and recalculating after edits.
*/
function computeCouponDiscount(coupon, lines) {
const scoped = scopedLines(coupon, lines);
const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
if (charged <= 0) return 0;
let discount = 0;
if (coupon.type === 'comp') {
discount = charged;
} else if (coupon.type === 'percent') {
const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100;
if (coupon.stackEarlyBird) {
discount = charged * pct;
} else {
discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0);
}
} else if (coupon.type === 'fixed') {
const amount = Math.max(0, Number(coupon.value || 0));
if (coupon.stackEarlyBird) {
discount = amount;
} else {
const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0);
discount = charged - Math.max(0, base - amount);
}
}
return round2(Math.min(charged, Math.max(0, discount)));
}
/**
* Checks whether a coupon can be used. Pure — the caller supplies the use counts.
*
* @param {object} coupon - with events[], options[], allowedUsers[]
* @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines,
* deferRestrictions } — deferRestrictions lets staff preview a
* restricted code before the attendee's account exists; it's
* checked against the attendee when the registration is saved.
* @returns {{ ok: true } | { ok: false, message: string }}
*/
function validateCoupon(coupon, ctx) {
const no = (message) => ({ ok: false, message });
if (!coupon) return no('That code isn\'t valid.');
if (!coupon.active) return no('This code is no longer active.');
const now = ctx.now ? new Date(ctx.now) : new Date();
if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.');
if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.');
if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) {
return no('This code isn\'t valid for this event.');
}
const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId);
const restrictedRoles = coupon.restrictRoles || [];
const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) {
const user = ctx.user;
if (!user) return no('Log in to use this code.');
const email = String(user.email || '').toLowerCase();
const allowed = restrictedUsers.includes(user.id)
|| restrictedRoles.includes(user.role)
|| (domain && email.endsWith(`@${domain}`));
if (!allowed) return no('This code isn\'t available for your account.');
}
if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) {
return no('This code has been fully used.');
}
if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) {
return no('You\'ve already used this code.');
}
const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0);
if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.');
if (coupon.minQuantity && scopedQty < coupon.minQuantity) {
return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`);
}
return { ok: true };
}
/** Short human description, e.g. "20% off", "R50 off", "Free". */
function describeCoupon(coupon) {
if (!coupon) return '';
if (coupon.type === 'comp') return 'Free';
if (coupon.type === 'percent') return `${Number(coupon.value)}% off`;
return `R${Number(coupon.value).toFixed(2)} off`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
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 } } },
user: { select: { id: true, role: true, email: true } },
};
async function findCouponByCode(code, db = prisma) {
const normalized = normalizeCode(code);
if (!normalized) return null;
return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE });
}
/** Active uses: unreleased redemptions on registrations that aren't cancelled. */
async function countCouponUses(couponId, userId, db = prisma) {
const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } };
const [usesTotal, usesByUser] = await Promise.all([
db.couponRedemption.count({ where: base }),
userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0),
]);
return { usesTotal, usesByUser };
}
/**
* 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.
*/
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 });
if (!check.ok) throw couponError(check.message);
return { coupon, discount: computeCouponDiscount(coupon, lines) };
}
/**
* Applies `code` to a saved registration: validates it against the registration's owner
* and items, locks in the discount, and updates Registration.discountTotal. Runs under a
* per-coupon advisory lock so the last available use can't be claimed twice.
*/
async function applyCouponToRegistration({ registrationId, code, appliedById = null }) {
const normalized = normalizeCode(code);
if (!normalized) throw couponError('Enter a code.');
return prisma.$transaction(async (tx) => {
const coupon = await findCouponByCode(normalized, tx);
if (!coupon) throw couponError('That code isn\'t valid.');
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`;
const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) throw couponError('Registration not found.');
if (registration.couponRedemptions.length > 0) {
throw couponError(registration.couponRedemptions[0].couponId === coupon.id
? 'This code is already applied to this registration.'
: 'A code has already been applied to this registration.');
}
const lines = buildCouponLines(registration);
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);
const discount = computeCouponDiscount(coupon, lines);
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() } });
return { coupon, discount, redemption };
});
}
/**
* 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.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
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;
for (const r of registration.couponRedemptions) {
let amount;
if (r.coupon.type === 'fixed') {
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
amount = round2(Math.min(r.discountAmount, inScope));
} else {
amount = computeCouponDiscount(r.coupon, lines);
}
if (Math.abs(amount - r.discountAmount) > 0.001) {
await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } });
}
total += amount;
}
total = round2(total);
if (Math.abs(total - registration.discountTotal) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
}
return total;
}
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
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 } });
}
return released.count;
}
module.exports = {
normalizeCode,
couponError,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
findCouponByCode,
countCouponUses,
previewCoupon,
applyCouponToRegistration,
recalculateRegistrationDiscount,
releaseRegistrationCoupons,
};
+83 -8
View File
@@ -2,6 +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');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -89,6 +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 } } } },
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 } } } },
},
@@ -101,11 +103,31 @@ async function loadTicketFull(ticketId) {
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: true,
},
});
}
/**
* Who a ticket's messages go to: a named attendee with contact details when the
* registration sends each person their own tickets, otherwise the payer.
*/
function ticketRecipient(ticket) {
const a = ticket.attendee;
if (a && (a.email || a.phone) && ticket.registrationOption?.registration?.ticketDelivery === 'each') {
const hasEmail = !!(a.email && a.email.includes('@'));
return {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
}
return ticket.user;
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
@@ -115,6 +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 } } } },
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 } } } },
},
@@ -126,8 +149,8 @@ async function loadPaymentFull(paymentId) {
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table. */
function selectionsTable(registrationOptions) {
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
function selectionsTable(registrationOptions, couponLine = null) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
@@ -141,6 +164,13 @@ function selectionsTable(registrationOptions) {
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
if (couponLine) {
rows.push(`<tr>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
</tr>`);
}
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
@@ -151,6 +181,14 @@ function selectionsTable(registrationOptions) {
</table>`;
}
/** Plain-text selections, one bullet per item plus the coupon 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)}`);
return lines.join('\n');
}
/** Renders a financial summary line. */
function financialSummary(totalDue, totalPaid, balance) {
const ff2 = ff;
@@ -279,14 +317,14 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -320,14 +358,14 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired
@@ -1136,7 +1174,7 @@ async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const user = ticketRecipient(ticket);
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
@@ -1428,7 +1466,44 @@ async function sendDailyEventSummaries(now = new Date()) {
}
}
// Sent when a payer (or staff) asks for a named attendee to fill in their own form.
// Always tries both channels the attendee has, regardless of anyone's preference.
async function sendAttendeeFormLinkMessage(attendee) {
const org = getOrg();
const reg = attendee.registration;
const eventTitle = reg?.event?.title || 'the event';
const link = `${org.url}/forms?registrationId=${encodeURIComponent(attendee.registrationId)}&attendeeId=${encodeURIComponent(attendee.id)}`;
const payer = reg?.user?.name || 'Someone';
const sent = [];
if (attendee.email && attendee.email.includes('@')) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your form</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${attendee.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${payer} registered you for <strong>${eventTitle}</strong>. Please fill in your attendee form.</p>
${ctaButton('Fill in my form', link)}
${fallbackLink(link)}
`, { preheader: `Your attendee form for ${eventTitle}` });
try {
await sendMail({ to: attendee.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${attendee.name},\n\n${payer} registered you for ${eventTitle}. Please fill in your attendee form:\n${link}\n\n${org.name}` });
sent.push('email');
} catch (e) {
console.warn('[attendee form link] email failed:', e?.message || e);
}
}
if (attendee.phone) {
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
if (isValidZAPhone(attendee.phone)) {
await waTextAny({ name: attendee.name, phoneNumber: attendee.phone }, `Hi ${attendee.name}, ${payer} registered you for *${eventTitle}*. Please fill in your attendee form: ${link}`);
sent.push('WhatsApp');
}
}
return sent;
}
module.exports = {
sendAttendeeFormLinkMessage,
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
+24 -3
View File
@@ -93,6 +93,18 @@ function buildLineItems(registrationOptions) {
return rows;
}
/**
* 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.
*/
function couponDiscountLine(registration) {
const amount = Number(registration?.discountTotal || 0);
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' };
}
/** 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;
@@ -198,6 +210,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) {
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;
}
y += 20;
const boxW = 160, boxH = 32;
@@ -240,6 +259,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 filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
@@ -303,9 +323,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) {
if (discount > 0.01 || coupon) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
@@ -348,4 +369,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
+49 -5
View File
@@ -18,6 +18,10 @@
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
* rows created before tranches existed).
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
* automatically when it's lower than the price they'd otherwise pay.
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
* subtracted by computeRegistrationTotalDue, never below zero.
* - 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
@@ -26,6 +30,24 @@
const prisma = require('../config/db');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
/** The staff price this user gets for an option/variant, or null if none applies. */
function staffPriceFor(option, variantId, user) {
if (!user || !STAFF_ROLES.includes(user.role)) return null;
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
? variant.staffPrice
: option?.staffPrice;
return price === null || price === undefined ? null : Number(price);
}
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
function withStaffPrice(resolved, staffPrice) {
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
return { price: staffPrice, tierId: null };
}
/**
* Compute effective unit price for an event option at a given time considering early-bird tiers.
* Only checks deadline (not stock). Used for display and as a legacy fallback.
@@ -65,9 +87,14 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
*
* @param {object} option - EventOption with price, earlyBirdTiers[]
* @param {number} requestedQty - quantity being purchased
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveOptionPrice(option, requestedQty = 1) {
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
}
async function resolveOptionTierPrice(option, requestedQty) {
const now = new Date();
// Only option-level tiers (no variant association)
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
@@ -115,9 +142,14 @@ async function resolveOptionPrice(option, requestedQty = 1) {
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
* @param {string} variantId
* @param {number} requestedQty
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
}
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
const now = new Date();
const variant = (option.variants || []).find(v => v.id === variantId);
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
@@ -172,6 +204,7 @@ async function refreshPricingForRegistration(registrationId) {
}
},
payments: true,
user: { select: { role: true } },
}
});
if (!registration) return { changed: false };
@@ -215,8 +248,8 @@ async function refreshPricingForRegistration(registrationId) {
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, newest.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
@@ -273,7 +306,8 @@ function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
* Compute total due for a registration at a given time.
*
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
* less the registration's coupon discount (`discountTotal`), never below zero.
*
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[]
@@ -291,6 +325,13 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
}
/** Sum of the item lines before any coupon discount. */
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
}
@@ -317,6 +358,7 @@ function attachComputedTotals(registration) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
@@ -332,6 +374,8 @@ module.exports = {
refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue,
computeRegistrationSubtotal,
staffPriceFor,
attachComputedTotals,
attachComputedTotalsToList,
};
+27 -39
View File
@@ -1,11 +1,14 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/**
* Generate tickets for a registration when payment status is "paid".
*
* Rules:
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
* line's places share ONE quantity ticket per registrationOption (see
* utils/attendees.js planLineTickets).
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
* - If duplicate ticket records exist for the same registrationOption (old data),
@@ -90,55 +93,40 @@ const generateTicketsForRegistration = async (registrationId) => {
});
}));
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
// ── Step 2: For each unique option, sync its tickets: one per named attendee, plus
// one shared quantity ticket for any unnamed places ──
// Re-read fresh list (some options may have been deleted above)
const freshOptions = await prisma.registrationOption.findMany({
where: { registrationId },
include: {
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } },
attendees: { orderBy: { order: 'asc' } },
}
});
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
const targetQty = option.quantity || 1;
const existingTickets = option.tickets || [];
if (existingTickets.length === 0) {
// Create one ticket
return prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: targetQty,
updatedAt: new Date()
}
});
}
// Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
const updates = [];
// Update quantity on primary if needed
if (primary.quantity !== targetQty) {
updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }));
}
// Delete unscanned duplicates
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
if (dups.length > 0) {
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
}
if (updates.length > 0) await Promise.all(updates);
return null;
const plan = planLineTickets({ quantity: option.quantity || 1 }, option.attendees || [], option.tickets || []);
await Promise.all([
plan.remove.length ? prisma.ticket.deleteMany({ where: { id: { in: plan.remove } } }) : null,
...plan.update.map(u => prisma.ticket.update({ where: { id: u.id }, data: { ...u.data, updatedAt: new Date() } })),
]);
return Promise.all(plan.create.map(c => prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: c.quantity,
attendeeId: c.attendeeId,
holderUserId: c.holderUserId,
updatedAt: new Date()
}
})));
}));
return perOptionResults.filter(Boolean);
return perOptionResults.flat();
} catch (error) {
console.error('Error generating tickets:', error);
throw error;
+6 -3
View File
@@ -47,9 +47,12 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n');
const { couponDiscountLine } = require('./pdfDocs');
const coupon = couponDiscountLine(reg);
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
].join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const due = totalDue ?? 0;
+104
View File
@@ -0,0 +1,104 @@
const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees');
const EVENT = {
allowNamedAttendees: true,
eventOptions: [
{ id: 'kid', name: 'Camper', isMainTicket: true },
{ id: 'shirt', name: 'T-shirt', isMainTicket: false },
],
};
const places = (n) => ({ [lineKey('kid', null)]: n });
describe('validateAttendees', () => {
test('cleans up names and contact details', () => {
const out = validateAttendees(EVENT, places(2), [
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
]);
expect(out).toEqual([
{ eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' },
{ eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null },
]);
});
test('nothing to check when no attendees are given', () => {
expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]);
});
test('refuses names on events that do not take them', () => {
expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/);
});
test('only main tickets can be named', () => {
expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/);
});
test('needs a name and a valid email', () => {
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/);
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/);
});
test('no more names than places, counting names already given', () => {
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/);
expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow();
expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/);
});
});
describe('planLineTickets', () => {
const scan = (q = 1) => [{ quantityRedeemed: q }];
test('no attendees: one shared ticket for the whole quantity', () => {
expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] });
});
test('every attendee gets their own ticket; unnamed places share one', () => {
const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []);
expect(plan.create).toEqual([
{ attendeeId: 'a1', holderUserId: 'u9', quantity: 1 },
{ attendeeId: 'a2', holderUserId: null, quantity: 1 },
{ attendeeId: null, holderUserId: null, quantity: 1 },
]);
});
test('naming every place replaces the shared ticket', () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]);
expect(plan.remove).toEqual(['t0']);
expect(plan.create).toHaveLength(2);
});
test('a scanned shared ticket is kept and never shrunk below what was used', () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]);
expect(plan.remove).toEqual([]);
expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]);
});
test('removing an attendee removes their unscanned ticket', () => {
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: [] },
]);
expect(plan).toEqual({ create: [], update: [], remove: ['t2'] });
});
test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => {
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() },
]);
expect(plan).toEqual({ create: [], update: [], remove: [] });
});
test('follows the attendee to a newly linked account', () => {
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]);
expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]);
});
test('existing single shared ticket is resized, duplicates dropped', () => {
const plan = planLineTickets({ quantity: 4 }, [], [
{ id: 't1', attendeeId: null, quantity: 2, usages: [] },
{ id: 't2', attendeeId: null, quantity: 2, usages: [] },
]);
expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] });
});
});
+176
View File
@@ -0,0 +1,176 @@
const {
normalizeCode,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
} = require('../src/utils/coupons');
const { computeRegistrationTotalDue, staffPriceFor } = require('../src/utils/pricing');
const NOW = new Date('2026-06-15T10:00:00Z');
const PAST = new Date('2026-06-01T00:00:00Z');
const FUTURE = new Date('2026-07-01T00:00:00Z');
const coupon = (over = {}) => ({
id: 'c1', code: 'STAFF50', type: 'percent', value: 50, stackEarlyBird: false, active: true,
validFrom: null, validUntil: null, maxUses: null, maxUsesPerUser: 1, minQuantity: null,
restrictRoles: [], restrictDomain: null, events: [], options: [], allowedUsers: [],
...over,
});
// 2 adult tickets at R200 (standard) and 1 T-shirt at R100.
const LINES = [
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 200, basePrice: 200 },
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 100, basePrice: 100 },
];
describe('normalizeCode', () => {
test('trims and uppercases', () => {
expect(normalizeCode(' staff50 ')).toBe('STAFF50');
expect(normalizeCode(null)).toBe('');
});
});
describe('computeCouponDiscount', () => {
test('percentage over the whole event', () => {
expect(computeCouponDiscount(coupon(), LINES)).toBe(250);
});
test('fixed amount, capped at what is in scope', () => {
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 120 }), LINES)).toBe(120);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 9999 }), LINES)).toBe(500);
});
test('comp covers only in-scope options', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'adult', variantId: null }] });
expect(computeCouponDiscount(c, LINES)).toBe(400);
});
test('scope can target a single variant', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'M' }] });
expect(computeCouponDiscount(c, LINES)).toBe(0);
const c2 = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'L' }] });
expect(computeCouponDiscount(c2, LINES)).toBe(100);
});
test('best price wins against early-bird when not stacking', () => {
// Early-bird R150 vs standard R200: 10% off standard = R180, early-bird already beats it.
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10 }), eb)).toBe(0);
// 50% off standard = R100, beats R150 early-bird by R50.
expect(computeCouponDiscount(coupon({ value: 50 }), eb)).toBe(50);
// Fixed R80 off standard = R120, beats R150 by R30.
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80 }), eb)).toBe(30);
});
test('stacking applies on top of the early-bird price', () => {
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10, stackEarlyBird: true }), eb)).toBe(15);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80, stackEarlyBird: true }), eb)).toBe(80);
});
test('never goes below zero or above the price', () => {
expect(computeCouponDiscount(coupon({ value: 150 }), LINES)).toBe(500);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: -10 }), LINES)).toBe(0);
expect(computeCouponDiscount(coupon(), [])).toBe(0);
});
});
describe('validateCoupon', () => {
const ctx = (over = {}) => ({ eventId: 'e1', user: { id: 'u1', role: 'user', email: 'a@church.org' }, now: NOW, usesTotal: 0, usesByUser: 0, lines: LINES, ...over });
test('accepts an unrestricted active code', () => {
expect(validateCoupon(coupon(), ctx())).toEqual({ ok: true });
});
test('rejects inactive, not-yet-valid and expired codes', () => {
expect(validateCoupon(coupon({ active: false }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ validFrom: FUTURE }), ctx()).message).toMatch(/isn't valid yet/);
expect(validateCoupon(coupon({ validUntil: PAST }), ctx()).message).toMatch(/expired/);
});
test('limits to the coupon events when any are set', () => {
expect(validateCoupon(coupon({ events: [{ eventId: 'e2' }] }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ events: [{ eventId: 'e1' }, { eventId: 'e2' }] }), ctx()).ok).toBe(true);
});
test('restricted codes need a known account matching any restriction', () => {
const staffOnly = coupon({ restrictRoles: ['staff', 'supervisor', 'admin'] });
expect(validateCoupon(staffOnly, ctx({ user: null })).message).toMatch(/Log in/);
expect(validateCoupon(staffOnly, ctx()).ok).toBe(false);
expect(validateCoupon(staffOnly, ctx({ user: { id: 'u1', role: 'staff', email: 'x@y.z' } })).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: '@church.org' }), ctx()).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: 'other.org' }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ allowedUsers: [{ userId: 'u1' }] }), ctx()).ok).toBe(true);
});
test('enforces total and per-user use limits', () => {
expect(validateCoupon(coupon({ maxUses: 5 }), ctx({ usesTotal: 5 })).message).toMatch(/fully used/);
expect(validateCoupon(coupon({ maxUsesPerUser: 1 }), ctx({ usesByUser: 1 })).message).toMatch(/already used/);
expect(validateCoupon(coupon({ maxUsesPerUser: null }), ctx({ usesByUser: 9 })).ok).toBe(true);
});
test('needs qualifying items and the minimum quantity', () => {
expect(validateCoupon(coupon({ options: [{ eventOptionId: 'other' }] }), ctx()).message).toMatch(/doesn't apply/);
expect(validateCoupon(coupon({ minQuantity: 4 }), ctx()).message).toMatch(/at least 4/);
expect(validateCoupon(coupon({ minQuantity: 3 }), ctx()).ok).toBe(true);
});
});
describe('buildCouponLines', () => {
test('one line per tranche; early-bird tranches keep the standard price as base', () => {
const reg = {
registrationOptions: [{
eventOptionId: 'adult', variantId: null, quantity: 3,
eventOption: { price: 200, variants: [] },
tranches: [
{ quantity: 2, priceSnapshot: 150, appliedTierId: 'tier1' },
{ quantity: 1, priceSnapshot: 200, appliedTierId: null },
],
}],
};
expect(buildCouponLines(reg)).toEqual([
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 150, basePrice: 200 },
{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 200, basePrice: 200 },
]);
});
});
describe('computeRegistrationTotalDue with a discount', () => {
const reg = (discountTotal) => ({
discountTotal,
registrationOptions: [{ quantity: 2, tranches: [{ quantity: 2, priceSnapshot: 100 }] }],
});
test('subtracts the coupon discount', () => {
expect(computeRegistrationTotalDue(reg(50), NOW)).toBe(150);
});
test('never goes below zero', () => {
expect(computeRegistrationTotalDue(reg(500), NOW)).toBe(0);
});
});
describe('staffPriceFor', () => {
const option = { price: 200, staffPrice: 50, variants: [{ id: 'v1', staffPrice: 20 }, { id: 'v2', staffPrice: null }] };
test('applies to Staff and above only', () => {
expect(staffPriceFor(option, null, { role: 'staff' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'admin' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'user' })).toBeNull();
expect(staffPriceFor(option, null, null)).toBeNull();
});
test('a variant staff price overrides the option one', () => {
expect(staffPriceFor(option, 'v1', { role: 'staff' })).toBe(20);
expect(staffPriceFor(option, 'v2', { role: 'staff' })).toBe(50);
});
});
describe('describeCoupon', () => {
test('summarises each type', () => {
expect(describeCoupon(coupon())).toBe('50% off');
expect(describeCoupon(coupon({ type: 'fixed', value: 25 }))).toBe('R25.00 off');
expect(describeCoupon(coupon({ type: 'comp' }))).toBe('Free');
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.12.0",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.12.0",
"version": "1.14.0",
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.12.0",
"version": "1.14.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -18,6 +18,10 @@ const ACTIONS = [
"event_updated",
"event_deleted",
"settings_updated",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -30,6 +34,10 @@ const ACTION_LABELS: Record<string, string> = {
event_updated: "Event updated",
event_deleted: "Event deactivated",
settings_updated: "Settings updated",
coupon_created: "Coupon created",
coupon_updated: "Coupon edited",
coupon_disabled: "Coupon turned off",
coupon_applied_manual: "Coupon applied/removed by staff",
};
interface AuditLogEntry {
+2 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ 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 },
@@ -145,7 +145,7 @@ function EventTicketsContent() {
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
const qty = t.quantity || 1;
const holder = t.user?.name || t.userId || "";
const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || "");
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
t.qrCode || t.id
)}`;
@@ -375,7 +375,10 @@ export default function TicketScanningPage() {
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
)}
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
</div>
@@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -100,6 +102,11 @@ export default function AtTheDoorPage() {
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<any | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
const [doorNaming, setDoorNaming] = useState(false);
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
const [confirming, setConfirming] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -153,6 +160,7 @@ export default function AtTheDoorPage() {
});
setEventOptions(ev.options || ev.eventOptions || []);
setDoorAllowsNames(!!ev.allowNamedAttendees);
} catch {
setEventOptions([]);
}
@@ -226,14 +234,28 @@ export default function AtTheDoorPage() {
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
});
let message = "Registration updated";
if (doorCoupon) {
// Applied after the items are saved, so it's worked out on the final items.
try {
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
method: "POST",
authToken: token,
body: { code: doorCoupon.code },
});
message = `Registration updated — code ${doorCoupon.code} applied`;
} catch (ce: any) {
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
}
}
setShowOptionsModal(false);
setPendingEditReg(null);
setInfo("Registration updated");
setInfo(message);
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
@@ -249,9 +271,14 @@ export default function AtTheDoorPage() {
},
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
...(doorAllowsNames && doorNaming && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
: {}),
}
});
setShowOptionsModal(false);
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
handleRegistrationCreated(res);
}
} catch (e: any) {
@@ -283,6 +310,10 @@ export default function AtTheDoorPage() {
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setDoorCoupon(null);
setDoorNaming(false);
setDoorEntries({});
setDoorDelivery("payer");
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
};
@@ -319,6 +350,7 @@ export default function AtTheDoorPage() {
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
setDoorCoupon(null);
setShowOptionsModal(true);
};
@@ -463,6 +495,17 @@ export default function AtTheDoorPage() {
confirming={confirming}
isEdit={!!pendingEditReg}
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
eventId={eventId}
token={token}
coupon={doorCoupon}
setCoupon={setDoorCoupon}
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
names={doorAllowsNames && !pendingEditReg ? {
enabled: doorNaming, onEnabledChange: setDoorNaming,
entries: doorEntries, onEntriesChange: setDoorEntries,
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
} : null}
/>
<DonationModal
open={showDonationModal}
@@ -1147,9 +1190,16 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
if (!open) return null;
const basket = Object.entries(quantities as Record<string, number>)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
});
const safeOptions: any[] = options || [];
const total = safeOptions.reduce((sum: number, o: any) => {
@@ -1254,8 +1304,31 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
One or more items are below the quantity already issued as tickets. Please increase the selection.
</div>
)}
{names && (
<div className="mb-3 max-h-64 overflow-y-auto">
<AttendeeNamesEditor
options={safeOptions}
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
{...names}
payerLabel="the person registering"
/>
</div>
)}
{existingCode ? (
<div className="mb-3 text-sm text-green-700">Code <span className="font-mono font-semibold">{existingCode}</span> is applied to this registration.</div>
) : (
<div className="mb-3">
<CouponCodeField eventId={eventId} basket={basket} token={token} forUserId={couponForUserId} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="flex justify-between items-center">
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && !isEdit && (
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
)}
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
Cancel
@@ -0,0 +1,596 @@
"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 { TicketPercent, Plus, X, Search } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
type EventLite = {
id: string;
title: string;
startDate: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
type Coupon = {
id: string;
code: string;
description: string | null;
type: CouponType;
value: number;
stackEarlyBird: boolean;
validFrom: string | null;
validUntil: string | null;
maxUses: number | null;
maxUsesPerUser: number | null;
minQuantity: number | null;
restrictRoles: Role[];
restrictDomain: string | null;
active: boolean;
summary: string;
uses: number;
totalDiscount: number;
events: { eventId: string; event: { id: string; title: string } }[];
options: { eventOptionId: string; variantId: string | null; eventOption: { name: string; eventId: string }; variant: { name: string } | null }[];
allowedUsers: { userId: string; user: { id: string; name: string; email: string } }[];
};
type Redemption = {
id: string;
discountAmount: number;
createdAt: string;
releasedAt: string | null;
user: { name: string; email: string };
appliedBy: { name: string } | null;
registration: { status: string; event: { title: string } };
};
type Draft = {
code: string;
description: string;
type: CouponType;
value: string;
stackEarlyBird: boolean;
validFrom: string;
validUntil: string;
maxUses: string;
maxUsesPerUser: string;
minQuantity: string;
eventIds: string[];
options: { eventOptionId: string; variantId: string | null }[];
restrictTo: "anyone" | "roles" | "users" | "domain";
restrictRoles: Role[];
restrictDomain: string;
users: { id: string; name: string; email: string }[];
active: boolean;
};
const blankDraft = (): Draft => ({
code: "", description: "", type: "percent", value: "", stackEarlyBird: false,
validFrom: "", validUntil: "", maxUses: "", maxUsesPerUser: "1", minQuantity: "",
eventIds: [], options: [], restrictTo: "anyone", restrictRoles: ["staff", "supervisor", "admin"],
restrictDomain: "", users: [], active: true,
});
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocalInput = (iso: string | null) => {
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())}`;
};
function draftFromCoupon(c: Coupon): Draft {
const restrictTo: Draft["restrictTo"] = c.allowedUsers.length ? "users" : c.restrictRoles.length ? "roles" : c.restrictDomain ? "domain" : "anyone";
return {
code: c.code, description: c.description || "", type: c.type, value: c.type === "comp" ? "" : String(c.value),
stackEarlyBird: c.stackEarlyBird, validFrom: toLocalInput(c.validFrom), validUntil: toLocalInput(c.validUntil),
maxUses: c.maxUses ? String(c.maxUses) : "", maxUsesPerUser: c.maxUsesPerUser ? String(c.maxUsesPerUser) : "",
minQuantity: c.minQuantity ? String(c.minQuantity) : "", eventIds: c.events.map(e => e.eventId),
options: c.options.map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId })),
restrictTo, restrictRoles: c.restrictRoles.length ? c.restrictRoles : ["staff", "supervisor", "admin"],
restrictDomain: c.restrictDomain || "", users: c.allowedUsers.map(u => u.user), active: c.active,
};
}
function bodyFromDraft(d: Draft) {
return {
code: d.code.trim(),
description: d.description.trim() || null,
type: d.type,
value: d.type === "comp" ? 0 : Number(d.value),
stackEarlyBird: d.stackEarlyBird,
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
maxUses: d.maxUses || null,
maxUsesPerUser: d.maxUsesPerUser || null,
minQuantity: d.minQuantity || null,
eventIds: d.eventIds,
options: d.options,
restrictRoles: d.restrictTo === "roles" ? d.restrictRoles : [],
restrictDomain: d.restrictTo === "domain" ? d.restrictDomain.trim() : null,
userIds: d.restrictTo === "users" ? d.users.map(u => u.id) : [],
active: d.active,
};
}
const fmtDate = (iso: string | null) => (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";
function couponStatus(c: Coupon): { label: string; cls: string } {
const now = Date.now();
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
return { label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
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 [coupons, setCoupons] = useState<Coupon[]>([]);
const [events, setEvents] = useState<EventLite[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setFetching(true);
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
]);
setCoupons(c || []);
setEvents(ev || []);
} catch (e: any) {
setError(e?.message || "Failed to load coupons");
} finally {
setFetching(false);
}
}, [token, setError]);
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
}, [coupons, query, showOff]);
const toggleActive = async (c: Coupon) => {
try {
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
load();
} catch (e: any) {
setError(e?.message || "Failed to update coupon");
}
};
const openUses = async (c: Coupon) => {
try {
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
setViewing({ coupon: c, redemptions: full.redemptions || [] });
} catch (e: any) {
setError(e?.message || "Failed to load uses");
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<TicketPercent className="w-5 h-5 text-brand-600" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Coupon codes</h1>
<p className="text-sm text-gray-500">Discounts for staff, leaders, volunteers and promotions</p>
</div>
</div>
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft() })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
>
<Plus className="w-4 h-4" /> New code
</button>
)}
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage coupon codes.</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="relative w-full sm:w-64">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
</label>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Events</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Valid</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(c => {
const status = couponStatus(c);
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
<div className="text-xs text-amber-700 mt-0.5">
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
</div>
)}
</TableCell>
<TableCell>
<div>{c.summary}</div>
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
</TableCell>
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
<TableCell className="text-sm">
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
{c.active ? "Turn off" : "Turn on"}
</button>
</TableCell>
</TableRow>
);
})}
{!fetching && visible.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
{editing && (
<CouponModal
token={token!}
events={events}
couponId={editing.id}
initial={editing.draft}
onClose={() => setEditing(null)}
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
/>
)}
{viewing && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto">
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
)}
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
couponId: string | null;
initial: Draft;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [d, setD] = useState<Draft>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
useEffect(() => {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
return () => clearTimeout(t);
}, [userQuery, d.restrictTo, token]);
const toggleEvent = (id: string) => {
const eventIds = d.eventIds.includes(id) ? d.eventIds.filter(x => x !== id) : [...d.eventIds, id];
// Drop scoped options from events that are no longer selected.
const keepOptionIds = new Set(events.filter(e => eventIds.includes(e.id)).flatMap(e => e.eventOptions.map(o => o.id)));
setD(prev => ({ ...prev, eventIds, options: prev.options.filter(o => keepOptionIds.has(o.eventOptionId)) }));
};
const scopeKey = (o: { eventOptionId: string; variantId: string | null }) => `${o.eventOptionId}::${o.variantId || ""}`;
const toggleScope = (eventOptionId: string, variantId: string | null) => {
const key = scopeKey({ eventOptionId, variantId });
set("options", d.options.some(o => scopeKey(o) === key) ? d.options.filter(o => scopeKey(o) !== key) : [...d.options, { eventOptionId, variantId }]);
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (d.restrictTo === "users" && d.users.length === 0) { setError("Add at least one person, or choose another restriction."); return; }
setSaving(true);
try {
const body = bodyFromDraft(d);
if (couponId) {
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
onSaved(`${body.code.toUpperCase()} saved.`);
} else {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err: any) {
setError(err?.message || "Failed to save");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</div>
</form>
</div>
);
}
@@ -50,6 +50,7 @@ type Variant = {
name: string;
price: string; // empty → use option base price
stockLimit: string;
staffPrice?: string; // empty → use the option's staff price
earlyBirdTiers: Tier[];
};
@@ -59,6 +60,7 @@ type OptionDraft = {
price: string;
isMainTicket: boolean;
stockLimit: string;
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
};
@@ -193,6 +195,11 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Staff and above pay this automatically when it's lower than the price they'd otherwise pay">Staff price (R)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -253,6 +260,11 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Staff price</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20" placeholder="(option)"
value={v.staffPrice ?? ""} onChange={e => updVariant(vi, { staffPrice: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
</div>
))}
@@ -757,7 +769,7 @@ interface EventDraft {
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string;
location: string; allowNamedAttendees: boolean;
}
const blankDraft = (location = ""): EventDraft => ({
@@ -765,7 +777,7 @@ const blankDraft = (location = ""): EventDraft => ({
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location,
location, allowNamedAttendees: false,
});
const blankOptions = (): OptionDraft[] => [
@@ -800,6 +812,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
allowNamedAttendees: !!ev.allowNamedAttendees,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
} : blankDraft(settings.org_address || ""));
@@ -815,6 +828,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
@@ -823,6 +837,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
@@ -908,13 +923,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), variants: variantsPayload }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -946,7 +962,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
@@ -984,6 +1000,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const variantsPayload = opt.variants.map((v, vi) => ({
name: v.name, price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
let optId: string;
@@ -992,13 +1009,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = created?.id;
}
@@ -1069,6 +1086,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
requiresRegistration: draft.requiresRegistration,
allowNamedAttendees: draft.allowNamedAttendees,
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
@@ -1323,6 +1341,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<input type="checkbox" className="mt-0.5" checked={!draft.requiresAuth} onChange={e => upd({ requiresAuth: !e.target.checked })} />
<span><strong>Allow guest registration</strong> — attendees can register without creating an account</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.allowNamedAttendees} onChange={e => upd({ allowNamedAttendees: e.target.checked })} />
<span><strong>Named attendees (group tickets)</strong> — someone registering several people can give each a name, so each gets their own ticket and attendee form</span>
</label>
{mode === "edit" && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.isActive} onChange={e => upd({ isActive: e.target.checked })} />
@@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
import { UserPlus, CreditCard } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -215,7 +217,18 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [userQuery, allUsers]);
// Discount code, checked against the picked account (or a walk-in when none is picked).
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
// Named attendees (group tickets), when the event takes them.
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
const manualQtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}::${variantId}` : optionId] || 0;
const selectedEventAllowsNames = !!events.find(ev => ev.id === selectedEventId)?.allowNamedAttendees;
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
@@ -308,9 +321,15 @@ export default function ManualRegistrationPage() {
options: opts,
user: guest,
notificationPreference: resolvedPref,
...(coupon ? { couponCode: coupon.code } : {}),
...(selectedEventAllowsNames && namingOthers && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
: {}),
}
});
setMessage("Manual registration created successfully.");
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -325,6 +344,11 @@ export default function ManualRegistrationPage() {
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setCoupon(null);
setPickedUserId(null);
setNamingOthers(false);
setAttendeeEntries({});
setDelivery("payer");
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
@@ -504,7 +528,7 @@ export default function ManualRegistrationPage() {
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); setPickedUserId(null); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
✕ Clear
@@ -581,8 +605,43 @@ export default function ManualRegistrationPage() {
)}
</div>
{selectedEventAllowsNames && (
<div className="mt-6">
<AttendeeNamesEditor
options={options}
qtyFor={manualQtyFor}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
payerLabel="the person registering"
/>
</div>
)}
{selectedEventId && totalDue > 0 && (
<div className="mt-6 max-w-md">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={token}
forUserId={pickedUserId}
applied={coupon}
onChange={setCoupon}
/>
</div>
)}
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<div className="text-sm">
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
</div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ 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 },
+87 -2
View File
@@ -8,6 +8,7 @@ import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
@@ -381,7 +382,7 @@ export default function UserDashboardPage() {
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
</div>
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
${t.attendee?.name ? `<div class="qty">Name: <strong>${t.attendee.name}</strong></div>` : `<div class="qty">Qty: <strong>${qty}</strong></div>`}
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">Ticket ID: ${t.id}</div>
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
@@ -497,6 +498,52 @@ export default function UserDashboardPage() {
return false;
}, [activeReg, activeEventHasForm]);
// Discount code on an existing registration
const [codeInput, setCodeInput] = useState("");
const [codeBusy, setCodeBusy] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const applyCodeToActive = async () => {
if (!token || !activeRegId || !codeInput.trim()) return;
setCodeBusy(true);
setCodeError(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
method: 'POST',
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e: any) {
setCodeError(e?.message || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
try {
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
};
// Cancel registration state
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@@ -936,8 +983,11 @@ export default function UserDashboardPage() {
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
Qty: {t.quantity || 1}
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
</div>
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
)}
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
@@ -1034,10 +1084,35 @@ export default function UserDashboardPage() {
</ul>
</div>
);
const activeCoupon = (activeReg.couponRedemptions || [])[0];
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
{Number(activeReg.discountTotal || 0) > 0 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
<input
value={codeInput}
onChange={e => { setCodeInput(e.target.value.toUpperCase()); setCodeError(null); }}
placeholder="Have a discount code?"
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm font-mono uppercase"
aria-label="Discount code"
/>
<button
onClick={applyCodeToActive}
disabled={codeBusy || !codeInput.trim()}
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
@@ -1142,6 +1217,16 @@ export default function UserDashboardPage() {
</div>
)}
</div>
{token && (
<AttendeesPanel
registration={activeReg}
token={token}
canEdit={canModifyActive}
hasForm={!!(activeReg.event?.form?.fields || []).length}
onSaved={reloadAfterAttendees}
onMessage={setInfo}
/>
)}
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
+36 -14
View File
@@ -15,6 +15,8 @@ function FormsContent() {
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
// Set when a named attendee opens the link sent to them — show only their form.
const onlyAttendeeId = search.get("attendeeId");
const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
@@ -22,8 +24,8 @@ function FormsContent() {
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
// Local entry state for new responses, keyed by form block (see `blocks` below)
const [formsData, setFormsData] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
(async () => {
@@ -65,7 +67,28 @@ function FormsContent() {
}
}, [registration]);
const remaining = Math.max(0, requiredCount - submittedCount);
// One form block per place still to fill: each named attendee (group tickets) by name,
// then numbered blocks for unnamed places. Unnamed blocks keep their old numeric keys,
// so drafts saved before attendees existed still prefill.
type FormBlock = { key: string; label: string; attendeeId?: string };
const blocks = useMemo((): FormBlock[] => {
if (!registration) return [];
const responses = registration.formResponses || [];
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
const attendees = (registration.attendees || []) as any[];
const named = attendees
.filter(a => !answered.has(a.id))
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
}));
const all: FormBlock[] = [...named, ...unnamed];
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
}, [registration, requiredCount, onlyAttendeeId]);
const remaining = blocks.length;
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
// Determine if all required fields are filled for each attendee form to be submitted
const canSubmit = useMemo(() => {
@@ -73,8 +96,8 @@ function FormsContent() {
const reqFields = (eventForm.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < remaining; i++) {
const data = formsData[i] || {};
for (const b of blocks) {
const data = formsData[b.key] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
@@ -83,16 +106,13 @@ function FormsContent() {
}
}
return true;
}, [eventForm, formsData, remaining]);
}, [eventForm, formsData, remaining, blocks]);
const submit = async () => {
if (!registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
if (payload.length === 0) {
// Nothing left to submit – still go to success page to continue the flow
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
@@ -191,7 +211,7 @@ function FormsContent() {
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
@@ -214,9 +234,9 @@ function FormsContent() {
<div className="border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
<div className="space-y-4">
{Array.from({ length: remaining }, (_, idx) => (
{blocks.map(({ key: idx, label }) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
<div className="font-medium mb-2">{label}</div>
{eventForm.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
@@ -252,7 +272,9 @@ function FormsContent() {
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
{!onlyAttendeeId && (
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
)}
</div>
</div>
)}
@@ -3,6 +3,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 { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -39,6 +41,7 @@ type Event = {
eventOptions?: EventOption[];
form?: { isRequired: boolean; fields?: any[] } | null;
requiresAuth?: boolean;
allowNamedAttendees?: boolean;
};
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
@@ -246,6 +249,14 @@ export default function RegisterForm({ event }: { event: Event }) {
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
const qtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}:${variantId}` : optionId] || 0;
const namedAttendees = () => (event.allowNamedAttendees && namingOthers
? attendeesPayload(event.eventOptions || [], qtyFor, attendeeEntries)
: []);
// Guest details (for unauthenticated free-event registration)
const [guestName, setGuestName] = useState("");
@@ -292,7 +303,11 @@ export default function RegisterForm({ event }: { event: Event }) {
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const buildItems = () => {
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;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
@@ -304,7 +319,7 @@ export default function RegisterForm({ event }: { event: Event }) {
}
});
return items;
};
}
const submitRegistration = async () => {
const items = buildItems();
@@ -322,18 +337,30 @@ export default function RegisterForm({ event }: { event: Event }) {
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items },
body: {
eventId,
options: items,
...(coupon ? { couponCode: coupon.code } : {}),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
authToken: token,
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
setError(`You're registered, but the code couldn't be applied: ${res.couponError} Continuing at full price…`);
setTimeout(() => { window.location.href = next; }, 4000);
return;
}
window.location.href = next;
}
} catch (e: any) {
setError(e?.message || "Registration failed");
@@ -357,7 +384,10 @@ export default function RegisterForm({ event }: { event: Event }) {
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined },
body: {
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
@@ -475,8 +505,37 @@ export default function RegisterForm({ event }: { event: Event }) {
);
})()}
{event.allowNamedAttendees && !eventSoldOut && (isLoggedIn || showGuestForm) && (
<div className="mt-6">
<AttendeeNamesEditor
options={event.eventOptions || []}
qtyFor={qtyFor}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
/>
</div>
)}
{isLoggedIn && total > 0 && !eventSoldOut && (
<div className="mt-6">
<CouponCodeField eventId={eventId} basket={basket} token={token} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="mt-6 flex items-center justify-between">
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && (
<div className="text-sm text-gray-500">
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
+53 -2
View File
@@ -3,6 +3,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload } from "@/components/shared/AttendeeNamesEditor";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -38,6 +40,7 @@ type KioskEvent = {
picture?: string;
eventOptions?: EventOption[];
form?: EventForm | null;
allowNamedAttendees?: boolean;
};
type Screen = "setup" | "form" | "eventform" | "confirmation";
@@ -134,6 +137,10 @@ export default function SelfServicePage() {
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
// Set while a supervisor with two-factor on still has to enter their code.
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
const [setupCode, setSetupCode] = useState("");
@@ -408,7 +415,15 @@ export default function SelfServicePage() {
// activation email/WhatsApp at them afterward.
skipActivationNotice: !createAccount,
notificationPreference: notificationPref,
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
};
const kioskNames = selectedEvent?.allowNamedAttendees && namingOthers
? attendeesPayload(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries)
: [];
if (kioskNames.length > 0) {
payload.attendees = kioskNames;
payload.ticketDelivery = delivery;
}
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
}
@@ -421,7 +436,7 @@ export default function SelfServicePage() {
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || total === 0);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
@@ -522,6 +537,10 @@ export default function SelfServicePage() {
}
function resetForm() {
setKioskCoupon(null);
setNamingOthers(false);
setAttendeeEntries({});
setDelivery("payer");
setVisitorName("");
setVisitorEmail("");
setVisitorPhone("");
@@ -989,9 +1008,41 @@ export default function SelfServicePage() {
);
})}
</div>
{selectedEvent?.allowNamedAttendees && (
<div className="mt-4">
<AttendeeNamesEditor
options={selectedEvent.eventOptions || []}
qtyFor={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
/>
</div>
)}
{total > 0 && selectedEventId && (
<div className="mt-4">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={supervisorToken}
forUserId={matchedAccount?.id ?? null}
applied={kioskCoupon}
onChange={setKioskCoupon}
/>
</div>
)}
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
Total: {fmtCurrency(total)}
{kioskCoupon && kioskCoupon.discount > 0 && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
</div>
)}
</div>
@@ -13,6 +13,7 @@ import {
FileBarChart,
BarChart3,
ClipboardList,
TicketPercent,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -33,6 +34,7 @@ export const REPORTS = [
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
+111 -3
View File
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -173,6 +173,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
const [discountRows, setDiscountRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -282,6 +283,19 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
const loadDiscounts = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<any[]>(`/api/coupons/redemptions?${query.toString()}`, { authToken: token });
} catch (e) {
return [];
}
};
const loadCashupAudit = async (eventIds: string[]) => {
if (!token) return [] as any[];
const query = new URLSearchParams();
@@ -298,6 +312,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
};
// Discounts report: one row per code per event.
const discountSummary = useMemo(() => {
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
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;
row.total += Number(r.discountAmount || 0);
byKey.set(key, row);
}
const rows = Array.from(byKey.values()).sort((a, b) => b.total - a.total);
const byCode = new Map<string, number>();
rows.forEach(r => byCode.set(r.code, (byCode.get(r.code) || 0) + r.total));
return {
rows,
totalUses: rows.reduce((s, r) => s + r.uses, 0),
totalValue: rows.reduce((s, r) => s + r.total, 0),
codes: byCode.size,
chart: Array.from(byCode.entries()).map(([label, value]) => ({ label, value })),
};
}, [discountRows]);
// View handler according to selected report
const onView = async () => {
setBusy(true);
@@ -351,6 +388,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
} else if (report === "cashupAudit") {
const rows = await loadCashupAudit(selectedEventIds);
setAuditRows(rows);
} else if (report === "discounts") {
setDiscountRows(await loadDiscounts(selectedEventIds));
}
setReady(true);
} finally {
@@ -924,6 +963,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
};
}
if (report === "discounts") {
return {
title: 'Discounts (coupon codes)', 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' },
],
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.',
table: {
columns: ["Code", "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))],
],
},
};
}
if (report === "cashup") {
const rows: (string | number)[][] = [];
const incomeByMethod: Record<string, number> = {};
@@ -958,7 +1016,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0);
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0);
paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0);
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
@@ -972,6 +1030,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
if (f.discountsTotal > 0) rows.push([title, "", "Less discount codes", -Number(f.discountsTotal.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", "", ""]);
@@ -1108,6 +1167,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
financeReport: "Finance Report",
profitReport: "Profit Report",
cashupAudit: "Cashup Audit Trail",
discounts: "Discounts Report",
};
const doPrintPdf = async () => {
@@ -1558,6 +1618,54 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{report === "discounts" && (
<div>
{discountSummary.rows.length === 0 ? (
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
) : (
<>
<StatTileRow>
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Code</th>
<th className="py-2 px-3">Discount</th>
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Uses</th>
<th className="py-2 px-3 text-right">Value (R)</th>
</tr>
</thead>
<tbody>
{discountSummary.rows.map(row => (
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
<td className="py-2 px-3 font-mono">{row.code}</td>
<td className="py-2 px-3">{row.summary}</td>
<td className="py-2 px-3">{row.event}</td>
<td className="py-2 px-3 text-right">{row.uses}</td>
<td className="py-2 px-3 text-right">R {row.total.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3" colSpan={3}>Total</td>
<td className="py-2 px-3 text-right">{discountSummary.totalUses}</td>
<td className="py-2 px-3 text-right">R {discountSummary.totalValue.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
</>
)}
</div>
)}
{report === "donations" && (
<div>
{Object.keys(paymentsByEvent).length === 0 ? (
@@ -1878,7 +1986,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="What was sold" value={`R ${(f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" />
<StatTile icon={ShoppingCart} label={f.discountsTotal > 0 ? "What was sold (after discounts)" : "What was sold"} value={`R ${((f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0)).toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid by (all methods)" value={`R ${METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" />
<StatTile icon={HandHeart} label="Donations counted as profit" value={`R ${(f.unallocatedDonationsTotal || 0).toFixed(2)}`} tone="rose" />
<StatTile icon={FileSpreadsheet} label="Total costs" value={`R ${(f.totalCosts || 0).toFixed(2)}`} tone="amber" />
@@ -0,0 +1,130 @@
"use client";
import React from "react";
import { Users } from "lucide-react";
export type AttendeeOption = {
id: string;
name: string;
isMainTicket?: boolean;
variants?: { id: string; name: string }[] | null;
};
export type AttendeeEntry = { name: string; email: string; phone: string };
export type AttendeeEntries = Record<string, AttendeeEntry>;
export type TicketDelivery = "payer" | "each";
export type AttendeePayload = { eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string };
type Slot = { key: string; eventOptionId: string; variantId: string | null; label: string };
/**
* One slot per main-ticket place in the basket. `qtyFor(optionId, variantId)` reads the
* caller's quantities (each page keys its quantity map differently).
*/
function slotsFor(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number): Slot[] {
const slots: Slot[] = [];
for (const o of options) {
if (!o.isMainTicket) continue;
const variants = o.variants || [];
const lines = variants.length > 0 ? variants.map(v => ({ variantId: v.id, label: `${o.name} (${v.name})` })) : [{ variantId: null, label: o.name }];
for (const line of lines) {
const qty = qtyFor(o.id, line.variantId);
for (let i = 0; i < qty; i++) {
slots.push({ key: `${o.id}::${line.variantId || ""}#${i}`, eventOptionId: o.id, variantId: line.variantId, label: `${line.label} ${i + 1}` });
}
}
}
return slots;
}
/** The named places as the API expects them (unnamed places are left out). */
export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries): AttendeePayload[] {
return slotsFor(options, qtyFor)
.map(s => ({ s, e: entries[s.key] }))
.filter(({ e }) => e && e.name.trim())
.map(({ s, e }) => ({
eventOptionId: s.eventOptionId,
...(s.variantId ? { variantId: s.variantId } : {}),
name: e.name.trim(),
...(e.email.trim() ? { email: e.email.trim() } : {}),
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
}));
}
/**
* "Registering for other people?" — a name (and optional email/phone) for each main
* ticket place, plus who gets the tickets. Named places get their own ticket; any left
* blank share one ticket as usual.
*/
export function AttendeeNamesEditor({
options,
qtyFor,
enabled,
onEnabledChange,
entries,
onEntriesChange,
delivery,
onDeliveryChange,
payerLabel = "me",
}: {
options: AttendeeOption[];
qtyFor: (optionId: string, variantId: string | null) => number;
enabled: boolean;
onEnabledChange: (v: boolean) => void;
entries: AttendeeEntries;
onEntriesChange: (v: AttendeeEntries) => void;
delivery: TicketDelivery;
onDeliveryChange: (v: TicketDelivery) => void;
payerLabel?: string;
}) {
const slots = slotsFor(options, qtyFor);
const set = (key: string, field: keyof AttendeeEntry, value: string) => {
const prev = entries[key] || { name: "", email: "", phone: "" };
onEntriesChange({ ...entries, [key]: { ...prev, [field]: value } });
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
<Users className="w-4 h-4 text-brand-600" />
Registering for other people?
</label>
{enabled && (
<div className="mt-3 space-y-3">
{slots.length === 0 ? (
<p className="text-sm text-gray-500">Choose how many tickets first, then add a name for each person.</p>
) : (
<>
<p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>
{slots.map(s => {
const e = entries[s.key] || { name: "", email: "", phone: "" };
return (
<div key={s.key} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-2">{s.label}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className={inputCls} placeholder="Full name" value={e.name} onChange={ev => set(s.key, "name", ev.target.value)} aria-label={`${s.label} name`} />
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
</div>
</div>
);
})}
<fieldset className="pt-1">
<legend className="text-sm font-medium text-gray-700 mb-1">Send the tickets</legend>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="ticket-delivery" checked={delivery === "payer"} onChange={() => onDeliveryChange("payer")} /> All to {payerLabel}
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="ticket-delivery" checked={delivery === "each"} onChange={() => onDeliveryChange("each")} /> Each person their own (by their email or WhatsApp; anyone without contact details goes to {payerLabel})
</label>
</fieldset>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import React, { useMemo, useState } from "react";
import { Users, Send } from "lucide-react";
import { apiFetch } from "@/lib/api";
type Attendee = { id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null; ticket?: { id: string; isUsed: boolean } | null };
type Row = { id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean };
/**
* Named attendees on a registration (group tickets): shows who each main-ticket place
* is for, and lets the payer (or staff) name places, change details, choose where the
* tickets go, and send an attendee the link to their own form.
*/
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
registration: any;
token: string;
canEdit: boolean;
hasForm: boolean;
onSaved: () => void;
onMessage: (msg: string) => void;
}) {
const attendees: Attendee[] = registration.attendees || [];
const [editing, setEditing] = useState(false);
const [rows, setRows] = useState<Row[]>([]);
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// One row per main-ticket place: named ones first (in order), then blanks.
const buildRows = useMemo(() => () => {
const out: Row[] = [];
for (const ro of registration.registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
const label = `${ro.eventOption?.name || "Ticket"}${ro.variant?.name ? ` (${ro.variant.name})` : ""}`;
const named = attendees.filter(a => a.eventOptionId === ro.eventOptionId && (a.variantId || null) === (ro.variantId || null));
for (let i = 0; i < (ro.quantity || 0); i++) {
const a = named[i];
out.push({
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
locked: !!a?.ticket?.isUsed,
});
}
}
return out;
}, [registration, attendees]);
if (!registration.event?.allowNamedAttendees && attendees.length === 0) return null;
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
const update = (i: number, field: "name" | "email" | "phone", value: string) => setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
const save = async () => {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
method: "PUT",
authToken: token,
body: {
ticketDelivery: delivery,
attendees: rows.filter(r => r.name.trim()).map(r => ({
...(r.id ? { id: r.id } : {}),
eventOptionId: r.eventOptionId,
...(r.variantId ? { variantId: r.variantId } : {}),
name: r.name.trim(),
email: r.email.trim() || undefined,
phone: r.phone.trim() || undefined,
})),
},
});
setEditing(false);
onMessage("Attendees saved.");
onSaved();
} catch (e: any) {
setError(e?.message || "Couldn't save attendees");
} finally {
setBusy(false);
}
};
const sendLink = async (a: Attendee) => {
try {
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
onMessage(res.message);
} catch (e: any) {
setError(e?.message || "Couldn't send the form link");
}
};
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
return (
<div>
<div className="font-medium mb-1 flex items-center justify-between">
<span className="flex items-center gap-1.5"><Users className="w-4 h-4 text-brand-600" /> People</span>
{canEdit && !editing && (
<button onClick={startEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700">{attendees.length ? "Edit names" : "Add names"}</button>
)}
</div>
{!editing ? (
attendees.length === 0 ? (
<p className="text-sm text-gray-500">No names yet — add a name for each person to give them their own ticket.</p>
) : (
<>
<ul className="text-sm space-y-1">
{attendees.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2">
<span>
{a.name}
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
</span>
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
)}
</li>
))}
</ul>
<p className="text-xs text-gray-500 mt-1">Tickets go {registration.ticketDelivery === "each" ? "to each person (or you, if they have no contact details)" : "to you"}.</p>
</>
)
) : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{rows.map((r, i) => (
<div key={i}>
<div className="text-xs font-semibold text-gray-600 mb-1">{r.label}{r.locked && <span className="font-normal text-gray-500"> — checked in, can&apos;t be changed</span>}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="border rounded px-2 py-1 text-sm" placeholder="Full name" value={r.name} disabled={r.locked} onChange={e => update(i, "name", e.target.value)} aria-label={`${r.label} name`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
</div>
</div>
))}
<div className="text-sm pt-1">
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "payer"} onChange={() => setDelivery("payer")} /> Send all tickets to me</label>
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "each"} onChange={() => setDelivery("each")} /> Send each person their own</label>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2">
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" disabled={busy}>Cancel</button>
<button onClick={save} disabled={busy} className="px-3 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
</div>
</div>
)}
{!editing && error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { TicketPercent, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
export type BasketItem = { eventOptionId: string; variantId?: string | null; quantity: number };
export type AppliedCoupon = {
code: string;
summary: string;
discount: number;
subtotal: number;
total: number;
};
/**
* "Have a code?" field. Checks the code against the current basket with
* POST /api/coupons/validate and reports the result through `onChange` — the parent
* sends `applied.code` as `couponCode` when it registers. Re-checks automatically when
* the basket changes, since the discount (or whether the code applies at all) can change.
*
* Staff checking on someone's behalf pass `forUserId` (the attendee), or `null` for a
* walk-in with no account yet.
*/
export function CouponCodeField({
eventId,
basket,
token,
forUserId,
applied,
onChange,
compact = false,
}: {
eventId: string;
basket: BasketItem[];
token?: string | null;
forUserId?: string | null;
applied: AppliedCoupon | null;
onChange: (applied: AppliedCoupon | null) => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(!!applied);
const [code, setCode] = useState(applied?.code || "");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const items = basket.filter(b => b.quantity > 0);
const basketKey = JSON.stringify(items);
const lastKey = useRef(basketKey);
const check = async (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
if (items.length === 0) { setError("Choose your tickets first."); return; }
setChecking(true);
setError(null);
try {
const body: Record<string, unknown> = { code: trimmed, eventId, options: items };
if (forUserId !== undefined) body.forUserId = forUserId;
const res = await apiFetch<AppliedCoupon & { valid: boolean }>("/api/coupons/validate", { method: "POST", authToken: token || undefined, body });
onChange({ code: res.code, summary: res.summary, discount: res.discount, subtotal: res.subtotal, total: res.total });
} catch (e) {
onChange(null);
setError(e instanceof ApiError ? e.message : "Couldn't check that code. Please try again.");
} finally {
setChecking(false);
}
};
// Basket changed after a code was applied: check it again for the new items.
useEffect(() => {
if (lastKey.current === basketKey) return;
lastKey.current = basketKey;
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [basketKey]);
// Staff switched attendee: restricted codes depend on who it's for.
useEffect(() => {
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forUserId]);
const remove = () => {
onChange(null);
setCode("");
setError(null);
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 text-sm text-brand-600 hover:underline">
<TicketPercent className="w-4 h-4" /> Have a code?
</button>
);
}
if (applied) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm">
<div className="flex items-center gap-2 text-green-800">
<TicketPercent className="w-4 h-4 shrink-0" />
<span>
<span className="font-mono font-semibold">{applied.code}</span> — {applied.summary}
{applied.discount > 0 && <span className="font-medium"> (−R {applied.discount.toFixed(2)})</span>}
</span>
</div>
<button type="button" onClick={remove} className="text-green-800 hover:text-green-950" aria-label="Remove code">
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div>
{!compact && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
<div className="flex gap-2">
<input
value={code}
onChange={e => { setCode(e.target.value.toUpperCase()); setError(null); }}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); check(code); } }}
placeholder="Enter code"
className="flex-1 min-w-0 border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-brand-400"
aria-label="Discount code"
/>
<button
type="button"
onClick={() => check(code)}
disabled={checking || !code.trim()}
className="px-4 py-2 rounded-lg border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50"
>
{checking ? "Checking…" : "Apply"}
</button>
</div>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
+5 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -34,11 +34,14 @@ export const dashboardUserHelpContent: HelpContent = {
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick.
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird or discount-code saving, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick. If you have a discount code and haven&apos;t paid in full yet, enter it in the registration popup.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only — tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem>
<GuideItem icon={Users} title="People on a group booking" tone="brand">
On events that take names, the registration popup has a People section: add or change each person&apos;s name and contact details, choose whether tickets come to you or go to each person, and send someone the link to fill in their own attendee form. Tickets someone else booked for you show here too, marked &quot;Booked by&quot;.
</GuideItem>
</div>
</div>
),
+5 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Ticket, Tag, Share2, QrCode, Calendar } from "lucide-react";
import { Ticket, Tag, Share2, QrCode, Calendar, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -20,11 +20,14 @@ export const eventDetailHelpContent: HelpContent = {
<p>The tickets panel lists every option available for this event, with live availability.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird pricing" tone="amber">
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends.
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under &quot;Have a code?&quot; (you need to be logged in).
</GuideItem>
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked.
</GuideItem>
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
Some events let you name each person you&apos;re booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own.
</GuideItem>
</div>
</div>
),
+6
View File
@@ -21,6 +21,7 @@ import { supervisorManualHelpContent, supervisorManualRegistrationLegacyHelpCont
import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
import { supervisorFormsHelpContent } from "./supervisor-forms";
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
@@ -147,6 +148,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/payments",
content: supervisorPaymentsHelpContent,
},
{
slug: "supervisor-coupons",
matches: pathname => pathname === "/dashboard/supervisor/coupons",
content: supervisorCouponsHelpContent,
},
{
slug: "supervisor-forms",
matches: pathname => pathname === "/dashboard/supervisor/forms" || pathname === "/dashboard/admin/forms",
@@ -42,6 +42,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
<div className="space-y-4">
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
</div>
),
},
@@ -0,0 +1,73 @@
import React from "react";
import { TicketPercent, Plus, Users, CalendarClock, Layers, Ban, BarChart2, BadgePercent, DoorOpen } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/coupons. */
export const supervisorCouponsHelpContent: HelpContent = {
title: "Coupon codes",
subtitle: "Discounts for staff, leaders, volunteers and promotions.",
tabs: [
{
key: "create",
label: "Creating codes",
icon: Plus,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Discount type" tone="brand">
A percentage off, a fixed rand amount off, or free (100%). People type the code on the registration page; it isn&apos;t case-sensitive.
</GuideItem>
<GuideItem icon={Layers} title="Events and tickets" tone="green">
Leave the events unticked for every event, or pick one or more. Once events are picked you can limit the code to certain ticket options or variants — otherwise it discounts the whole registration.
</GuideItem>
<GuideItem icon={BadgePercent} title="Early-bird prices" tone="violet">
By default the better of the two prices wins: the code is worked out against the standard price, and only what beats the early-bird price comes off. Tick &quot;Also apply on top of early-bird prices&quot; to stack them.
</GuideItem>
<GuideItem icon={CalendarClock} title="Dates and limits" tone="amber">
Optional start and end dates, a maximum number of uses overall, a maximum per person (1 by default), and a minimum number of tickets.
</GuideItem>
<GuideItem icon={Users} title="Who can use it" tone="blue">
Anyone with the code, people with certain roles (e.g. Staff and above), specific people, or an email domain. Restricted codes need the person to be logged in, so they can&apos;t be passed around a group chat.
</GuideItem>
</div>
</div>
),
},
{
key: "using",
label: "How discounts work",
icon: TicketPercent,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Locked in when applied" tone="brand">
The discount is saved on the registration and shows as its own line on invoices, receipts, emails and the payment page. Turning a code off later doesn&apos;t change discounts already given.
</GuideItem>
<GuideItem icon={Layers} title="Changing items" tone="green">
If the items on a registration change, percentage and free codes are worked out again on the new items. Fixed-amount codes keep their amount.
</GuideItem>
<GuideItem icon={Ban} title="Cancellations" tone="rose">
Cancelling a registration frees its code so it can be used again. A free code that covers everything marks the registration paid and sends the tickets.
</GuideItem>
<GuideItem icon={DoorOpen} title="Applying for someone" tone="amber">
Manual registration, At the door and the kiosk all have a &quot;Have a code?&quot; field. Every code staff apply is recorded in the admin audit log.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Staff price: a ticket option can also have a staff price (set in the event&apos;s Items &amp; pricing step) that Staff and above get automatically, with no code.</p>
</div>
),
},
{
key: "tracking",
label: "Tracking",
icon: BarChart2,
content: (
<div className="space-y-4">
<p>&quot;Uses&quot; on each code lists who used it, on which event, and how much it took off. The <strong>Discounts</strong> report (Reports → Financial) totals uses and value given away per code and event, and the Finance report shows sales after discounts.</p>
<p className="text-xs text-gray-500">A code that has been used can&apos;t be renamed — turn it off and create a new one instead.</p>
</div>
),
},
],
};
@@ -35,7 +35,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers.
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
</GuideItem>
<GuideItem icon={Layers} title="Sections" tone="violet">
Group ticket options under named headings as shown on the public registration page.
@@ -44,7 +44,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Build a custom registration form — text, number, date, yes/no, statement, and heading fields, reorderable, optionally required before tickets are generated.
</GuideItem>
<GuideItem icon={Eye} title="Visibility" tone="gray">
Hide the event, allow guest (no-account) registration, or toggle it active.
Hide the event, allow guest (no-account) registration, turn on named attendees (group tickets — each named person gets their own ticket and form), or toggle it active.
</GuideItem>
<GuideItem icon={Bell} title="Notifications" tone="blue">
Choose which staff get registration, payment, and daily-summary emails for this event.
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
icon: UserPlus,
content: (
<div className="space-y-4">
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird and staff pricing are applied automatically, and a discount code can be added under &quot;Have a code?&quot; — then create the registration. On events that take names, tick &quot;Registering for other people?&quot; to name each person. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
</div>
),
+4 -1
View File
@@ -13,6 +13,8 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
### For your congregation
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, dates, and location (with a map and directions). Fully booked events show a "Sold Out" badge, and popular tickets show how many are left.
- **Group bookings** — a parent can register several children at once, naming each one so they get their own ticket and their own form, with the tickets sent to the parent or to each person.
- **Discount codes** — enter a code for a staff, leader or promotional discount, shown on the invoice and receipt.
- **Register online** — choose from multiple ticket types (e.g. adult, child, family, dinner, T-shirt sizes) at different prices, including early-bird pricing. People can register as a guest or with an account, depending on how you set up the event.
- **Pay online** — secure card payments (including Apple Pay and Google Pay) via Yoco. Outstanding balances can be settled any time from a link in their email or WhatsApp.
- **Give** — members can make donations online, which you can put towards someone's registration (for example, to sponsor a child's camp place).
@@ -35,11 +37,12 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
- **Event management** — a step-by-step wizard for creating events: details, ticket types and pricing, early-bird tiers, stock limits, check-in points, registration forms, visibility, and attachments.
- **Flexible publishing** — schedule an event to go live later, keep it hidden and share it only by link, give it a short web address (e.g. `/camp-2026`), or create "contact us" events (e.g. baptisms) with no registration.
- **Coupon codes** — percentage, fixed or free codes for staff, leaders, volunteers and promotions, limited to events, tickets, dates, number of uses or particular people. A staff price can also apply automatically to your team.
- **Payments** — record cash, card, EFT, or voucher payments; issue refunds; send payment links; assign donations; and match card payments to registrations. Every payment records who took it.
- **Messaging** — send emails or WhatsApp messages to attendees, filtered by who has paid or still owes. Personalise messages with each person's name, balance, or their own payment link. Schedule reminders, thank-you messages, and promotions in advance.
- **Daily summary** — the event team gets a daily email with registrations, payments, and outstanding balances in the run-up to each event.
- **Forms** — view, filter, and print attendee form responses.
- **Reports** — 14 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
- **Reports** — 15 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, discounts, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
- **Dashboard** — revenue, donations, registrations, and tickets sold at a glance, compared with the previous month.
### For administrators and treasurers
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.12.0",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.12.0",
"version": "1.14.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.12.0",
"version": "1.14.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",