Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4cae0fc672 Fix the production build: escape quotes in the Payments help text
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
The deploy's next build fails on react/no-unescaped-entities. Bump to
1.37.3; the handover now says to lint the whole frontend before tagging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:57:46 +02:00
joshua cd33ba281e Merge branch 'feat/payment-status'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 15:28:31 +02:00
joshuaandClaude Opus 5.5 220dafb400 Bump to 1.37.2
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:31 +02:00
joshuaandClaude Opus 5.5 eb0f390215 Payment status checks for notifications that never arrive
- src/payments/status.js: open checkouts, checkCheckouts and the
  payment-status job (every 15 min, checkouts 10 min - 3 days old). Found
  payments are settled exactly as their webhook would be (settlePayment,
  extracted from the webhook handler: amount check, record, emails).
- PayFast: signed API client (alphabetical signature, testing=true in
  sandbox), findPayments from the transaction history CSV matched on
  M Payment ID, and testConnection (signed /ping). Verified against a
  real sandbox account.
- Yoco documents no checkout lookup, so its checkouts are "can't check".
- Payments -> Reconcile: "Waiting for payment" with Check status /
  Check all; PayFast card: Test connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:28:28 +02:00
joshua 55a7ee2970 Merge branch 'feat/hide-pay-online'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 15:05:01 +02:00
joshuaandClaude Opus 5.5 4bc7dbc989 Bump to 1.37.1
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:05:01 +02:00
joshuaandClaude Opus 5.5 71b9da2458 Hide "Pay online" when no provider is on or the amount is under R15
A shared useOnlinePayments hook (GET /api/payments/providers) gates the
registration success page, My Events (Make payment, Make donation), the
donate page, Sponsor a spot, the register form's donation box and the
supervisor Payment Link tab; they explain why where it helps.

Roadmap: SnapScan and Zapper dropped; later versions move up by two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:04:58 +02:00
joshua 654f51b86d Merge branch 'feat/payfast'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 14:26:09 +02:00
joshuaandClaude Opus 5.5 aeb5d6d678 Bump to 1.37.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:26:09 +02:00
joshuaandClaude Opus 5.5 f2b51dbd88 PayFast provider, Pay online page, amount checks on payments
- PayFast (custom integration): signed form (PHP-style encoding, checked
  against a real sandbox), ITN checks (signature over all posted fields,
  PayFast host, VALID from /eng/query/validate, amount vs checkout).
- ProviderCheckout records every checkout (amount, metadata, request);
  webhooks match payments to it and hold a different amount for review
  (ProviderTransaction.note).
- /pay/<id>: payers choose a provider when several are on, and the
  browser posts PayFast's form from there, so payment links work for
  every provider.
- Site settings → Payments: PayFast card (merchant ID/key, encrypted
  passphrase, sandbox/live) while PayFast is enabled.
- Payer-facing wording no longer names Yoco; legal pages name both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:25:49 +02:00
joshua 25e3e9d3a6 Merge branch 'feat/payment-providers'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 12:17:56 +02:00
joshuaandClaude Opus 5.5 e7bed08a35 Bump to 1.36.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:17:56 +02:00
joshuaandClaude Opus 5.5 ede1ddbe00 Payment provider interface: Yoco behind it, Site settings → Payments
- src/payments: provider registry and PaymentProvider interface
  (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund,
  listTransactions as capabilities), Yoco as the first provider. All four
  direct Yoco checkout calls go through payments.createCheckout.
- Webhook split into the provider part (signature, parsing) and shared
  processing; same URL (/api/webhooks/yoco), same behaviour.
- YocoTransaction renamed in place to ProviderTransaction (+ provider,
  unique per provider + externalId); Payment.provider added and
  backfilled to "yoco". Payment.method stays the wallet type so cashups
  are unchanged.
- Site settings → Payments: Enabled toggle and order per provider; Yoco's
  settings shown while it's enabled. R15 minimum for all online payments.
- New endpoint names (/api/payments/checkout, /api/provider-transactions);
  the old /yoco-checkout and /yoco-transactions paths still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:17:29 +02:00
joshua 8933a1fe77 Merge branch 'feat/session-gaps'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 11:38:43 +02:00
joshuaandClaude Opus 5.5 30e5db82df Bump to 1.35.1
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:43 +02:00
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- Waitlist per session: entries for pick-sessions tickets name a session;
  offers use that session's free places, never run past its start, and
  claim links tick it. Held offers count towards session capacity.
- At the door: the Select items pop-up has the session picker, with
  today's session preselected for new attendees.
- On pick-sessions events every ticket shows the session list straight
  away. "All sessions" tickets record the ticked sessions
  (RegistrationOption.sessionIds) at one price; scanning, attendance,
  certificates, reminders and capacity follow them.
- Fix: per-session stock limits weren't enforced (takenCount filtered
  WaitlistEntry by a sessionId column that didn't exist, and the error
  was swallowed), and the public event page lost its stock info.
- Fix: editing a door registration with picked sessions couldn't be saved.
- Fix: the offline-scanning service worker served /_next/static cache-first
  forever, so dev browsers that had opened the scanner ran stale code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00
joshua 7c3f4694e2 Merge branch 'feat/pick-sessions'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 09:25:35 +02:00
joshuaandClaude Opus 5.5 b441ffac60 Bump to 1.35.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:25:32 +02:00
joshuaandClaude Opus 5.5 6010641629 Pick sessions (frontend), roadmap and handover docs
Event wizard: how people book (whole course / pick sessions), capacity
per session and per-option session mode. Session picker on the public
registration page, Manual registration and the kiosk; at the door points
to Manual registration. Register, tickets and My Events handle picked
sessions. Adds docs/ROADMAP-v2.md and docs/HANDOVER.md so work can carry
on from another machine, and a pick-sessions e2e test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:25:21 +02:00
joshuaandClaude Opus 5.5 e338768f6e Pick sessions (backend): book individual sessions with per-session capacity
Options on a multi-day event can be bought per session (one line and
ticket per session, priced per session, bookable until the session
starts). Sessions can have a capacity covering picked places and
whole-course tickets. Picked tickets only scan on their own session; the
register, certificates and reminders follow the sessions booked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:05:48 +02:00
joshua cc44e62379 Merge branch 'feat/ci-e2e-jobs'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-22 15:50:00 +02:00
joshuaandClaude Opus 5.5 5aba2e735f Bump to 1.34.0
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:49:56 +02:00
joshuaandClaude Opus 5.5 48660732fe Run background jobs on a pg-boss queue; add e2e tests, CI and deploy script
Timed jobs move from in-process timers to a job registry run by pg-boss:
once per schedule across processes, retries, run history, cron schedules
in the church timezone, and an admin Background jobs page with Run now.
Playwright end-to-end tests run against their own seeded database. A
Gitea Actions workflow runs backend tests, frontend checks and the e2e
tests, and deploys v* tags with scripts/deploy.sh once secrets are set.
Also fixes the at-the-door check-in count for multi-day tickets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:49:46 +02:00
joshua 8cb2755493 Merge branch 'feat/multi-day-sessions' 2026-10-22 14:54:03 +02:00
joshuaandClaude Opus 5.5 e84d390393 Bump to 1.33.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:53:59 +02:00
joshuaandClaude Opus 5.5 39558577f7 Add multi-day events (frontend), attendance register and certificates
Event wizard: one date or multiple sessions with a repeat-pattern builder,
and a new Attendance step (late joining and pricing, check-in window,
session reminders, certificates with preview). Public pages show the
schedule and late prices; the scanner picks today's session; new
attendance register page, My Certificates, and a public verify page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:53:39 +02:00
joshuaandClaude Opus 5.5 c79b74bc67 Add multi-day event sessions (backend)
Events can run over several sessions: per-session check-in with a whole-day
or session-time window, late joining with pro-rata or fixed late pricing,
an attendance register (Excel/PDF), session reminders, and completion
certificates (four templates, public verify page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:20:52 +02:00
joshua 6a8ef62630 Merge branch 'feat/early-bird-ribbon' 2026-10-22 13:48:29 +02:00
joshuaandClaude Opus 5.5 dbac248d8f Bump to 1.32.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:28 +02:00
joshuaandClaude Opus 5.5 b1af901d6a Show an early-bird ribbon on event cards
Event cards on the home page and events list get a diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open. GET /api/events returns earlyBirdEndsAt per event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:22 +02:00
joshua 0bddb329e3 Merge branch 'feat/google-site-verification' 2026-10-22 11:43:01 +02:00
joshuaandClaude Opus 5.5 be4b6f02ea Bump to 1.31.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:43:00 +02:00
joshuaandClaude Opus 5.5 880e3206cc Add Google Search Console verification setting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:42:56 +02:00
joshuaandClaude Opus 5.5 7b2bfc31e6 Merge branch 'feat/seo-and-traffic-sources'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:17 +02:00
joshuaandClaude Opus 5.5 963c1824bf Bump to 1.31.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:15 +02:00
joshuaandClaude Opus 5.5 38262022ef Add event structured data, canonical URLs and referrer-based traffic sources
- Event pages emit schema.org Event JSON-LD (dates, venue, image,
  organizer, ZAR offers, sold-out status) for Google event results.
- Event pages, the events list and home page set a canonical URL so
  tagged and alias links don't split rankings.
- Funnel tracking falls back to the referring site (Facebook, Instagram,
  Google, ...) and fbclid/gclid when a link has no utm_source/src.
- Share and Save QR on the event page tag their links as share / qr.
- getServerSettings moves to lib/serverSettings.ts for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:02 +02:00
joshuaandClaude Opus 5.5 15b73d5d9e Merge branch 'feat/unified-ticket-design'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:24 +02:00
joshuaandClaude Opus 5.5 c74a943709 Bump to 1.30.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:21 +02:00
joshuaandClaude Opus 5.5 28c8fe9715 Use the dashboard ticket design for all tickets
The emailed/WhatsApp ticket PDF and the staff and at-the-door print
views now match the user dashboard's printed tickets: rounded cards with
a light border and soft shadow. Printing goes through a shared helper in
lib/ticketPrint.ts, which also escapes names in the print HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:07 +02:00
joshuaandClaude Opus 5.5 2e7bf263ee Merge branch 'feat/registration-donation'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:17 +02:00
joshuaandClaude Opus 5.5 377630473d Bump to 1.30.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:11 +02:00
joshuaandClaude Opus 5.5 286cf09dd8 Add optional donation to registration; fix soldOutLines server crash
The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

Move soldOutLines() out of the "use client" JoinWaitlistButton module
into lib/waitlist.ts so the server-rendered event page can call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:27:47 +02:00
joshuaandClaude Opus 5.5 27c870d46d Bump to 1.29.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:17 +02:00
joshuaandClaude Opus 5.5 a0c5a6a98c Merge branch 'feat/coupons-redesign'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 e7969429b6 Redesign the Coupon codes page to match Bundle deals
Cards instead of the table, phone-friendly sheets for the form and the uses list,
inactive codes hidden by default, and the events picker shows upcoming events only
(with search and a Show past events option).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
162 changed files with 12189 additions and 2365 deletions
+129
View File
@@ -0,0 +1,129 @@
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
#
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
# https://docs.gitea.com/usage/actions/act-runner
name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
env:
NODE_VERSION: "20"
# Throwaway values for the test database and tokens — never production secrets.
JWT_SECRET: ci-only-jwt-secret-not-for-production
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
working-directory: backend
- name: Migrations apply cleanly
run: npx prisma migrate deploy
working-directory: backend
- run: npm test
working-directory: backend
frontend:
name: Frontend lint, typecheck, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npx eslint src
working-directory: frontend
- run: npx tsc --noEmit -p .
working-directory: frontend
- run: npm run build
working-directory: frontend
env:
NEXT_PUBLIC_API_URL: http://localhost:5000
NEXT_TELEMETRY_DISABLED: "1"
e2e:
name: End-to-end tests
runs-on: ubuntu-latest
needs: [backend, frontend]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm ci
working-directory: backend
- run: npm ci
working-directory: frontend
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
CI: "true"
- name: Keep the report when tests fail
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |
e2e/report
e2e/test-results
retention-days: 7
deploy:
name: Deploy tagged release
runs-on: ubuntu-latest
needs: [backend, frontend, e2e]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
TAG: ${{ github.ref_name }}
run: |
if [ -z "$DEPLOY_HOST" ]; then
echo "DEPLOY_HOST isn't set — skipping the deploy."
exit 0
fi
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
+6 -1
View File
@@ -39,4 +39,9 @@ hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
backend/data/banner.json
backend/data/banner.json
# end-to-end tests
e2e/.data/
e2e/report/
e2e/test-results/
frontend/.next-e2e/
+160
View File
@@ -7,6 +7,166 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.37.3] - 2026-10-23
### Fixed
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
## [1.37.2] - 2026-10-23
### Added
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
## [1.37.1] - 2026-10-23
### Changed
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
## [1.37.0] - 2026-10-23
### Added
- **PayFast.** A second online payment provider (card, instant EFT and more). Switch it on in Site settings → Payments, then enter the merchant ID, merchant key and salt passphrase from your PayFast (or Sandbox) dashboard and choose Sandbox or Live. Payments are confirmed by PayFast's notification (ITN) at `/api/webhooks/payfast`, which is checked the way PayFast requires: the signature, that it came from PayFast, that PayFast confirms it, and that the amount matches the checkout. PayFast starts switched off.
- **Choosing how to pay.** When more than one provider is on, payers choose on a new "Pay online" page (`/pay/…`). Payment links in emails, WhatsApp and invoices go there too, so they work for every provider.
- Payer-facing wording no longer says Yoco: "Pay online" after registering, a neutral redirect message, and the Terms and Privacy pages name both providers (Yoco or PayFast), including that PayFast may receive your name and email to fill in its payment page.
- A payment of a different amount from the checkout (e.g. changed along the way) isn't recorded automatically: it waits in "Unreconciled Online Payments" with a note explaining why.
## [1.36.0] - 2026-10-23
### Added
- **Site settings → Payments** (replaces the Yoco tab): turn each online payment provider on or off and set their order. The first one enabled and set up is used by default. Yoco's keys and webhook settings are on the same tab. Yoco is the only provider for now; PayFast, SnapScan and Zapper follow in the next releases. If no provider is on and set up, online payments are off and people pay at the door.
### Changed
- Online payments go through one payment provider layer (`backend/src/payments`). Yoco works exactly as before: the same checkout, the same webhook URL (`/api/webhooks/yoco`) and the same reconciliation.
- **Online payments must be at least R15**, for every kind of online payment (balance, part-payment, donation, sponsorship). A smaller balance is paid at the door.
- Payments record which provider took them (`provider`, e.g. "yoco"); existing online payments are marked as Yoco. The payment method (card, Apple Pay…) is unchanged, so cashups and reports add up as before. Provider fees are still never deducted or added — they're handled at cashup.
- Yoco transactions are now "provider transactions" (`/api/provider-transactions`, with `/api/yoco-transactions` still working). The Payments page's reconcile list is called "Unreconciled Online Payments" and shows the provider. The database table is renamed in place, so stored transactions are kept.
- The checkout endpoint is now `POST /api/payments/checkout` (with an optional `provider`); `/api/payments/yoco-checkout` still works.
## [1.35.1] - 2026-10-23
### Added
- **Waitlist per session.** On pick-sessions events, a full session shows "Join waitlist" in the session list, and the waitlist works per session: when a place in that session frees up, the next person waiting for it gets the offer. Offers never run past the session's start, and nobody is offered a session that has started or was cancelled. The claim link ticks the session for them. My Events, the claim page and the supervisor Waitlist page show which session an entry is for.
- **"All sessions" tickets on pick-sessions events let people choose their sessions.** When an event's "How people book" is "Pick sessions", every ticket now shows the session list. A ticket set to "All sessions" is still one ticket at one price, whatever is ticked. The ticket then only scans on the ticked sessions (online and offline), and the attendance register, certificates, session reminders and session capacity follow them. Tickets and the ticket email list the ticked dates. A ticket set to "Pick sessions" is still charged per session. Events booked as "The whole course" show no session list. Bookings made before this cover every session, as before.
- **Session picker at the door.** The At the door "Select items" pop-up now has the session picker for per-session tickets, so walk-ins can be booked straight in. For a new attendee, a place in today's session is selected by default when there's room.
### Changed
- Per-session tickets show their list of sessions straight away (on the registration page, Manual registration, At the door and the self-service kiosk). Before, the list only appeared after pressing "+", so it wasn't obvious you could choose sessions. Ticking a session sets the quantity to 1.
### Fixed
- Per-session stock limits on pick-sessions tickets weren't being enforced, and the public event page lost all stock information on events with such a ticket.
- The offline-scanning service worker served the app's script files from its cache first, for good. On a development server, where those files keep their names when they change, a browser that had opened the scanner kept running old code on every page, even after a hard reload. It now fetches them from the network and uses the cache only when offline. Its cache is renamed, so old copies are cleared.
- At the door, editing a registration that has per-session tickets could no longer be saved ("below the quantity already issued").
- Whole-course tickets: the waitlist now also counts room in sessions that have a capacity, so it no longer says "places available" when a session is full. Places held for waitlist offers now count towards session capacity.
## [1.35.0] - 2026-10-23
### Added
- **Pick sessions.** A multi-day event can let people choose which sessions to come to — day passes for a conference, or a weekly drop-in class. In the event wizard choose "How people book: Pick sessions", then in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want when they register; each session is its own line and its own ticket, at the per-session price (early-bird and staff prices apply per session; late-joiner pricing doesn't). A session can be booked until it starts (staff can book until it ends). Whole-course tickets can still be sold alongside, and stop selling when the course starts.
- **Session capacity**: in pick-sessions events each session can have a capacity, covering picked places plus whole-course tickets. Full sessions can't be ticked. A per-session ticket's stock limit is places per session.
- Picked-session tickets only scan on their own session ("This ticket is for Class 2 (2026-11-11)…"), on and offline. The attendance register shows one row per person with a dash for sessions they didn't book, and completion and certificates count the sessions they booked. Session reminders go only to the people booked on that session. Tickets, printouts and the ticket email show the session's date.
- The session picker is on the public registration page, Manual registration and the self-service kiosk.
- The roadmap to 2.0 and a handover note are now in the repo: `docs/ROADMAP-v2.md`, `docs/HANDOVER.md` (plus the previous round's spec, `docs/FEATURES-ROUND-1.md`).
### Changed
- Per-session tickets can't be changed from My Events' "edit booking" (book more sessions by registering again); they still count towards the total. At the door, per-session tickets are booked from Manual registration (the page says so).
## [1.34.0] - 2026-10-22
### Added
- **Background jobs page** (Admin → Site Settings → Background jobs): every scheduled job with its schedule, next run and last 10 runs (start, finish, result or error), and a "Run now" button (recorded in the audit log).
- **End-to-end tests** with Playwright (`npm run test:e2e`): they start their own backend and frontend against a separate, freshly seeded database and cover the public pages, free and paid registration through payment, ticket, check-in and cashup, multi-day check-in with the attendance register and certificates, creating a multi-session event in the wizard, the self-service kiosk, and the jobs page.
- **CI workflow** (`.gitea/workflows/ci.yml`): backend tests, frontend lint, typecheck and build, and the end-to-end tests on every push and pull request. It runs once a Gitea Actions runner is registered.
- **Deploy script** (`scripts/deploy.sh <tag>`): backs up the database, checks out the tag, installs, migrates, builds and reloads PM2, going back to the previous version if a step fails before the reload. CI runs it over SSH for `v*` tags once the `DEPLOY_*` secrets are set.
### Changed
- Timed background jobs (daily summary, waitlist, feedback, session reminders and certificates, consent, analytics rollup, backup, temp cleanup) now run on a job queue in Postgres (pg-boss) instead of in-process timers. Each runs once per schedule even with more than one server process, failed runs are retried, and a slow run never overlaps the next. Schedules are cron, in `APP_TIMEZONE` (Africa/Johannesburg by default) rather than the server's clock; change one with `JOB_SCHEDULE_<NAME>`, or turn all jobs off with `JOBS_ENABLED=false`. The existing `*_ENABLED` flags still work; the old `*_JOB_INTERVAL_MS` settings are replaced by the schedules. If the queue can't start, the jobs run on timers as before.
- The server lets a running job finish before it stops or restarts.
### Fixed
- At the door, the Check-In tab counted every past session's check-in on a multi-day ticket, so from the second session the ticket showed as fully used. It now counts only today's session.
## [1.33.0] - 2026-10-22
### Added
- **Multi-day events.** An event can run over several sessions, e.g. every Wednesday 18:30–20:30 for 5 weeks. In the event wizard choose "Multiple sessions", create the dates from a repeat pattern (daily or weekly, every N, on chosen weekdays, for a number of sessions or until a date), then edit any session's label, times or location, add one-off dates, or cancel a session (kept for history). People register and pay once and get one ticket for every session.
- The event page shows the schedule (past sessions greyed out, the next one highlighted, cancelled ones struck through). Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec", and multi-day events stay listed until their last session, sorted by their next one. Calendar files have one entry per session. Ticket PDFs, printed tickets and the ticket email give the session range.
- **Late joining**: a multi-day event closes registration when its first session starts, unless late joining is on (until session N, set per event). Late joiners pay full price, a pro-rata price for the sessions still to come (rounded to the nearest R10, at least R10), or a fixed late price — main tickets only, and never more than the normal price. The event and registration pages say when the late price applies.
- **Per-session check-in**: a multi-day ticket is checked in once per session. The scanner uses today's session (staff can pick another), shows the session on the confirm screen, and says "Already checked in for Week 3 at 18:42" on a second scan. Each event chooses whether check-in is open the whole day of a session (the default) or only from 2 hours before it until it ends. Offline mode downloads the list for today's session, and synced scans count for the session at their scan time.
- **Session reminders**: optional per event — 1 hour, 3 hours and/or 1 day before each session, by email and/or WhatsApp (each person's notification preference still applies). Off by default.
- **Attendance register** (Dashboard → Attendance): attendees × sessions with ticks, totals per session, completion, search and filters (completed, not completed, missed the last two sessions), and Excel/PDF export. Supervisors can tick someone present or absent, recorded in the audit log. Also works for single-date events (one "Checked in" column).
- **Completion certificates**: optional per event, in the wizard's new Attendance step. Four designs (Classic, Modern, Church, Kids) with the site logo and colour, your title and wording ({name}, {event}, {sessionsAttended}, {totalSessions}, {endDate}), an optional signature name, role and image, and who qualifies (every session, at least N sessions, or at least N%). "Preview certificate" shows a sample. Certificates are issued automatically when the event ends (and can be sent automatically), or from the attendance register: issue now, issue to one person anyway, send, download all as one PDF, or revoke. People download theirs under My Certificates on My Events. Each has a code and QR code that anyone can check on the public page `/certificates/verify/<code>`, which shows only the name, event and dates.
- Saving an event where sessions moved or were cancelled offers a "Message attendees" link, since registrants aren't told automatically.
- A background job every 5 minutes sends due session reminders and issues certificates (`SESSION_JOBS_ENABLED=false` to turn it off). The church's timezone for "today" defaults to Africa/Johannesburg (`APP_TIMEZONE` to change).
### Changed
- The event wizard has a ninth step, Attendance, for late joining, check-in window, session reminders and certificates.
## [1.32.0] - 2026-10-22
### Added
- Event cards on the home page and events list show a diagonal "Early bird" ribbon in the top-right corner while the event has an early-bird tier running (deadline not passed and places left on that tier). It isn't shown when the event is sold out, registration is closed or not open yet, or the event is contact-only.
## [1.31.1] - 2026-10-22
### Added
- Settings → Organisation has a "Google Search Console verification" field. Paste the HTML tag from Search Console and the site outputs it on every page, so each deployment can verify its own domain without uploading a file.
## [1.31.0] - 2026-10-22
### Added
- The registration funnel's traffic source works without tagged links: visits from Facebook, Instagram, Google, X, LinkedIn and other sites are recognised from where the visitor came from (and Facebook's/Google's click ids). Links without a tag used to count as "direct". WhatsApp still only shows from tagged links (`?utm_source=whatsapp`), since it doesn't pass that on.
- The event page's Share button shares the link tagged `?utm_source=share`, and Save QR encodes it tagged `?utm_source=qr`, so those visits show separately.
- Event pages tell search engines about the event in Google's event format (schema.org `Event`): name, dates, venue, picture, organiser, ticket prices in Rand and whether it's sold out. Google can then show events with their date, venue and ticket link in search results and in its event listings. Contact-only events are listed without tickets.
### Changed
- Event pages, the events list and the home page tell search engines their one official address, so `?utm_source=…`, `?src=…` and alias links all count towards the same page in search rankings. Shared event links also carry the event's address.
## [1.30.1] - 2026-10-22
### Changed
- All tickets now share the design of the tickets printed from the user dashboard: rounded white cards with a light border and soft shadow, the date beside the event name, and the QR code in the middle. This covers the PDF sent by email and WhatsApp (now 6 per page, so the QR codes are bigger) and printing from Event Tickets (staff) and At the door (supervisor). Printed names are now escaped, so a name with `<` or `&` prints correctly.
## [1.30.0] - 2026-10-22
### Added
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
### Fixed
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
## [1.29.1] - 2026-10-21
### Changed
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
## [1.29.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
+94 -19
View File
@@ -56,7 +56,9 @@ Four roles, each including everything the roles below it can do:
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
logged in).
- **Events list** (`/events`) — event cards with picture, dates, location,
price, and a Register / Sold Out / Contact us button.
price, and a Register / Sold Out / Contact us button. A diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open.
- **Event detail** (`/events/[id]`) — description, ticket options with
variant and early-bird pricing, low-stock and sold-out badges, downloadable
attachments, embedded Google Map with "Directions" link, and an
@@ -77,14 +79,46 @@ Four roles, each including everything the roles below it can do:
## Events
Created and edited by supervisors through a 6-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Attachments**.
Created and edited by supervisors through a 9-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Notifications → Attachments →
Feedback → Attendance**.
- **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address),
contact person.
- **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets.
- **Multi-day events (sessions)** — one event on several dates, e.g. every
Wednesday 18:30–20:30 for 5 weeks. A repeat pattern (daily/weekly, every N,
chosen weekdays, a number of sessions or an end date) creates the sessions,
which can then be edited one by one (label, times, location), added to, or
cancelled (kept for history; one with check-ins can't be deleted). People
register and pay once and get one ticket. The event page shows the schedule
with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed
2 Dec" and stay listed until the last session. Calendar files get one entry
per session. Saving moved or cancelled sessions offers to message attendees.
- **Pick sessions** (multi-day) — instead of the whole course, people can choose
which sessions to come to (day passes, drop-in classes). Tickets set to "Pick
sessions" are priced and bought per session — one line and one ticket per session,
bookable until the session starts (staff: until it ends) — alongside optional
whole-course tickets. Sessions can have a capacity (picked places plus whole-course
tickets); a per-session ticket's stock limit is places per session. Picked tickets
only scan on their own session; reminders, the register and certificates follow
the sessions each person booked. "All sessions" tickets on such an event also show
the sessions: people tick the ones they'll come to and pay the ticket's one price; the
ticket scans, reminds and counts towards capacity for those sessions only. The waitlist
works per session (a full session shows "Join waitlist"). Bookable from the registration page, Manual registration,
At the door (today's session is preselected) and the kiosk. Not available on youth
events.
- **Late joining** (multi-day) — registration closes when the first session
starts, or later (until session N) with late joining on. Late joiners pay
full price, a pro-rata price for the sessions left (rounded to the nearest
R10), or a fixed late price — main tickets only.
- **Completion certificates** — optional per event: four designs (Classic,
Modern, Church, Kids), custom wording with placeholders, signature name/role/
image, and a completion rule (every session, at least N sessions, or N%).
Issued automatically when the event ends; each has a code and QR code checked
on a public verify page.
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
T-shirt), each with a price and optional **stock limit**. One option is marked
as the main ticket, which is what gets checked in at the door.
@@ -140,7 +174,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
emailed/WhatsApped a claim link and the places are held for them (24 hours by
default) so nobody else can buy them. Unclaimed offers expire and move on; people
who asked for more than came free can be offered what's free or skipped. Claiming
works without logging in. People can leave from My Events.
works without logging in. People can leave from My Events. On pick-sessions events
people wait for a particular session; offers never run past the session's start.
- **Parental consent (youth events)** — on events that require it, every attendee
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
a WhatsApp with a consent link. They read the event's consent text, optionally give
@@ -165,7 +200,17 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Yoco.
Site Settings → Payments, where providers are switched on or off and put in order
(the first one is the default). Online payments are at least R15; payments are
recorded at the full amount and provider fees are dealt with at cashup.
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
modes. When several providers are on, payers choose on the "Pay online" page; a
payment of a different amount from the checkout is held for review instead of being
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
online.
- **Payment status checks** — if a provider's notification never arrives, Payments →
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
@@ -216,7 +261,7 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
Supervisors can **assign** a donation (fully or partly) to someone's
registration and **unassign** it later. Assignments are kept as separate
records, so the original donation never changes.
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
- **Online payment reconciliation** — provider (Yoco) transactions can be matched to a
registration, recorded as a donation (optionally choosing the donor), or
ignored.
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
@@ -240,6 +285,15 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
and remaining quantity.
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
checked in and how many remain.
- **Per-session check-in** — multi-day tickets are checked in once per session.
The scanner uses today's session (or one picked from a list). Check-in is open
the whole day of a session, or only from 2 hours before it until it ends, per
event. Optional reminders (1 h / 3 h / 1 day before each session) by email
and/or WhatsApp.
- **Attendance register** — attendees × sessions with ticks, totals per session,
completion, filters (completed, missed the last two), Excel/PDF export.
Supervisors can tick someone present or absent (audit-logged), and issue,
send, download or revoke certificates.
- **Scan stats** — recent scans and per-event scan counts.
- **Offline scanning** — while online, a staff member locks an event section (or the
whole event) to their device and downloads its tickets. They can then keep scanning
@@ -344,7 +398,7 @@ logout controls. Disconnected sessions are recovered automatically.
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Manage events** — the 6-step event wizard, ticket options, variants,
- **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
payments, a **Check-In** tab for redeeming main tickets by quantity, and
@@ -457,7 +511,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Payments | Online payment providers: Enabled toggle and order; PayFast merchant ID/key, passphrase and sandbox/live; Yoco's secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
@@ -499,16 +553,26 @@ falling back to a general guide.
## Background jobs
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
runs once per schedule even with several server processes, every run is recorded,
failed runs are retried twice, and a slow run never overlaps the next. Admin → Site
Settings → Background jobs shows each job's schedule, next run and last 10 runs, with
"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule
with `JOB_SCHEDULE_<NAME>` (cron); `JOBS_ENABLED=false` turns them all off. If the queue
can't start, the same jobs run on in-process timers.
| Job | Schedule |
|-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_JOB_ENABLED=false`) |
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
---
@@ -520,7 +584,16 @@ falling back to a general guide.
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Jest tests for early-bird tranche pricing and donation balances.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
registration through to check-in and cashup, multi-day check-in, the attendance
register and certificates, the event wizard, the kiosk, and the admin jobs page.
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
goes back to the previous version if a step fails before the reload.
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
---
@@ -534,16 +607,18 @@ falling back to a general guide.
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
| `/pay/[id]` | Public | Pay online: choose a provider, or go on to PayFast |
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Return pages after paying online |
| `/forms` | Public / User | Attendee forms |
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
| `/setup` | First run | Setup wizard |
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/certificates/verify/[code]` | Public | Check a completion certificate |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/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`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+20 -2
View File
@@ -67,7 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
- Yoco payments are set up in Admin → Site Settings → Payments: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
@@ -81,8 +81,26 @@ Documentation: see Frontend README for environment and scripts.
- npm run dev
- Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port.
## Tests
- Backend unit tests: `cd backend && npm test`
- End-to-end tests (Playwright), from the repo root:
- once: `npm install` and `npx playwright install chromium`
- `npm run test:e2e`
- They start their own backend (port 5100) and frontend (port 3100) against a separate
database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
servers and database aren't touched. The database user needs permission to create
databases.
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
## Deployment
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
(backs up the database, migrates, builds, reloads PM2).
### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL.
@@ -138,7 +156,7 @@ Documentation: see Frontend README for environment and scripts.
- **`OptionVariant` model**: event options can now have sub-variants (sizes, colours, ticket types — e.g. Adult/Child/VIP under a single "Ticket" option). Variants have an optional `price` override and their own `stockLimit`.
- **Stock limits**: `stockLimit` added to `EventOption` and `EarlyBirdTier`. A value of `0` means unlimited. All stock counts exclude cancelled registrations.
- **Early-bird concurrency handling**: once a Yoco checkout has been issued at an early-bird price, that price is honoured even if another user simultaneously exhausts the tier's stock. Stock-based forfeiture only applies at registration time (via `resolveOptionPrice`). Deadline expiry at payment time forfeits the price; stock races after the checkout is issued do not.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/yoco-checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **`priceSnapshot` / `appliedTierId`** on `RegistrationOption`: price and tier are snapshotted at registration creation. `computeRegistrationTotalDue` uses `priceSnapshot` as authoritative when present (legacy rows without a snapshot fall back to deadline-only re-evaluation).
- **Cancelled registration tickets**: `GET /api/tickets/mytickets` now excludes tickets from cancelled registrations at the database level.
- **Cancellation payment guard**: `DELETE /api/registrations/:id` blocks non-admins from cancelling a registration that has positive payments. Admins can cancel at any time.
+6 -2
View File
@@ -57,12 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments (Yoco)
## Payments
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `payfast_merchant_id` / `payfast_merchant_key` | `PAYFAST_MERCHANT_ID` / `PAYFAST_MERCHANT_KEY` env vars, else *(blank)* | From the PayFast (or Sandbox) dashboard. Both are needed before PayFast can take payments. The key isn't secret (it's in the payment form), so it's shown in full. Managed through `/api/payfast`. |
| `payfast_passphrase` | `PAYFAST_PASSPHRASE` env var, else *(blank)* | Stored encrypted. Must match the dashboard's salt passphrase exactly, or PayFast refuses payments ("signature does not match"). |
| `payfast_mode` | `PAYFAST_MODE` env var, else `sandbox` | `sandbox` or `live`: where payers are sent and where notifications are confirmed. |
| `payment_providers` | Yoco enabled | JSON `{ order: [...], enabled: { yoco: true } }`: which online payment providers are on, and their order (the first one enabled and set up is the default). Providers not listed yet come last, switched off (Yoco on, as it was the only provider). If none is on and set up, online payments are off. |
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
+12
View File
@@ -39,6 +39,16 @@ FRONTEND_URL=http://localhost:3000
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
# PAYFAST_MERCHANT_ID=
# PAYFAST_MERCHANT_KEY=
# PAYFAST_PASSPHRASE=
# PAYFAST_MODE=sandbox
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
# PAYFAST_SKIP_IP_CHECK=true
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
APP_BASE_URL=https://your-frontend-domain.com
@@ -59,6 +69,8 @@ CONSENT_JOBS_ENABLED=true
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
WAITLIST_JOB_ENABLED=true
# WAITLIST_JOB_INTERVAL_MS=600000
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
PAYMENT_STATUS_JOB_ENABLED=true
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
+109 -15
View File
@@ -21,6 +21,7 @@ Tokens are issued by `POST /api/users/login`.
- [Coupons](#coupons)
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Multi-day events & attendance](#multi-day-events--attendance)
- [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer)
- [Rooms & groups](#rooms--groups)
@@ -28,6 +29,7 @@ Tokens are issued by `POST /api/users/login`.
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -39,7 +41,7 @@ Tokens are issued by `POST /api/users/login`.
- [Sections](#sections)
- [Banner](#banner)
- [Forms](#forms)
- [Yoco Transactions](#yoco-transactions)
- [Provider Transactions](#provider-transactions)
- [WhatsApp Admin](#whatsapp-admin)
- [Webhooks](#webhooks)
- [Settings](#settings)
@@ -285,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Background jobs
Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) —
see `backend/src/jobs/index.js`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` |
| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` |
Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …)
turns it off; `JOB_SCHEDULE_<NAME>` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its
cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg).
---
## Impersonation
| Method | Path | Auth | Description |
@@ -330,7 +348,7 @@ Data handling:
Notes:
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
- Only HMACs of link tokens are stored, and a reminder issues a new link.
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
## Sponsor a spot
@@ -422,16 +440,78 @@ How syncing works:
- Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
## Multi-day events & attendance
An event with `scheduleType: "sessions"` runs on several dates (e.g. every Wednesday for
5 weeks). People register and pay once and get one ticket; check-in is per session.
`startDate`/`endDate` always follow the first and last active session.
Create/update fields (`POST`/`PUT /api/events`):
| Field | Notes |
|-------|-------|
| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins |
| `attendanceMode` | `whole` (default) or `pick` — people choose sessions. Not allowed with `requiresParentalConsent` |
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled?, capacity? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only |
| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) |
| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` |
| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` |
Event options take `sessionMode: "all" | "pick"` (`POST /api/events/:id/options`, `PUT /api/events/options/:id`).
In a pick-mode event, a `pick` option is bought per session: registration lines send
`sessionIds: [...]` (`POST /api/registrations`, `/manual`, coupon/bundle quotes), and each
session becomes its own registration line (`RegistrationOption.sessionId`) and ticket.
Capacity counts picked places plus whole-course main tickets; a pick option's
`stockLimit` is per session. `PUT /api/registrations/:id/options` refuses pick options and
leaves picked lines untouched. An `all` option in a pick-mode event may also send
`sessionIds`: it stays one line and one ticket at one price, with the sessions kept in
`RegistrationOption.sessionIds` (empty = every session). That ticket only scans on those
sessions, and capacity, reminders, the register and certificates follow them; adding more
of the same ticket to a registration merges the sessions. Pick-mode events stay open until the last session starts
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`),
`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`.
`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`.
Scanning: `GET /api/tickets/scan-preview/:qr?sessionId=` and `POST /api/tickets/scan/:qr`
(`{ sessionId? }`) default to today's session and return `session` plus `remaining` for it;
`sessionError` / 400 `NO_SESSION` when no session is open for check-in. Offline snapshots
are for the session running when downloaded; synced scans count for the session at their scan time.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/attendance/:eventId` | staff+ | Register: `columns` (sessions), `rows` (one per main ticket: `checkIns` per session, `attended`, `total`, `completed`, `certificate`), `totals` |
| GET | `/api/attendance/:eventId/export?format=xlsx\|pdf` | staff+ | Download the register |
| POST | `/api/attendance/:eventId/mark` | supervisor+ | `{ ticketId, sessionId, present }` — single-date events use `sessionId: "event"` |
| POST | `/api/attendance/:eventId/certificates` | supervisor+ | Issue now to everyone eligible, or `{ ticketId }` for one (even if not eligible) |
| POST | `/api/attendance/:eventId/certificates/send` | supervisor+ | Send unsent certificates, or `{ certificateId }` |
| GET | `/api/attendance/:eventId/certificates.pdf` | supervisor+ | All certificates in one PDF |
| POST | `/api/attendance/certificates/preview` | supervisor+ | Sample PDF: `{ title, config }` |
| POST | `/api/attendance/certificates/:id/revoke` | supervisor+ | Revoke (never re-issued automatically) |
| GET | `/api/attendance/certificates/mine` | user+ | My certificates |
| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) |
| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` |
A job every 5 minutes (`sessions` job; `SESSION_JOBS_ENABLED`, `JOB_SCHEDULE_SESSIONS`) sends due session
reminders (each once; when several are due at once only the closest is sent) and issues
certificates for events with certificates on that ended in the last 14 days.
---
## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, sessionId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity. `sessionId` is required for a pick-sessions option (one session per entry) and refused otherwise |
| GET | `/api/waitlist/mine` | user+ | My active entries |
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers (with `sessionId`, `sessionName`, `lineName` for a per-session offer; `eventStart` is the session's start) |
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
@@ -440,9 +520,10 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
- Pick-sessions options are waited for per session: free places are that session's (stock per session and session capacity), an offer expires no later than the session's start, and sessions that started or were cancelled aren't offered. For main tickets, free places also take session capacity into account, and held places count towards it.
## Parental consent
@@ -494,7 +575,15 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) |
| POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
| POST | `/api/payments/checkout` | user+ | Start an online checkout (also `/api/payments/yoco-checkout`); `provider` optional (default: first enabled provider); at least R15; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
| POST | `/api/payments/checkout/send` | supervisor+ | Send a generated payment link by email or WhatsApp (also `/api/payments/yoco-checkout/send`) |
| GET | `/api/payments/providers` | public | Providers people can pay with now (enabled and set up), in order: `{ providers: [{ id, label }], minimumAmount: 15 }` |
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) |
| GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` |
| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` |
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
@@ -514,7 +603,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
}
```
### POST /api/payments/yoco-checkout
### POST /api/payments/checkout
```json
{
"registrationId": "reg-uuid-...",
@@ -663,16 +752,16 @@ Groups of event options shown as labelled sections on the registration page (e.g
---
## Yoco Transactions
## Provider Transactions
Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically.
Transactions payment providers sent by webhook (`ProviderTransaction`, one per provider + external id). Used for reconciling payments that weren't matched automatically. Each item has `provider`, `providerLabel` and the checkout `metadata`. Also served at `/api/yoco-transactions` (the older name).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/yoco-transactions` | supervisor+ | All Yoco webhook transactions |
| GET | `/api/yoco-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment |
| POST | `/api/yoco-transactions/:id/reconcile` | supervisor+ | Manually link transaction to a registration |
| POST | `/api/yoco-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
| GET | `/api/provider-transactions` | supervisor+ | All transactions (`?provider=yoco&reconciled=&ignored=`) |
| GET | `/api/provider-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment (`?provider=`) |
| POST | `/api/provider-transactions/:id/reconcile` | supervisor+ | Record it as a payment on a registration (`{ registrationId }`) or a donation (`{ eventId, userId? }`); the payment gets the transaction's provider |
| POST | `/api/provider-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
---
@@ -699,7 +788,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets |
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
---
@@ -720,7 +810,11 @@ For what each individual setting key defaults to when it's never been saved, see
## Yoco
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
Managed from Admin → Site Settings → Payments.
PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping.
Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/<checkoutId>` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
+67 -50
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.29.0",
"version": "1.37.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.29.0",
"version": "1.37.3",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
@@ -15,6 +15,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -25,6 +26,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -1615,9 +1617,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1639,9 +1638,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1663,9 +1659,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1687,9 +1680,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1711,9 +1701,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,9 +1722,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2707,9 +2691,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2724,9 +2705,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2741,9 +2719,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2758,9 +2733,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2775,9 +2747,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2792,9 +2761,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2809,9 +2775,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2826,9 +2789,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2843,9 +2803,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2860,9 +2817,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4143,6 +4097,19 @@
"node": ">= 10"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7134,6 +7101,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7761,6 +7737,20 @@
}
}
},
"node_modules/pg-boss": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"pg": "^8.16.3",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
@@ -8526,6 +8516,33 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serialize-error/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.29.0",
"version": "1.37.3",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -23,6 +23,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -33,6 +34,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -0,0 +1,132 @@
-- CreateEnum
CREATE TYPE "ScheduleType" AS ENUM ('single', 'sessions');
-- CreateEnum
CREATE TYPE "AttendanceMode" AS ENUM ('whole', 'pick');
-- CreateEnum
CREATE TYPE "LatePricing" AS ENUM ('full', 'prorata', 'fixed');
-- CreateEnum
CREATE TYPE "CheckInWindow" AS ENUM ('whole_day', 'session_only');
-- 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 'event_sessions_changed';
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_marked';
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_unmarked';
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_issued';
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_revoked';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "attendanceMode" "AttendanceMode" NOT NULL DEFAULT 'whole',
ADD COLUMN "certificateConfig" JSONB,
ADD COLUMN "certificatesEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "checkInWindow" "CheckInWindow" NOT NULL DEFAULT 'whole_day',
ADD COLUMN "lateFixedPrice" DOUBLE PRECISION,
ADD COLUMN "lateJoinEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lateJoinUntilSessionId" TEXT,
ADD COLUMN "latePricing" "LatePricing" NOT NULL DEFAULT 'full',
ADD COLUMN "scheduleType" "ScheduleType" NOT NULL DEFAULT 'single',
ADD COLUMN "sessionReminders" JSONB;
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sessionId" TEXT;
-- CreateTable
CREATE TABLE "EventSession" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"label" TEXT,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"location" TEXT,
"cancelledAt" TIMESTAMP(3),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SessionReminderLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"offset" INTEGER NOT NULL,
"sent" INTEGER NOT NULL DEFAULT 0,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SessionReminderLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Certificate" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"userId" TEXT,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sessionsAttended" INTEGER NOT NULL DEFAULT 0,
"totalSessions" INTEGER NOT NULL DEFAULT 0,
"manual" BOOLEAN NOT NULL DEFAULT false,
"issuedById" TEXT,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"emailedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventSession_eventId_startsAt_idx" ON "EventSession"("eventId", "startsAt");
-- CreateIndex
CREATE UNIQUE INDEX "SessionReminderLog_sessionId_offset_key" ON "SessionReminderLog"("sessionId", "offset");
-- CreateIndex
CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
-- CreateIndex
CREATE INDEX "Certificate_userId_idx" ON "Certificate"("userId");
-- CreateIndex
CREATE INDEX "Certificate_ticketId_idx" ON "Certificate"("ticketId");
-- CreateIndex
CREATE INDEX "Certificate_issuedById_idx" ON "Certificate"("issuedById");
-- CreateIndex
CREATE UNIQUE INDEX "Certificate_eventId_ticketId_key" ON "Certificate"("eventId", "ticketId");
-- CreateIndex
CREATE INDEX "TicketUsage_sessionId_idx" ON "TicketUsage"("sessionId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventSession" ADD CONSTRAINT "EventSession_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionReminderLog" ADD CONSTRAINT "SessionReminderLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
-- AlterTable
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
-- AddForeignKey
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'job_run_manual';
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "WaitlistEntry" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "WaitlistEntry_sessionId_idx" ON "WaitlistEntry"("sessionId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,23 @@
-- Payment providers (1.36.0). YocoTransaction becomes ProviderTransaction: renamed in place
-- so every stored transaction is kept (Prisma's generated plan drops and recreates it).
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'payment_providers_updated';
-- Rename YocoTransaction → ProviderTransaction
ALTER TABLE "YocoTransaction" RENAME TO "ProviderTransaction";
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_pkey" TO "ProviderTransaction_pkey";
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_paymentId_fkey" TO "ProviderTransaction_paymentId_fkey";
ALTER INDEX "YocoTransaction_paymentId_idx" RENAME TO "ProviderTransaction_paymentId_idx";
-- Which provider sent it; external ids are unique per provider
ALTER TABLE "ProviderTransaction" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'yoco';
DROP INDEX "YocoTransaction_externalId_key";
CREATE UNIQUE INDEX "ProviderTransaction_provider_externalId_key" ON "ProviderTransaction"("provider", "externalId");
-- Payment.provider: every online payment so far came through Yoco (linked to a transaction,
-- or the donation split off one)
ALTER TABLE "Payment" ADD COLUMN "provider" TEXT;
UPDATE "Payment" SET "provider" = 'yoco'
WHERE "id" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL)
OR "originalPaymentId" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL);
@@ -0,0 +1,24 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'payfast_config_updated';
-- AlterTable
ALTER TABLE "ProviderTransaction" ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "ProviderCheckout" (
"id" TEXT NOT NULL,
"provider" TEXT,
"externalId" TEXT,
"redirectUrl" TEXT,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ZAR',
"metadata" JSONB NOT NULL,
"request" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProviderCheckout_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderCheckout_provider_externalId_idx" ON "ProviderCheckout"("provider", "externalId");
+179 -3
View File
@@ -119,6 +119,8 @@ model User {
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
certificates Certificate[]
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
}
model Event {
@@ -199,6 +201,28 @@ model Event {
scanLocks ScanLock[]
bundles EventBundle[]
// Multi-day events: `sessions` is one event running on several dates (e.g. every
// Wednesday for 5 weeks). startDate/endDate are kept in sync with the first session's
// start and the last session's end (see utils/sessions.js), so code that only reads
// them keeps working.
scheduleType ScheduleType @default(single)
attendanceMode AttendanceMode @default(whole)
// Late joining: registration stays open until session `lateJoinUntilSessionId` starts
// (otherwise it closes when the first session starts). Late pricing applies to main
// tickets only.
lateJoinEnabled Boolean @default(false)
lateJoinUntilSessionId String?
latePricing LatePricing @default(full)
lateFixedPrice Float?
checkInWindow CheckInWindow @default(whole_day)
// null = off; { offsets: [minutes before start], channels: ["email", "whatsapp"] }
sessionReminders Json?
certificatesEnabled Boolean @default(false)
// { template, title, body, signatureName, signatureRole, signatureImage, rule: { type, value } }
certificateConfig Json?
sessions EventSession[]
certificates Certificate[]
@@index([createdById])
@@index([closedById])
@@index([reopenedById])
@@ -213,6 +237,9 @@ model EventOption {
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
// (price is per session, stockLimit is places per session); `all` options cover every session.
sessionMode OptionSessionMode @default(all)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
@@ -313,11 +340,19 @@ model RegistrationOption {
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
// A "pick sessions" option: one line (and ticket) per session booked. Null for
// everything else.
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
// An all-sessions option on a "pick sessions" event: the sessions chosen (one line,
// one ticket, one price). Empty = every session.
sessionIds String[] @default([])
@@index([registrationId])
@@index([eventOptionId])
@@index([variantId])
@@index([appliedTierId])
@@index([sessionId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
@@ -358,6 +393,9 @@ model Payment {
externalId String? @unique
status String?
originalPaymentId String?
// Online payments: the payment provider that took it (src/payments, e.g. "yoco"). Null for
// payments recorded by staff. Payment.method stays the wallet type (card, apple_pay, …).
provider String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
@@ -365,7 +403,7 @@ model Payment {
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
splitPayments Payment[] @relation("SplitPayments")
YocoTransaction YocoTransaction[]
providerTransactions ProviderTransaction[]
@@index([userId])
@@index([recordedById])
@@ -398,6 +436,7 @@ model Ticket {
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
certificates Certificate[]
@@index([holderUserId])
@@index([eventId])
@@ -418,6 +457,11 @@ model TicketUsage {
offline Boolean @default(false)
scanLockId String?
syncedAt DateTime?
// Multi-day events: the session this check-in counts for (null on single-date events).
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
// Ticked present by a supervisor from the attendance register, not scanned.
manual Boolean @default(false)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
@@ -425,6 +469,7 @@ model TicketUsage {
@@index([ticketId])
@@index([scannedAt])
@@index([scannedById, scannedAt])
@@index([sessionId])
}
model PasswordReset {
@@ -518,6 +563,14 @@ enum AdminAuditAction {
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
payment_providers_updated
payfast_config_updated
event_sessions_changed
attendance_marked
attendance_unmarked
certificate_issued
certificate_revoked
job_run_manual
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -632,9 +685,12 @@ model FormDraft {
// Stores all webhook events from Yoco. If a Payment is created (registration or donation),
// the record is marked reconciled and linked via paymentId. Otherwise, remains unreconciled.
model YocoTransaction {
// A transaction a payment provider told us about (by webhook). Matched to a Payment when it
// belongs to one of our checkouts; the rest wait on the Payments page to be reconciled.
model ProviderTransaction {
id String @id @default(uuid())
externalId String @unique // Yoco payment/event id
provider String @default("yoco") // payment provider id (src/payments)
externalId String // the provider's payment/event id
amount Int?
currency String?
createdDate DateTime?
@@ -645,12 +701,37 @@ model YocoTransaction {
paymentId String?
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull)
raw Json?
// Why it wasn't recorded automatically and needs a look (e.g. paid a different amount
// from the checkout).
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
@@index([paymentId])
}
// An online checkout we started: what it's for and how much, so the payment that comes back
// can be checked against it. Its id is the payer's link (/pay/<id>) and, for PayFast, the
// m_payment_id. `provider` is null until the payer picks one (when several are enabled).
model ProviderCheckout {
id String @id @default(uuid())
provider String?
// The provider's own checkout id (Yoco) and where it sends the payer.
externalId String?
redirectUrl String?
amountCents Int
currency String @default("ZAR")
// Who/what the payment is for: registrationId, userId, eventId, type, donationAmount, …
metadata Json
// Everything needed to start the checkout with any provider: lineItems, return URLs, payer.
request Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([provider, externalId])
}
model Section {
id String @id @default(uuid())
eventId String
@@ -1319,6 +1400,8 @@ model WaitlistEntry {
eventId String
eventOptionId String
variantId String?
// "Pick sessions" options: the session they're waiting for. Null for everything else.
sessionId String?
userId String
quantity Int
position Int
@@ -1334,12 +1417,14 @@ model WaitlistEntry {
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@index([eventId, status, position])
@@index([eventOptionId, status])
@@index([variantId])
@@index([sessionId])
@@index([userId])
@@index([registrationId])
}
@@ -1391,3 +1476,94 @@ model OfflineSyncReport {
@@index([scanLockId])
@@index([reviewedById])
}
enum ScheduleType {
single
sessions
}
enum AttendanceMode {
whole
pick
}
enum LatePricing {
full
prorata
fixed
}
enum OptionSessionMode {
all
pick
}
enum CheckInWindow {
whole_day
session_only
}
// One date/time of a multi-day event.
model EventSession {
id String @id @default(uuid())
eventId String
label String?
startsAt DateTime
endsAt DateTime
location String?
// Cancelled sessions are kept (they may have check-ins) but hidden from the schedule
// and left out of attendance totals.
cancelledAt DateTime?
order Int @default(0)
// Optional limit on people at this session (picked places + whole-course tickets).
capacity Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
waitlistEntries WaitlistEntry[]
@@index([eventId, startsAt])
}
// Which session reminders have gone out, so the job sends each one once.
model SessionReminderLog {
id String @id @default(uuid())
sessionId String
// Minutes before the session start this reminder is for.
offset Int
sent Int @default(0)
sentAt DateTime @default(now())
session EventSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@unique([sessionId, offset])
}
// A completion certificate for one ticket (one person).
model Certificate {
id String @id @default(uuid())
eventId String
ticketId String
userId String?
name String
// Short code printed on the certificate and checked on the public verify page.
code String @unique
sessionsAttended Int @default(0)
totalSessions Int @default(0)
manual Boolean @default(false)
issuedById String?
issuedAt DateTime @default(now())
emailedAt DateTime?
revokedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
issuedBy User? @relation("CertificateIssuedBy", fields: [issuedById], references: [id], onDelete: SetNull)
@@unique([eventId, ticketId])
@@index([userId])
@@index([ticketId])
@@index([issuedById])
}
@@ -0,0 +1,409 @@
const ExcelJS = require('exceljs');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const certificates = require('../utils/certificates');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const notFound = (msg) => Object.assign(new Error(msg), { statusCode: 404 });
const badRequest = (msg) => Object.assign(new Error(msg), { statusCode: 400 });
// Single-date events have no sessions; the register treats the event itself as one
// column with this id (its check-ins have no sessionId).
const WHOLE_EVENT = 'event';
/**
* The attendance register for an event: columns (sessions), one row per main ticket with
* check-ins per session, and certificate status.
*/
async function buildRegister(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event) throw notFound('Event not found');
const multi = event.scheduleType === 'sessions';
const active = sessionsUtil.activeSessions(event.sessions);
const columns = multi
? event.sessions.map(s => ({
id: s.id, label: s.cancelledAt ? (s.label || 'Session') : sessionsUtil.sessionLabel(s, event.sessions),
startsAt: s.startsAt, endsAt: s.endsAt, cancelled: !!s.cancelledAt,
}))
: [{ id: WHOLE_EVENT, label: 'Checked in', startsAt: event.startDate, endsAt: event.endDate, cancelled: false }];
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
include: {
attendee: { select: { name: true } },
holder: { select: { name: true } },
user: { select: { name: true, email: true, phoneNumber: true } },
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
},
orderBy: { createdAt: 'asc' },
});
const activeIds = new Set(active.map(s => s.id));
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
// A picked-session booking (one ticket per session) shows as one row.
const rows = sessionsUtil.groupTickets(tickets).map(g => {
const t = g.tickets[0];
const bySession = {};
for (const tk of g.tickets) {
for (const u of tk.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
}
}
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
const counted = booked ? new Set(booked) : activeIds;
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? counted.size : 1;
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked or ticked sessions: which sessions this person booked, and the ticket for each
// (an all-sessions ticket with sessions ticked is one ticket for all of them).
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.flatMap(tk => (tk.registrationOption.sessionId ? [[tk.registrationOption.sessionId, tk.id]] : g.sessionIds.map(id => [id, tk.id])))) : null,
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
email: t.user?.email || '',
phone: t.user?.phoneNumber || '',
option: t.registrationOption?.eventOption?.name || '',
variant: t.registrationOption?.variant?.name || null,
quantity: t.quantity || 1,
checkIns: bySession,
attended,
total,
completed: sessionsUtil.completionMet(config.rule, attended, total),
certificate: cert,
};
}).sort((a, b) => a.name.localeCompare(b.name));
const totals = Object.fromEntries(columns.map(c => [c.id, rows.reduce((n, r) => n + (r.checkIns[c.id] ? 1 : 0), 0)]));
return {
event: {
id: event.id, title: event.title, scheduleType: event.scheduleType, startDate: event.startDate, endDate: event.endDate,
cashupStatus: event.cashupStatus, certificatesEnabled: event.certificatesEnabled, certificateConfig: config,
},
columns,
rows,
totals,
};
}
// @desc Attendance register (sessions × attendees)
// @route GET /api/attendance/:eventId
// @access Private/Staff
const getRegister = async (req, res) => {
try {
res.json(await buildRegister(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @desc Tick someone present or absent for a session (e.g. let in without a scan)
// @route POST /api/attendance/:eventId/mark body: { ticketId, sessionId, present }
// @access Private/Supervisor
const markAttendance = async (req, res) => {
try {
const { eventId } = req.params;
const { ticketId, present } = req.body || {};
const sessionKey = String(req.body?.sessionId || '');
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true, sessionIds: true } } } });
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
const multi = ticket.event.scheduleType === 'sessions';
let sessionId = null;
if (multi) {
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
if (session.cancelledAt) throw badRequest('That session was cancelled');
if (ticket.registrationOption && !require('../utils/sessions').lineCovers(ticket.registrationOption, session.id)) throw badRequest('That ticket isn\'t booked for this session');
sessionId = session.id;
} else if (sessionKey !== WHOLE_EVENT) {
throw badRequest('This event has no sessions');
}
const forSession = ticket.usages.filter(u => (u.sessionId || null) === sessionId);
if (present === true) {
const redeemed = forSession.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const left = (ticket.quantity || 1) - redeemed;
if (left > 0) {
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: req.user.id, quantityRedeemed: left, sessionId, manual: true } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
}
} else {
if (forSession.length > 0) {
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map(u => u.id) } } });
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
}
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: present === true ? 'attendance_marked' : 'attendance_unmarked',
targetType: 'Ticket', targetId: ticket.id,
metadata: { eventId, sessionId, removedScans: present === true ? 0 : forSession.length },
ip: getClientIp(req),
});
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
};
function registerFileName(reg, ext) {
return `Attendance - ${reg.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + `.${ext}`;
}
function fmtColumnDate(d) {
return new Date(d).toLocaleDateString('en-GB', { timeZone: sessionsUtil.TIMEZONE, day: 'numeric', month: 'short' });
}
// @desc Download the attendance register as Excel or PDF
// @route GET /api/attendance/:eventId/export?format=xlsx|pdf
// @access Private/Staff
const exportRegister = async (req, res) => {
try {
const reg = await buildRegister(req.params.eventId);
const cols = reg.columns.filter(c => !c.cancelled);
if (req.query.format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'pdf')}"`);
doc.pipe(res);
doc.font('Helvetica-Bold').fontSize(16).text(`Attendance — ${reg.event.title}`);
doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(`${reg.rows.length} attendee(s) · printed ${new Date().toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE })}`);
doc.moveDown(0.6);
const nameW = 170;
const totalW = 50;
const usable = doc.page.width - 72 - nameW - totalW;
const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length)));
// Header height fits the longest session label.
const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`;
doc.font('Helvetica-Bold').fontSize(8);
const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 })));
const header = () => {
const y = doc.y;
doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW });
cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' }));
doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' });
doc.y = y + headH + 6;
doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke();
doc.y += 2;
};
// The built-in PDF fonts have no ✓, so the tick is drawn.
const tick = (cx, cy) => {
doc.save().lineWidth(1.4).strokeColor('#15803d')
.moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore();
};
header();
for (const r of reg.rows) {
if (doc.y > doc.page.height - 50) { doc.addPage(); header(); }
const y = doc.y;
doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false });
cols.forEach((c, i) => {
const qty = r.checkIns[c.id] || 0;
const x = 36 + nameW + i * colW;
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
});
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
doc.y = y + 14;
}
doc.moveDown(0.5);
const y = doc.y;
doc.font('Helvetica-Bold').fontSize(8.5).text('Present', 36, y, { width: nameW });
cols.forEach((c, i) => doc.text(String(reg.totals[c.id] || 0), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false }));
doc.end();
return;
}
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Attendance');
ws.addRow(['Name', 'Booked by', 'Ticket', 'Email', 'Phone', 'Places', ...cols.map(c => `${c.label} (${fmtColumnDate(c.startsAt)})`), 'Attended', 'Completed', 'Certificate']);
ws.getRow(1).font = { bold: true };
for (const r of reg.rows) {
ws.addRow([
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
]);
}
ws.addRow([]);
ws.addRow(['Present', '', '', '', '', '', ...cols.map(c => reg.totals[c.id] || 0)]).font = { bold: true };
ws.columns.forEach((col, i) => { col.width = i < 5 ? 22 : 14; });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'xlsx')}"`);
await wb.xlsx.write(res);
res.end();
} catch (error) {
fail(res, error);
}
};
// ─── Certificates (supervisor) ────────────────────────────────────────────────
async function assertCertificatesOn(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true, endDate: true, certificatesEnabled: true, certificateConfig: true } });
if (!event) throw notFound('Event not found');
if (!event.certificatesEnabled) throw badRequest('Certificates are switched off for this event (Edit event → Attendance).');
return event;
}
// @desc Issue certificates now — to everyone eligible, or one ticket (even if not eligible)
// @route POST /api/attendance/:eventId/certificates body: { ticketId? }
// @access Private/Supervisor
const issueCertificates = async (req, res) => {
try {
await assertCertificatesOn(req.params.eventId);
const created = req.body?.ticketId
? [await certificates.issueManual(req.params.eventId, String(req.body.ticketId), req.user.id)]
: await certificates.issueEligible(req.params.eventId, { issuedById: req.user.id });
for (const c of created) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_issued', targetType: 'Certificate', targetId: c.id, metadata: { eventId: req.params.eventId, ticketId: c.ticketId, manual: !!req.body?.ticketId }, ip: getClientIp(req) });
}
res.json({ issued: created.length });
} catch (error) {
fail(res, error);
}
};
// @desc Revoke a certificate (the automatic job won't issue it again)
// @route POST /api/attendance/certificates/:id/revoke
// @access Private/Supervisor
const revokeCertificate = async (req, res) => {
try {
const cert = await prisma.certificate.findUnique({ where: { id: req.params.id } });
if (!cert) throw notFound('Certificate not found');
await prisma.certificate.update({ where: { id: cert.id }, data: { revokedAt: new Date() } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_revoked', targetType: 'Certificate', targetId: cert.id, metadata: { eventId: cert.eventId, ticketId: cert.ticketId }, ip: getClientIp(req) });
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
};
// @desc Email/WhatsApp certificates that haven't been sent yet (or one, again)
// @route POST /api/attendance/:eventId/certificates/send body: { certificateId? }
// @access Private/Supervisor
const sendCertificatesHandler = async (req, res) => {
try {
await assertCertificatesOn(req.params.eventId);
const where = req.body?.certificateId
? { id: String(req.body.certificateId), eventId: req.params.eventId }
: { eventId: req.params.eventId, emailedAt: null };
const ids = (await prisma.certificate.findMany({ where: { ...where, revokedAt: null }, select: { id: true } })).map(c => c.id);
const sent = await certificates.sendCertificates(ids);
res.json({ sent, skipped: ids.length - sent });
} catch (error) {
fail(res, error);
}
};
// @desc All of an event's certificates in one PDF (one page each)
// @route GET /api/attendance/:eventId/certificates.pdf
// @access Private/Supervisor
const downloadEventCertificates = async (req, res) => {
try {
const event = await assertCertificatesOn(req.params.eventId);
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } });
if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
const pdf = await certificates.renderCertificatesPdf(certs, event);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// @desc Sample certificate with the editor's (unsaved) settings
// @route POST /api/attendance/certificates/preview body: { title, config }
// @access Private/Supervisor
const previewCertificate = async (req, res) => {
try {
const event = { title: String(req.body?.title || 'Your event').slice(0, 200), endDate: new Date() };
const pdf = await certificates.renderCertificatesPdf([certificates.sampleCertificate()], event, req.body?.config || {});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="certificate-preview.pdf"');
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// ─── Certificates (attendees, public) ─────────────────────────────────────────
// @desc The logged-in user's certificates
// @route GET /api/attendance/certificates/mine
// @access Private
const myCertificates = async (req, res) => {
try {
const certs = await prisma.certificate.findMany({
where: { revokedAt: null, OR: [{ userId: req.user.id }, { ticket: { userId: req.user.id } }] },
include: { event: { select: { id: true, title: true, endDate: true } } },
orderBy: { issuedAt: 'desc' },
});
res.json(certs.map(c => ({ id: c.id, code: c.code, name: c.name, issuedAt: c.issuedAt, sessionsAttended: c.sessionsAttended, totalSessions: c.totalSessions, event: c.event })));
} catch (error) {
fail(res, error);
}
};
// @desc Download one certificate (its person, the booker, or a supervisor)
// @route GET /api/attendance/certificates/:id/pdf
// @access Private
const downloadCertificate = async (req, res) => {
try {
const cert = await prisma.certificate.findUnique({
where: { id: req.params.id },
include: { ticket: { select: { userId: true } }, event: { select: { id: true, title: true, endDate: true, certificateConfig: true } } },
});
const allowed = cert && (SUPERVISORS.includes(req.user.role) || cert.userId === req.user.id || cert.ticket?.userId === req.user.id);
if (!allowed || cert.revokedAt) throw notFound('Certificate not found');
const pdf = await certificates.renderCertificatesPdf([cert], cert.event);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${`Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
res.send(pdf);
} catch (error) {
fail(res, error);
}
};
// @desc Check a certificate code (public verify page). Shows only name, event and date.
// @route GET /api/attendance/certificates/verify/:code
// @access Public
const verifyCertificate = async (req, res) => {
try {
const code = String(req.params.code || '').trim().toUpperCase().slice(0, 20);
const cert = await prisma.certificate.findUnique({ where: { code }, include: { event: { select: { title: true, endDate: true } } } });
if (!cert) return res.json({ valid: false });
res.json({
valid: !cert.revokedAt,
revoked: !!cert.revokedAt,
name: cert.name,
event: cert.event.title,
completedOn: cert.event.endDate,
issuedAt: cert.issuedAt,
});
} catch (error) {
fail(res, error);
}
};
module.exports = {
getRegister, markAttendance, exportRegister,
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
myCertificates, downloadCertificate, verifyCertificate,
};
+6 -1
View File
@@ -250,7 +250,12 @@ async function basketBuyer(req, forUserId) {
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
for (const o of options || []) {
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
// sessions ticked is still one line at one price).
const { isPickOption } = require('../utils/sessions');
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
+196 -23
View File
@@ -7,6 +7,85 @@ const crypto = require('crypto');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
// flagged), when registration closes, the next session, and late-joiner pricing.
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
function withSchedule(ev, bookings = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
// The schedule/attendance fields of an event from a create/update body. `current` is the
// saved event on update (unsent fields keep their value).
function scheduleData(body, current = {}) {
const has = (k) => body[k] !== undefined;
const data = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session after an event is created
* or updated, inside the caller's transaction (`tx`) so a refused change saves nothing.
* `lateJoinUntilSessionIndex` points into the sent session list (sorted by start).
* @returns {Promise<object|null>} the change counts, or null when nothing was sent
*/
async function saveSchedule(tx, eventId, body) {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
@@ -81,7 +160,14 @@ const createEvent = async (req, res) => {
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const logEventCreate = (createdEvent) => logAdminAction({
actorId: req.user?.id,
@@ -96,6 +182,7 @@ const createEvent = async (req, res) => {
try {
const event = await prisma.event.create({ data });
logEventCreate(event);
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body));
// Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
@@ -151,7 +238,7 @@ const createEvent = async (req, res) => {
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
return res.status(201).json(event);
return res.status(201).json(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } })));
} catch (err) {
// Gracefully handle environments where migration isn't applied yet
const msg = String(err?.message || '');
@@ -186,10 +273,49 @@ const createEvent = async (req, res) => {
throw err;
}
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// Map of eventId → when its early bird ends, for events with at least one early-bird
// tier still running: deadline not passed and (no stock limit, or places left, counted
// across tranches the same way as utils/pricing.js). The date is the latest deadline
// among the running tiers.
async function earlyBirdEndsByEvent(events) {
const now = new Date();
const running = [];
for (const ev of events) {
for (const opt of ev.eventOptions || []) {
for (const tier of opt.earlyBirdTiers || []) {
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
}
}
}
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
const sold = new Map();
if (limitedIds.length > 0) {
const totals = await prisma.registrationOptionTranche.groupBy({
by: ['appliedTierId'],
where: {
appliedTierId: { in: limitedIds },
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
}
const ends = new Map();
for (const { eventId, tier } of running) {
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
const deadline = new Date(tier.deadline);
const current = ends.get(eventId);
if (!current || deadline > current) ends.set(eventId, deadline);
}
return ends;
}
// @desc Get all events
// @route GET /api/events
// @access Public
@@ -207,7 +333,7 @@ const getEvents = async (req, res) => {
goLiveAt: { lte: new Date() },
endDate: { gte: new Date() }
},
include: { eventOptions: eventOptionsInclude }
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
});
} catch (e) {
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
@@ -220,20 +346,24 @@ const getEvents = async (req, res) => {
});
}
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
// Compute isSoldOut per event: true when every option that has a stock limit
// is fully sold out. Events with no limited options are never sold out.
const withStock = await Promise.all(events.map(async ev => {
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
// Picked-session options have per-session limits, so they never make the event sold out.
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
let isSoldOut = false;
if (limitedOpts.length > 0) {
const { takenCount } = require('../utils/stock');
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt });
} catch {
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt });
}
}));
@@ -251,11 +381,12 @@ const getAllEvents = async (req, res) => {
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const events = await prisma.event.findMany({
include: {
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
sessions: SESSIONS_INCLUDE,
}
});
const mapped = events.map(ev => ({
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
@@ -292,12 +423,13 @@ const getEventsAll = async (req, res) => {
where,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } }
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
},
orderBy: { startDate: 'asc' }
});
const mapped = events.map(ev => ({
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
@@ -320,7 +452,7 @@ const getEventById = async (req, res) => {
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true };
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
// event edit screen's Notifications step; public callers don't need this.
@@ -431,6 +563,16 @@ const getEventById = async (req, res) => {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
// Picked-session options: the stock limit is places per session.
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
@@ -448,7 +590,9 @@ const getEventById = async (req, res) => {
// Non-fatal: fall back to options without stock info
}
const mapped = {
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const mapped = withSchedule({
...event,
eventOptions: eventOptionsWithStock,
picture: toAbsoluteUrl(req, event.picture),
@@ -458,7 +602,7 @@ const getEventById = async (req, res) => {
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
};
}, bookings);
res.json(mapped);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
@@ -473,7 +617,7 @@ const getEventIcs = async (req, res) => {
const eventId = req.params.id;
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
});
if (!event) {
@@ -583,10 +727,36 @@ const updateEvent = async (req, res) => {
location: location !== undefined ? (location || null) : event.location,
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
try {
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body;
let updatedEvent;
let sessionChanges;
await prisma.$transaction(async (tx) => {
updatedEvent = await tx.event.update({ where: { id: req.params.id }, data });
sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single'
? await saveSchedule(tx, updatedEvent.id, scheduleBody)
: null;
});
sessionsUtil.forgetSchedule(updatedEvent.id);
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req),
});
}
updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } }));
updatedEvent.sessionChanges = sessionChanges;
// Upsert form if provided
@@ -768,7 +938,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -779,8 +949,10 @@ const createEventOption = async (req, res) => {
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
}
});
sessionsUtil.forgetSchedule(req.params.id);
res.status(201).json(eventOption);
} catch (error) {
@@ -805,7 +977,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -815,6 +987,7 @@ const updateEventOption = async (req, res) => {
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
}
});
@@ -1440,7 +1613,7 @@ const emailEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Generates a live Yoco checkout link for the recipient's first registration with an
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
@@ -1451,8 +1624,8 @@ const emailEventAttendees = async (req, res) => {
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
@@ -1682,7 +1855,7 @@ const whatsappEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
// Generates a live Yoco checkout link for the recipient's first registration with an
// Generates a live checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
@@ -1692,8 +1865,8 @@ const whatsappEventAttendees = async (req, res) => {
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const { createRegistrationCheckout } = require('../payments/checkouts');
const result = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const jobs = require('../jobs');
// @desc Background jobs: schedule, next run and the last 10 runs of each
// @route GET /api/admin/jobs
// @access Private/Admin
const listJobs = async (req, res) => {
try {
res.json(await jobs.jobStatus(prisma));
} catch (error) {
res.status(500).json({ message: safeErrorMessage(error) });
}
};
// @desc Run a background job now
// @route POST /api/admin/jobs/:name/run
// @access Private/Admin
const runJobNow = async (req, res) => {
try {
const result = await jobs.runNow(req.params.name);
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'job_run_manual',
targetType: 'Job', targetId: req.params.name, metadata: { queued: result.queued }, ip: getClientIp(req),
});
res.json(result);
} catch (error) {
res.status(error.statusCode || 500).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listJobs, runJobNow };
@@ -0,0 +1,97 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { maskSecret } = require('../utils/yoco');
const {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastNotifyUrl, notifyUrlProblem,
} = require('../utils/payfast');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// @desc PayFast settings (passphrase masked) and the ITN notify URL
// @route GET /api/payfast/config
// @access Admin
const getConfig = async (req, res) => {
try {
const c = await getPayfastConfig();
const notifyUrl = payfastNotifyUrl();
res.json({
merchantId: c.merchantId,
merchantKey: c.merchantKey,
passphraseMasked: c.passphrase ? maskSecret(c.passphrase) : '',
hasPassphrase: !!c.passphrase,
mode: c.mode,
notifyUrl,
notifyUrlProblem: notifyUrlProblem(notifyUrl),
configured: !!(c.merchantId && c.merchantKey),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save PayFast settings ('' clears the passphrase; omitted fields stay as they are)
// @route PUT /api/payfast/config { merchantId?, merchantKey?, passphrase?, mode? }
// @access Admin
const updateConfig = async (req, res) => {
try {
const { merchantId, merchantKey, passphrase, mode } = req.body || {};
const updates = {};
if (typeof merchantId === 'string') {
const v = merchantId.trim();
if (v && !/^\d{5,12}$/.test(v)) { res.status(400); throw new Error('The merchant ID is a number, e.g. 10000100.'); }
updates[KEY_MERCHANT_ID] = v;
}
if (typeof merchantKey === 'string') {
const v = merchantKey.trim();
if (v && !/^[A-Za-z0-9]{8,32}$/.test(v)) { res.status(400); throw new Error('The merchant key is letters and numbers, as shown on your PayFast dashboard.'); }
updates[KEY_MERCHANT_KEY] = v;
}
if (typeof passphrase === 'string') {
// PayFast passphrases are 32 characters at most; spaces at the ends are trimmed by PayFast.
if (passphrase.trim().length > 32) { res.status(400); throw new Error('The passphrase can be at most 32 characters.'); }
updates[KEY_PASSPHRASE] = passphrase.trim();
}
if (mode !== undefined) {
if (!['sandbox', 'live'].includes(mode)) { res.status(400); throw new Error('Mode is sandbox or live.'); }
updates[KEY_MODE] = mode;
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await prisma.$transaction(Object.entries(updates).map(([key, value]) => {
const stored = value && key === KEY_PASSPHRASE ? encrypt(value) : value;
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
}));
invalidateSettingsCache();
await warmCache();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'payfast_config_updated', targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates), ...(updates[KEY_MODE] ? { mode: updates[KEY_MODE] } : {}) },
ip: getClientIp(req),
});
res.json({ message: 'PayFast settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping)
// @route POST /api/payfast/test
// @access Admin
const testConnection = async (req, res) => {
try {
const c = await getPayfastConfig();
if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); }
await require('../payments/providers/payfast').testConnection();
res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, testConnection };
+69 -211
View File
@@ -5,8 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const payments = require('../payments');
const { createRegistrationCheckout } = require('../payments/checkouts');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -914,113 +914,23 @@ 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
* bundle saving and coupon discount as negative lines. Previous/partial payment lines are
* added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
for (const line of discountLines(registration)) {
items.push({ displayName: line.label, quantity: 1, pricingDetails: { price: -Math.round(line.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 } = {}) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
});
if (!registration) throw new Error('Registration not found');
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
const amountInCents = Math.round(remainingAmount * 100);
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const lineItems = checkoutLineItems(registration);
// Show prior payments as a deduction line
if (totalPaid > 0) {
lineItems.push({
displayName: 'Previous Payments',
quantity: 1,
pricingDetails: { price: -Math.round(totalPaid * 100) }
});
}
// Paying full remaining — no "Partial Payment" line needed
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
successUrl,
cancelUrl,
failureUrl,
lineItems
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
where: { id: registrationId },
data: { checkoutId: response.data.id, updatedAt: new Date() }
});
return {
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: remainingAmount,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
};
}
// @desc Create a Yoco checkout for a registration
// @route POST /api/payments/yoco-checkout
// @desc Start an online checkout: a registration's balance (all of it, or part of it, plus
// an optional donation) or a donation to an event. `provider` picks the payment
// provider; by default the first one enabled in Site settings → Payments.
// @route POST /api/payments/checkout (also /api/payments/yoco-checkout)
// @access Private
const createYocoCheckout = async (req, res) => {
const createCheckout = async (req, res) => {
try {
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl, provider } = req.body;
const userId = req.user.id;
// Validate required parameters for either registration payment or donation
if (!registrationId && !eventId) {
res.status(400);
throw new Error('Either registrationId or eventId is required');
}
// Branch 1: Registration payment — delegate to internal helper
// A registration's balance
if (registrationId) {
// Authorisation check (the internal helper skips this)
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
@@ -1033,10 +943,9 @@ const createYocoCheckout = async (req, res) => {
await assertEventOpen(reg.eventId, res);
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
// Must run before totalDue is computed so the correct price is used for the checkout amount.
// Must run before the balance is worked out so the checkout charges the right price.
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
if (priceChanged) {
// Re-fetch with fresh priceSnapshot values
const freshReg = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
@@ -1053,125 +962,75 @@ const createYocoCheckout = async (req, res) => {
});
}
// If a specific partial amount was passed, fall through to the old inline path
// Part of the balance (supervisor payments page)
if (amount && !isNaN(parseFloat(amount))) {
// Re-use old inline logic for partial payments
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
});
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); }
const requested = parseFloat(amount);
if (requested <= 0) { res.status(400); throw new Error('Amount must be greater than 0'); }
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 lineItems = checkoutLineItems(registration);
// Previous payments deduction
if (totalPaid > 0) {
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
}
// Partial payment deduction — amount being deferred to a future payment
if (charge < remainingAmount) {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
return res.status(200).json(result);
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
// Optional donation added at registration ("Donation amount" on the register form)
let donation = 0;
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
}
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
return res.status(200).json(result);
}
// Branch 2: Donation to an event
if (eventId) {
// Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < 15) {
res.status(400);
throw new Error('Minimum donation is R15');
}
const amountInCents = Math.round(amt * 100);
const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`;
const metadata = {
userId,
eventId,
eventTitle: event.title,
type: 'donation',
isDonation: true
};
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata,
successUrl,
cancelUrl,
failureUrl,
lineItems: [
{ displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } }
]
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: amt,
event: { id: event.id, title: event.title }
});
// A donation to an event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.statusCode || error.response?.status || 400).json({
message: error.response?.data?.message || error.message
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < payments.MIN_ONLINE_AMOUNT) {
res.status(400);
throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
}
const amountInCents = Math.round(amt * 100);
const checkout = await payments.createCheckout(provider, {
amountCents: amountInCents,
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
description: `Donation to ${event.title}`,
payer: { name: req.user.name || '', email: req.user.email || '' },
successUrl, cancelUrl, failureUrl,
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
});
return res.status(200).json({
checkoutId: checkout.checkoutId,
redirectUrl: checkout.redirectUrl,
provider: checkout.provider,
amount: amt,
event: { id: event.id, title: event.title }
});
} catch (error) {
console.error('Checkout error:', error.message);
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp
// @route POST /api/payments/yoco-checkout/send
// @desc Deliver a previously-generated payment link to the registrant via email or WhatsApp
// @route POST /api/payments/checkout/send (also /api/payments/yoco-checkout/send)
// @access Private/Supervisor
const sendPaymentLink = async (req, res) => {
try {
@@ -1460,8 +1319,7 @@ module.exports = {
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
createCheckout,
sendPaymentLink,
createRefund,
getPaymentStats,
@@ -0,0 +1,177 @@
const prisma = require('../config/db');
const payments = require('../payments');
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
const { safeErrorMessage } = require('../utils/errorUtils');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// @desc Payment providers people can pay with now (enabled and set up), in display order
// @route GET /api/payments/providers
// @access Public
const listPaymentProviders = async (req, res) => {
try {
const providers = await payments.availableProviders();
res.json({ providers: providers.map(p => ({ id: p.id, label: p.label })), minimumAmount: payments.MIN_ONLINE_AMOUNT });
} catch (e) {
fail(res, e);
}
};
// @desc Every provider with its Enabled flag, order and whether it's set up
// @route GET /api/payments/providers/settings
// @access Admin
const getPaymentProviderSettings = async (req, res) => {
try {
res.json({ providers: await payments.providerSettings(), minimumAmount: payments.MIN_ONLINE_AMOUNT });
} catch (e) {
fail(res, e);
}
};
// @desc Turn providers on/off and set their order
// @route PUT /api/payments/providers/settings { providers: [{ id, enabled }] } (in display order)
// @access Admin
const updatePaymentProviderSettings = async (req, res) => {
try {
const list = req.body?.providers;
if (!Array.isArray(list) || list.length === 0) { res.status(400); throw new Error('providers must be a list'); }
const unknown = list.find(p => !payments.getProvider(p?.id));
if (unknown) { res.status(400); throw new Error(`Unknown payment provider: ${unknown?.id}`); }
const before = await payments.providerSettings();
const value = payments.serializeProviderSettings(list);
await prisma.appSetting.upsert({ where: { key: payments.SETTINGS_KEY }, update: { value }, create: { key: payments.SETTINGS_KEY, value } });
invalidateSettingsCache();
await warmCache();
const after = await payments.providerSettings();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'payment_providers_updated', targetType: 'AppSetting',
metadata: { before: before.map(p => ({ id: p.id, enabled: p.enabled })), after: after.map(p => ({ id: p.id, enabled: p.enabled })) },
ip: getClientIp(req),
});
res.json({ message: 'Payment providers saved', providers: after });
} catch (e) {
fail(res, e);
}
};
/** A checkout and whether it has been paid (a payment came back for it, or its registration is paid). */
async function loadCheckout(id) {
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
if (!checkout) return null;
return { checkout, paid: await checkoutPaid(checkout) };
}
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
// @desc A checkout for the payer's page (/pay/:id): amount, what it's for, the providers
// they can pay with, and — once a form provider (PayFast) is chosen — its signed form
// @route GET /api/payments/pay/:id
// @access Public (the checkout id is the credential)
const getPayPage = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
const { checkout, paid } = found;
const request = checkout.request || {};
const out = {
id: checkout.id,
status: paid ? 'paid' : 'open',
amount: checkout.amountCents / 100,
description: request.description || '',
provider: checkout.provider,
providers: providerList(await payments.availableProviders()),
cancelUrl: request.cancelUrl || null,
redirectUrl: null,
form: null,
};
if (!paid && checkout.provider) {
const provider = payments.getProvider(checkout.provider);
if (checkout.redirectUrl) out.redirectUrl = checkout.redirectUrl;
else if (provider) out.form = (await payments.startCheckout(checkout, provider)).form || null;
}
res.json(out);
} catch (e) {
fail(res, e);
}
};
// @desc Start a checkout with the provider the payer picked
// @route POST /api/payments/pay/:id { provider }
// @access Public (the checkout id is the credential)
const choosePayProvider = async (req, res) => {
try {
const found = await loadCheckout(req.params.id);
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
if (found.paid) { res.status(409); throw new Error('This has already been paid.'); }
const provider = payments.getProvider(req.body?.provider);
if (!provider) { res.status(400); throw new Error('Choose how you\'d like to pay.'); }
const usable = (await payments.availableProviders()).some(p => p.id === provider.id);
if (!usable) { res.status(400); throw new Error(`Paying with ${provider.label} isn't available right now.`); }
const started = await payments.startCheckout(found.checkout, provider);
res.json({ provider: provider.id, redirectUrl: started.form ? null : started.redirectUrl, form: started.form || null });
} catch (e) {
fail(res, e);
}
};
const OPEN_DAYS = 14;
/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */
async function describeCheckout(c, users) {
const meta = c.metadata || {};
const provider = payments.getProvider(c.provider);
return {
id: c.id,
provider: c.provider,
providerLabel: provider?.label || c.provider,
canCheck: !!provider?.findPayments,
amount: c.amountCents / 100,
description: c.request?.description || meta.eventTitle || '',
type: meta.type || null,
registrationId: meta.registrationId || null,
payer: users.get(meta.userId) || c.request?.payer?.name || null,
createdAt: c.createdAt,
};
}
// @desc Online checkouts started in the last 14 days that have no payment yet
// @route GET /api/payments/checkouts/open
// @access Supervisor
const listOpenCheckouts = async (req, res) => {
try {
const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))];
const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name]));
res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS });
} catch (e) {
fail(res, e);
}
};
// @desc Ask the providers whether open checkouts were paid, and record what they find
// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one)
// @access Supervisor
const checkCheckoutStatus = async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null;
const list = ids
? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } })
: await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
const results = await checkCheckouts(list);
const out = Object.fromEntries(results);
const recorded = [...results.values()].filter(r => r.status === 'recorded').length;
res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' });
} catch (e) {
fail(res, e);
}
};
module.exports = {
listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider,
listOpenCheckouts, checkCheckoutStatus,
};
@@ -0,0 +1,239 @@
const prisma = require('../config/db');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { getProvider } = require('../payments');
const canReconcile = (req) => !!req.user && ['admin', 'supervisor'].includes(req.user.role);
// The checkout metadata (who/what the payment was for), read by the provider that sent it.
const metadataOf = (tx) => {
const provider = getProvider(tx.provider);
try { return (provider && tx.raw ? provider.parseWebhook(tx.raw).transaction.metadata : null) || {}; } catch { return {}; }
};
const withLabel = (tx) => ({ ...tx, providerLabel: getProvider(tx.provider)?.label || tx.provider, metadata: metadataOf(tx) });
// @desc Payment provider transactions (optionally filter by provider, reconciled, ignored)
// @route GET /api/provider-transactions (also /api/yoco-transactions)
// @access Private/Admin or Supervisor
const getAllProviderTransactions = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { reconciled, ignored, provider } = req.query;
const where = {};
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
if (provider) where.provider = String(provider);
const items = await prisma.providerTransaction.findMany({ where, orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }] });
return res.status(200).json({ success: true, data: items.map(withLabel) });
} catch (error) {
console.error('Failed to fetch provider transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Unreconciled provider transactions (not reconciled and not ignored)
// @route GET /api/provider-transactions/unreconciled (also /api/yoco-transactions/unreconciled)
// @access Private/Admin or Supervisor
const getUnreconciledProviderTransactions = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const items = await prisma.providerTransaction.findMany({
where: { reconciled: false, ignored: false, ...(req.query.provider ? { provider: String(req.query.provider) } : {}) },
orderBy: { createdDate: 'desc' },
});
return res.status(200).json({ success: true, data: items.map(withLabel) });
} catch (error) {
console.error('Failed to fetch unreconciled provider transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Reconcile a provider transaction to a Registration or Donation (creates Payment)
// @route POST /api/provider-transactions/:id/reconcile (also /api/yoco-transactions/:id/reconcile)
// @access Private/Admin or Supervisor
const reconcileProviderTransaction = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { id } = req.params;
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
if (tx.reconciled && tx.paymentId) {
return res.status(409).json({ success: false, message: 'Already reconciled', data: withLabel(tx) });
}
// Load registration if provided
let registration = null;
if (registrationId) {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
event: true,
user: true,
registrationOptions: { include: { eventOption: true } },
payments: true
}
});
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
}
// Determine payment fields
const amountFloat = typeof tx.amount === 'number' ? (tx.amount / 100) : 0;
if (!amountFloat || amountFloat <= 0) {
return res.status(400).json({ success: false, message: 'Invalid amount on the transaction for reconciliation' });
}
const metadata = metadataOf(tx);
// Resolve a valid userId for the Payment to satisfy FK constraints
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && metadata.userId) {
try {
const exists = await prisma.user.findUnique({ where: { id: String(metadata.userId) } });
if (exists) resolvedUserId = String(metadata.userId);
} catch {}
}
if (!resolvedUserId && req.user?.id) {
// Fallback to the acting supervisor/admin to avoid FK violations
resolvedUserId = req.user.id;
}
if (!resolvedUserId) {
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
}
const paymentData = {
amount: amountFloat,
method: tx.methodType || 'card',
provider: tx.provider,
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
externalId: tx.externalId || undefined,
status: 'completed'
};
// Ensure donation has an eventId
if (!registration && !paymentData.eventId) {
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
}
// Keep the provider's creation time so early-bird pricing is evaluated correctly
const createdAt = tx.createdDate || tx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Update registration status and generate tickets if paid. Ticket generation stays
// synchronous so `generatedTickets` can be in the response; emailing is backgrounded below.
let generatedTickets = [];
if (registration) {
try {
const updatedReg = await updateRegistrationStatus(registration.id);
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
} catch (genErr) {
console.error('Error generating tickets after reconciliation:', genErr);
}
}
} catch (e) {
// log but do not fail reconciliation
console.warn('Failed to update registration after reconciliation:', e?.message || e);
}
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after reconciliation:', e); }
}
})();
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: payment.id } });
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment, generatedTickets: generatedTickets.length ? generatedTickets : undefined } });
} catch (error) {
console.error('Failed to reconcile provider transaction:', error);
// A Payment with the same externalId already exists: link to it instead
if (error?.code === 'P2002') {
try {
const tx = await prisma.providerTransaction.findUnique({ where: { id: req.params.id } });
const existing = tx ? await prisma.payment.findFirst({ where: { externalId: tx.externalId } }) : null;
if (existing) {
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: existing.id } });
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment: existing }, message: 'Linked to existing payment' });
}
} catch {}
}
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
// Minimal registration status update mirroring webhook logic
async function updateRegistrationStatus(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true
}
});
if (!registration) return null;
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
let newStatus = 'pending';
if (totalPaid >= totalDue) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
}
// @desc Ignore a provider transaction (mark as ignored and reconciled without creating a payment)
// @route POST /api/provider-transactions/:id/ignore (also /api/yoco-transactions/:id/ignore)
// @access Private/Admin or Supervisor
const ignoreProviderTransaction = async (req, res) => {
try {
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
const { id } = req.params;
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
if (tx.reconciled && tx.ignored) {
return res.status(200).json({ success: true, data: withLabel(tx), message: 'Already ignored' });
}
const updated = await prisma.providerTransaction.update({ where: { id }, data: { ignored: true, reconciled: true } });
return res.status(200).json({ success: true, data: withLabel(updated) });
} catch (error) {
console.error('Failed to ignore provider transaction:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
module.exports = {
getAllProviderTransactions,
getUnreconciledProviderTransactions,
reconcileProviderTransaction,
ignoreProviderTransaction
};
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
@@ -52,6 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -91,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
@@ -121,7 +123,7 @@ const createRegistration = async (req, res) => {
: true;
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: optionInclude ? { include: optionInclude } : true }
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
});
if (!event) {
@@ -207,11 +209,10 @@ const createRegistration = async (req, res) => {
}
}
// Enforce registration cutoff for public registrations
// Enforce registration cutoff for public registrations (multi-day events close when
// the first session starts, or later with late joining — see utils/sessions.js).
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
if ((deadline && now >= deadline) || now >= end) {
if (now >= registrationClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Registration is closed for this event');
}
@@ -222,11 +223,21 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = expandPickLines(event, options, { now });
// (An all-sessions ticket with sessions ticked is checked per session instead.)
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= wholeCourseClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
const resolvedOptions = await Promise.all(options.map(async (option) => {
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
@@ -239,7 +250,7 @@ const createRegistration = async (req, res) => {
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -259,7 +270,7 @@ const createRegistration = async (req, res) => {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -320,9 +331,9 @@ const createRegistration = async (req, res) => {
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
await prisma.$transaction([
@@ -337,7 +348,7 @@ const createRegistration = async (req, res) => {
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
})
]);
} else {
@@ -348,6 +359,8 @@ const createRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -395,6 +408,8 @@ const createRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -941,7 +956,7 @@ const createManualRegistration = async (req, res) => {
//Check event
const event = await prisma.event.findUnique({
where: {id: eventId},
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
});
if (!event) {
@@ -962,9 +977,13 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Picked sessions become one line each; staff can still book a session until it ends.
const lines = expandPickLines(event, options, { staff: true });
await assertSessionCapacity(event, lines);
// Validate each option up front, before any account is created or changed —
// including stock, which staff registrations previously skipped.
for (const option of options) {
for (const option of lines) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
res.status(400);
@@ -975,8 +994,8 @@ const createManualRegistration = async (req, res) => {
throw new Error('Quantity must be at least 1');
}
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
const oStock = await checkOptionStock(eventOption, option.quantity);
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
@@ -986,7 +1005,7 @@ const createManualRegistration = async (req, res) => {
// 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 priceManualOptions = (attendee) => Promise.all(lines.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);
@@ -1158,7 +1177,7 @@ const createManualRegistration = async (req, res) => {
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
return prisma.$transaction([
@@ -1177,6 +1196,8 @@ const createManualRegistration = async (req, res) => {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
// More of an all-sessions ticket: it covers the sessions of both bookings.
...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }),
}
})
]);
@@ -1188,6 +1209,8 @@ const createManualRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1256,6 +1279,8 @@ const createManualRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1340,20 +1365,20 @@ const createManualRegistration = async (req, res) => {
await generateTicketsForRegistration(registration.id);
} catch {}
// For paid registrations, generate a Yoco checkout link to include in the email
// For paid registrations, generate a checkout link to include in the email
let yocoPaymentUrl = null;
if (registration.status !== 'paid') {
try {
const { createRegistrationCheckoutInternal } = require('./paymentController');
const { createRegistrationCheckout } = require('../payments/checkouts');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(registrationId, userId, {
const checkout = await createRegistrationCheckout(registrationId, userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
yocoPaymentUrl = checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for self-service email:', e?.message || e);
console.warn('Could not create a checkout for the self-service email:', e?.message || e);
}
}
@@ -1465,6 +1490,7 @@ const updateRegistrationOptions = async (req, res) => {
}
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
for (const opt of options) {
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
res.status(400);
@@ -1474,8 +1500,17 @@ const updateRegistrationOptions = async (req, res) => {
res.status(400);
throw new Error('One or more options do not belong to this event');
}
if (pickOptionIds.has(opt.eventOptionId)) {
res.status(400);
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
}
}
// Picked-session lines aren't edited here: leave them exactly as they are (they still
// count towards the total due).
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
// Compute totalPaid
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
@@ -1576,6 +1611,7 @@ const updateRegistrationOptions = async (req, res) => {
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
newTotalDue += rowsTotal(pickedLines);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
+12 -7
View File
@@ -8,9 +8,12 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
// Payment provider settings are only readable (secrets masked) and writable through
// /api/yoco and /api/payfast — never through the generic settings endpoints.
const PAYMENT_PRIVATE_KEYS = new Set([
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
@@ -21,6 +24,8 @@ const PUBLIC_KEYS = [
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Google Search Console "HTML tag" code, output as a meta tag on every page
'google_site_verification',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
@@ -48,7 +53,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -84,7 +89,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -123,7 +128,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -200,7 +205,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -1,5 +1,4 @@
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const payments = require('../payments');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -45,7 +44,7 @@ const getEventSponsorship = async (req, res) => {
}
};
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
// @desc Start an online checkout to sponsor spots (or a custom amount) at an event
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
// @access Public (guests give a name and email for the receipt)
const createSponsorCheckout = async (req, res) => {
@@ -72,9 +71,8 @@ const createSponsorCheckout = async (req, res) => {
const back = `${base}/events/${encodeURIComponent(event.id)}`;
const amountInCents = Math.round(charge.amount * 100);
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
const checkout = await payments.createCheckout(req.body?.provider || null, {
amountCents: amountInCents,
metadata: {
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
sponsorAnonymous: isAnonymous ? 'true' : 'false',
@@ -85,14 +83,14 @@ const createSponsorCheckout = async (req, res) => {
successUrl: `${back}?sponsored=1`,
cancelUrl: back,
failureUrl: `${back}?sponsored=failed`,
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
description: label,
payer: { name: payer.name || '', email: payer.email || '' },
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
} catch (error) {
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
if (error.statusCode >= 500) console.error('Sponsor checkout error:', error.message);
fail(res, error);
}
};
+148 -35
View File
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
@@ -102,7 +103,7 @@ const getUserTickets = async (req, res) => {
},
},
user: { select: { id: true, name: true } },
event: true,
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
@@ -262,12 +263,15 @@ const getScanPreview = async (req, res) => {
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
@@ -275,7 +279,8 @@ const getScanPreview = async (req, res) => {
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt"
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
@@ -303,12 +308,33 @@ const getScanPreview = async (req, res) => {
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
@@ -317,7 +343,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 || []
usages
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -337,6 +363,9 @@ const scanTicket = async (req, res) => {
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -345,7 +374,8 @@ const scanTicket = async (req, res) => {
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt"
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
@@ -356,7 +386,7 @@ const scanTicket = async (req, res) => {
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
@@ -405,11 +435,35 @@ const scanTicket = async (req, res) => {
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
@@ -452,11 +506,13 @@ const scanTicket = async (req, res) => {
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: fullyUsed, updatedAt: new Date() }
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
@@ -468,8 +524,10 @@ const scanTicket = async (req, res) => {
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
@@ -491,10 +549,10 @@ const getTicketsByEvent = async (req, res) => {
const tickets = await prisma.ticket.findMany({
where: { eventId: req.params.eventId },
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: 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 } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -845,6 +903,67 @@ function allocationLabel(attendee) {
.join(' · ');
}
// One ticket card on the PDF, matching the printed design in frontend/src/lib/ticketPrint.ts.
function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
const pad = 12; const radius = 9;
const innerW = w - pad * 2;
const gray700 = '#374151'; const gray500 = '#6b7280';
doc.save();
doc.roundedRect(x, y + 1.5, w, h, radius).fillOpacity(0.06).fill('#000000');
doc.restore();
doc.save();
doc.roundedRect(x, y, w, h, radius).lineWidth(0.75).fillAndStroke('#ffffff', '#e5e7eb');
doc.restore();
// Event title, with the date on the right of the same line
let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session, null, ticket.registrationOption.sessionIds)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false });
const titleW = innerW - (dateStr ? dateW + 8 : 0);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#111827');
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
// Ticket type
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
doc.font('Helvetica').fontSize(10).fillColor(gray700).text(type, x + pad, cy, { width: innerW });
cy += doc.heightOfString(type, { width: innerW }) + 2;
// Name (named ticket) or quantity, value in bold
doc.font('Helvetica').fontSize(9).fillColor(gray700)
.text(ticket.attendee ? 'Name: ' : 'Qty: ', x + pad, cy, { continued: true })
.font('Helvetica-Bold').text(ticket.attendee ? ticket.attendee.name : String(ticket.quantity || 1));
cy += 12;
const groupLabel = allocationLabel(ticket.attendee);
if (groupLabel) {
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(groupLabel, x + pad, cy, { width: innerW });
cy += doc.heightOfString(groupLabel, { width: innerW }) + 2;
}
// Footer lines are measured first so the QR code takes the remaining space
const footer = [`Ticket ID: ${ticket.id}`, ...(buyerName ? [`Purchased by: ${buyerName}`] : [])];
doc.font('Helvetica').fontSize(8.5);
const footerH = footer.reduce((sum, line) => sum + doc.heightOfString(line, { width: innerW }) + 2, 0);
const qrSpace = (y + h - pad - footerH) - (cy + 6) - 6;
const qrSize = Math.max(60, Math.min(130, qrSpace));
const qrY = cy + 6 + Math.max(0, (qrSpace - qrSize) / 2);
doc.image(qrDataUrl, x + (w - qrSize) / 2, qrY, { width: qrSize, height: qrSize });
let fy = y + h - pad - footerH;
doc.fillColor(gray500);
for (const line of footer) {
doc.text(line, x + pad, fy, { width: innerW, align: 'center' });
fy += doc.heightOfString(line, { width: innerW }) + 2;
}
doc.fillColor('#000000');
}
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
@@ -865,8 +984,8 @@ async function emailTicketsInternal(req, res, userOverride) {
}
const ticketInclude = {
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
@@ -951,32 +1070,22 @@ async function emailTicketsInternal(req, res, userOverride) {
const doc = new PDFDocument({ size: 'A4', margin: 20 });
const writeStream = fs.createWriteStream(tempFilePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
const ticketsPerRow = 2; const ticketsPerColumn = 4;
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
// Same card design as the printed tickets (frontend/src/lib/ticketPrint.ts): rounded white
// cards with a light border and soft shadow, two columns, three rows per page.
const margin = 20; const gap = 12;
const ticketsPerRow = 2; const ticketsPerColumn = 3;
const ticketWidth = (doc.page.width - margin * 2 - gap * (ticketsPerRow - 1)) / ticketsPerRow;
const ticketHeight = (doc.page.height - margin * 2 - gap * (ticketsPerColumn - 1)) / ticketsPerColumn;
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
QRCode.toDataURL(ticket.qrCode, { margin: 1, width: 400 }, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
})));
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
let ticketIndex = 0;
for (const ticket of tickets) {
const row = Math.floor(ticketIndex % ticketsPerColumn);
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
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 });
const groupLabel = allocationLabel(ticket.attendee);
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `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: ${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 });
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
const slot = ticketIndex % (ticketsPerRow * ticketsPerColumn);
const x = margin + (slot % ticketsPerRow) * (ticketWidth + gap);
const y = margin + Math.floor(slot / ticketsPerRow) * (ticketHeight + gap);
drawTicketCard(doc, { x, y, w: ticketWidth, h: ticketHeight }, ticket, qrCodeMap[ticket.id], ticket.user?.name || user.name);
ticketIndex++;
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
}
@@ -984,9 +1093,13 @@ async function emailTicketsInternal(req, res, userOverride) {
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title;
const eventDate = tickets[0].event.startDate
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
// All-sessions tickets with the same sessions ticked: those sessions.
const chosenKeys = new Set(tickets.map(t => (t.registrationOption?.sessionIds || []).join(',')));
const chosenIds = chosenKeys.size === 1 && !pinnedSessions.length ? tickets[0].registrationOption?.sessionIds : null;
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null, chosenIds) || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: '';
: '');
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
const sentChannels = [];
+29 -11
View File
@@ -2,8 +2,8 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { findAccountByContact } = require('../utils/accountMatch');
const { remainingFor } = require('../utils/stock');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken, placesFree, lineName } = require('../utils/waitlist');
const { activeSessions, isPickOption, sessionLabel } = require('../utils/sessions');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
@@ -11,19 +11,21 @@ const ENTRY_INCLUDE = {
event: { select: { id: true, title: true, startDate: true, picture: true } },
eventOption: { select: { id: true, name: true } },
variant: { select: { id: true, name: true } },
session: { select: { id: true, label: true, startsAt: true, endsAt: true } },
};
// @desc Join the waitlist for a sold-out option
// @route POST /api/waitlist
// @access Public (login needed when the event requires it)
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
// Body: { eventId, eventOptionId, variantId?, sessionId?, quantity, guestName?, guestEmail?, guestPhone? }
// sessionId: required for a "pick sessions" option (one session per entry), not allowed otherwise.
const joinWaitlist = async (req, res) => {
try {
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const { eventId, eventOptionId, variantId = null, sessionId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const qty = parseInt(quantity, 10);
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } }, sessions: true } });
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
@@ -32,7 +34,16 @@ const joinWaitlist = async (req, res) => {
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
const free = await remainingFor(option, variant);
if (isPickOption(event, option)) {
if (!sessionId) { res.status(400); throw new Error(`Choose which session of "${option.name}" you want to wait for.`); }
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
if (!session) { res.status(400); throw new Error('That session isn\'t available.'); }
if (new Date(session.startsAt) <= new Date()) { res.status(400); throw new Error(`${sessionLabel(session, event.sessions)} has already started.`); }
} else if (sessionId) {
res.status(400); throw new Error(`"${option.name}" covers every session — no session to choose.`);
}
const free = await placesFree(event, option, variant, sessionId);
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
// Who's joining
@@ -47,15 +58,15 @@ const joinWaitlist = async (req, res) => {
})).id;
}
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, sessionId, status: { in: ACTIVE } } });
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
const entry = await prisma.waitlistEntry.create({
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
data: { eventId, eventOptionId, variantId, sessionId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
include: ENTRY_INCLUDE,
});
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, sessionId, status: 'waiting', position: { lt: entry.position } } });
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
} catch (error) {
fail(res, error);
@@ -106,11 +117,15 @@ const claimInfo = async (req, res) => {
status: expired ? 'expired' : 'offered',
eventId: entry.eventId,
eventTitle: entry.event.title,
eventStart: entry.event.startDate,
eventStart: entry.session?.startsAt || entry.event.startDate,
eventOptionId: entry.eventOptionId,
optionName: entry.eventOption.name,
variantId: entry.variantId,
variantName: entry.variant?.name || null,
sessionId: entry.sessionId,
sessionName: entry.session ? sessionLabel(entry.session, entry.event.sessions) : null,
sessionStart: entry.session?.startsAt || null,
lineName: lineName(entry),
quantity: entry.offeredQuantity,
requested: entry.quantity,
expiresAt: entry.offerExpiresAt,
@@ -142,8 +157,11 @@ const eventWaitlist = async (req, res) => {
// @access Private/Supervisor
const offerManually = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id }, include: { session: true } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (entry.session && (entry.session.cancelledAt || new Date(entry.session.startsAt) <= new Date())) {
res.status(400); throw new Error(`The session they're waiting for has ${entry.session.cancelledAt ? 'been cancelled' : 'already started'}.`);
}
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
const updated = await makeOffer(entry.id, qty);
+3 -571
View File
@@ -1,518 +1,9 @@
const crypto = require('crypto');
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { getYocoWebhookSecret } = require('../utils/yoco');
const { handleProviderWebhook } = require('../payments/webhook');
// @desc Handle Yoco webhook events
// @desc Handle Yoco webhook events (signature check and processing: src/payments)
// @route POST /api/webhooks/yoco
// @access Public
const handleYocoWebhook = async (req, res) => {
try {
const headers = req.headers;
const requestBody = req.rawBody;
// Log every JSON request body for webhook controller
try {
const contentType = headers['content-type'] || headers['Content-Type'] || '';
let parsedBody = null;
if (typeof requestBody === 'string') {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
// Not valid JSON; keep as raw string
}
} else if (requestBody && typeof requestBody === 'object') {
parsedBody = requestBody;
}
} catch (logErr) {
// Fail-safe: never block webhook processing due to logging issues
console.error('Failed to log webhook JSON request:', logErr);
}
// Verify webhook signature
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const rawSigHeader = headers['webhook-signature'];
if (!id || !timestamp || !rawSigHeader) {
const responseObj = {
success: false,
message: 'Missing webhook headers',
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
};
return res.status(400).json(responseObj);
}
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
}
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Webhook secret from Site Settings → Yoco (falls back to .env)
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
const responseObj = {
success: false,
message: 'Server configuration error',
details: { error: 'Missing webhook secret configuration' }
};
return res.status(500).json(responseObj);
}
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Accept if any of the space-separated signatures match (supports key rotation)
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
const signatureValid = signatures.some(sig => {
try {
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
} catch {
return false;
}
});
if (!signatureValid) {
console.error('Invalid webhook signature');
const responseObj = {
success: false,
message: 'Invalid signature',
details: { error: 'Webhook signature verification failed' }
};
return res.status(403).json(responseObj);
}
// Parse the webhook payload
const webhookData = JSON.parse(requestBody);
// Extract checkoutId from payload or metadata
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
if (hasFilter) {
const idMatches = receivedCheckoutId && (
allowedIds.includes(receivedCheckoutId) ||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
);
if (!idMatches) {
// Store event for audit/unreconciled tracking
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
// Acknowledge but ignore processing for unrelated checkout flows
const responseObj = {
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: {
filtered: true,
receivedCheckoutId,
allowedIdsCount: allowedIds.length,
allowedPrefixesCount: allowedPrefixes.length
}
};
return res.status(200).json(responseObj);
}
}
} catch (filterErr) {
// Never block webhook due to filter parsing errors; log only
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Automatic filtering by database registration record, with donation allowance
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({
where: { checkoutId: receivedCheckoutId },
select: { id: true }
});
if (!existingRegistration) {
// Allow donations (no registration) to proceed based on metadata
const meta = webhookData?.payload?.metadata || {};
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
if (!isDonation) {
try {
await saveYocoTransaction(webhookData);
} catch (saveErr) {
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
}
const responseObj = {
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: {
filtered: true,
reason: 'no_registration_for_checkoutId',
receivedCheckoutId
}
};
return res.status(200).json(responseObj);
}
}
}
} catch (dbFilterErr) {
// Do not fail webhook due to DB filter issues
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
let processedData = null;
// Process different event types
switch (webhookData.type) {
case 'payment.succeeded':
processedData = await handlePaymentSucceeded(webhookData);
break;
// Add more event types as needed
default:
console.log(`Unhandled webhook event type: ${webhookData.type}`);
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
const responseObj = {
success: true,
message: 'Webhook received but event type not handled',
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp
}
};
return res.status(200).json(responseObj);
}
// Return detailed success response
const responseObj = {
success: true,
message: `Successfully processed ${webhookData.type} webhook`,
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp,
processedData
}
};
return res.status(200).json(responseObj);
} catch (error) {
console.error('Webhook error:', error);
const responseObj = {
success: false,
message: error.message,
details: {
error: error.stack,
timestamp: new Date().toISOString()
}
};
return res.status(500).json(responseObj);
}
};
// Handle payment.succeeded event
const handlePaymentSucceeded = async (webhookData) => {
try {
const paymentData = webhookData.payload;
// Log the payment data
console.log('Payment succeeded:', paymentData);
// Extract payment details
const {
id: yocoPaymentId,
amount,
currency,
status,
metadata,
paymentMethodDetails: method,
checkoutId
} = paymentData;
// Check if payment already exists to avoid duplicates
const existingPayment = await prisma.payment.findFirst({
where: {
externalId: yocoPaymentId
}
});
if (existingPayment) {
console.log(`Payment ${yocoPaymentId} already processed`);
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
return {
status: 'already_processed',
paymentId: existingPayment.id,
externalId: yocoPaymentId
};
}
// Find the registration by checkoutId
let registration = null;
if (checkoutId) {
registration = await prisma.registration.findFirst({
where: {
checkoutId: checkoutId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// If no registration found by checkoutId, try metadata
if (!registration && metadata && metadata.registrationId) {
registration = await prisma.registration.findUnique({
where: {
id: metadata.registrationId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
let resolvedUserId = registration?.userId || metadata?.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit
method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id,
...(!registration?.id && metadata?.type === 'sponsorship' ? {
isSponsorship: true,
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
} : {})
},
include: {
user: {
select: {
id: true,
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: true
}
} : undefined,
event: (registration?.eventId || metadata?.eventId) ? true : undefined
}
});
// If this payment is for a registration, update the registration status
let updatedRegistration = null;
let generatedTickets = [];
if (registration) {
updatedRegistration = await updateRegistrationStatus(registration.id);
// If registration status is 'paid', tickets were generated
if (updatedRegistration.status === 'paid') {
// Find the generated tickets
generatedTickets = await prisma.ticket.findMany({
where: {
registrationOption: {
registrationId: registration.id
}
},
select: {
id: true,
qrCode: true,
isUsed: true,
registrationOptionId: true,
eventId: true
}
});
}
}
console.log(`Payment ${yocoPaymentId} processed successfully`);
// Mark the Yoco transaction as reconciled and link the payment
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); }
// Send emails: payment confirmation first, then tickets (guarantees order)
const _whPaymentId = payment.id;
const _whUserId = registration?.userId;
const _whRegId = registration?.id;
const _whTicketsGenerated = generatedTickets.length > 0;
(async () => {
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(_whPaymentId);
} catch (e) {
console.error('Failed to send webhook payment emails:', e);
}
if (_whTicketsGenerated && _whUserId && _whRegId) {
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
}
})();
// Return processed data
return {
status: 'success',
payment: {
id: payment.id,
externalId: yocoPaymentId,
amount: payment.amount,
method: payment.method,
status: payment.status,
createdAt: payment.createdAt
},
registration: registration ? {
id: registration.id,
status: updatedRegistration ? updatedRegistration.status : registration.status,
eventId: registration.eventId,
eventTitle: registration.event?.title,
userId: registration.userId,
userName: registration.user?.name
} : null,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
};
} catch (error) {
console.error('Error processing payment.succeeded event:', error);
throw error;
}
};
// Update registration status based on payments
const updateRegistrationStatus = async (registrationId) => {
try {
// Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers
// are accounted for so we never mark a registration paid based on stale prices.
try { await refreshPricingForRegistration(registrationId); } catch (e) {
console.warn('[webhook] Price refresh failed:', e?.message);
}
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: {
include: {
earlyBirdTiers: true
}
},
tranches: true
}
},
payments: true
}
});
if (!registration) {
throw new Error(`Registration ${registrationId} not found`);
}
// Calculate total amount paid
const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0);
// Calculate total amount due (uses priceSnapshot — refreshed above)
const totalDue = computeRegistrationTotalDue(registration, new Date());
// Update registration status based on payment
let newStatus;
if (totalPaid >= totalDue) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial_paid';
} else {
newStatus = 'pending';
}
const updatedRegistration = await prisma.registration.update({
where: { id: registrationId },
data: {
status: newStatus,
updatedAt: new Date()
}
});
// Generate tickets if status is "paid" (email handled by caller after payment email)
let generatedTickets = [];
if (newStatus === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registrationId);
} catch (error) {
console.error('Error generating tickets:', error);
// Don't throw the error, just log it - we still want to update the registration status
}
}
// Return the updated registration
return {
...updatedRegistration,
totalPaid,
totalDue,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
};
} catch (error) {
console.error('Error updating registration status:', error);
throw error;
}
};
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
@@ -527,62 +18,3 @@ module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
// Helper: save or update Yoco transactions for any webhook event
async function saveYocoTransaction(webhookData, updates = {}) {
try {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
if (!externalId) {
throw new Error('Missing external id in webhook payload');
}
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
const currency = payload?.currency || null;
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
const methodType = payload?.paymentMethodDetails?.type || null;
// Try find existing
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing) {
// Update with any provided updates
if (updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({
where: { id: existing.id },
data: { ...updates, updatedAt: new Date() }
});
}
return existing;
}
// Create new
const record = await prisma.yocoTransaction.create({
data: {
externalId,
amount,
currency,
createdDate,
checkoutId,
methodType,
reconciled: false,
raw: webhookData,
...updates
}
});
return record;
} catch (e) {
// Unique constraint race: fetch existing
if (e?.code === 'P2002') {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing && updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
}
return existing;
}
throw e;
}
}
@@ -1,282 +0,0 @@
const prisma = require('../config/db');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
function getYocoModel() {
// Gracefully handle environments where the Prisma client hasn't been regenerated
// and YocoTransaction model is not available yet.
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
}
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
// @route GET /api/yoco-transactions
// @access Private/Admin or Supervisor
const getAllYocoTransactions = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const { reconciled, ignored } = req.query;
const where = {};
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where,
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
// @route GET /api/yoco-transactions/unreconciled
// @access Private/Admin or Supervisor
const getUnreconciledYocoTransactions = async (req, res) => {
try {
// Basic role check if auth middleware sets req.user
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where: { reconciled: false, ignored: false },
orderBy: { createdDate: 'desc' }
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch unreconciled Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
// @route POST /api/yoco-transactions/:id/reconcile
// @access Private/Admin or Supervisor
const reconcileYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.paymentId) {
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
}
// Load registration if provided
let registration = null;
if (registrationId) {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
event: true,
user: true,
registrationOptions: { include: { eventOption: true } },
payments: true
}
});
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
}
// Determine payment fields
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
if (!amountFloat || amountFloat <= 0) {
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
}
const externalId = ytx.externalId || undefined;
// Resolve a valid userId for the Payment to satisfy FK constraints
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
if (exists) resolvedUserId = metaUserId;
} catch {}
}
if (!resolvedUserId && req.user?.id) {
// Fallback to the acting supervisor/admin to avoid FK violations
resolvedUserId = req.user.id;
}
if (!resolvedUserId) {
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
}
// Build payment data
const paymentData = {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
externalId: externalId,
status: 'completed'
};
// Ensure donation has an eventId
if (!registration && !paymentData.eventId) {
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
}
// Create payment
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Optionally update registration status when applicable and generate tickets if paid.
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
let generatedTickets = [];
if (registration) {
try {
const updatedReg = await updateRegistrationStatus(registration.id);
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
} catch (genErr) {
console.error('Error generating tickets after Yoco reconciliation:', genErr);
}
}
} catch (e) {
// log but do not fail reconciliation
console.warn('Failed to update registration after reconciliation:', e?.message || e);
}
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
}
})();
// Update yoco transaction as reconciled
const updatedTx = await Yoco.update({
where: { id: ytx.id },
data: { reconciled: true, paymentId: payment.id }
});
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
} catch (error) {
console.error('Failed to reconcile Yoco transaction:', error);
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
if (error?.code === 'P2002') {
try {
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
if (existing) {
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
if (updatedTx) {
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
}
}
} catch {}
}
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
// Minimal registration status update mirroring webhook logic
async function updateRegistrationStatus(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true
}
});
if (!registration) return null;
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
let newStatus = 'pending';
if (totalPaid >= totalDue) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
}
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
// @route POST /api/yoco-transactions/:id/ignore
// @access Private/Admin or Supervisor
const ignoreYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.ignored) {
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
}
const updated = await Yoco.update({
where: { id },
data: { ignored: true, reconciled: true }
});
return res.status(200).json({ success: true, data: updated });
} catch (error) {
console.error('Failed to ignore Yoco transaction:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
module.exports = {
getAllYocoTransactions,
getUnreconciledYocoTransactions,
reconcileYocoTransaction,
ignoreYocoTransaction
};
+67 -213
View File
@@ -131,7 +131,7 @@ const webhookRoutes = require('./routes/webhookRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
const reportRoutes = require('./routes/reportRoutes');
const formRoutes = require('./routes/formRoutes');
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
const providerTransactionRoutes = require('./routes/providerTransactionRoutes');
const automationRoutes = require('./routes/automationRoutes');
const broadcastRoutes = require('./routes/broadcastRoutes');
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
@@ -147,6 +147,7 @@ const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
const payfastRoutes = require('./routes/payfastRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -163,7 +164,8 @@ app.use('/api/tickets', ticketRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/forms', formRoutes);
app.use('/api/yoco-transactions', yocoTransactionRoutes);
app.use('/api/provider-transactions', providerTransactionRoutes);
app.use('/api/yoco-transactions', providerTransactionRoutes); // older name
app.use('/api/automations', automationRoutes);
app.use('/api/broadcasts', broadcastRoutes);
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
@@ -185,9 +187,11 @@ app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/allocations', require('./routes/allocationRoutes'));
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/attendance', require('./routes/attendanceRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
app.use('/api/yoco', yocoRoutes);
app.use('/api/payfast', payfastRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -546,7 +550,7 @@ app.get('/docs', async (req, res) => {
responses:[
{ status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }},
]},
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event',
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event. Multi-day events send scheduleType:"sessions" with the full sessions list (rows left out are deleted; ones with check-ins must be cancelled instead), plus lateJoinEnabled, lateJoinUntilSessionIndex, latePricing, lateFixedPrice, checkInWindow, sessionReminders, certificatesEnabled and certificateConfig. The response includes sessionChanges.',
pathParams:{ ':id':'Event UUID' },
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
@@ -660,6 +664,8 @@ app.get('/docs', async (req, res) => {
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
@@ -739,6 +745,20 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]},
{ title: 'Attendance & certificates', base: '/api/attendance', endpoints: [
{ method:'GET', path:'/api/attendance/:eventId', auth:'staff+', desc:'Attendance register: sessions (columns), one row per main ticket with check-ins per session, completion and certificate',
responses:[{ status:200, desc:'Register', body:{ event:{ id:'ev-uuid-...', scheduleType:'sessions' }, columns:[{ id:'ses-uuid-...', label:'Week 1', startsAt:'2026-11-04T16:30:00.000Z', cancelled:false }], rows:[{ ticketId:'t-uuid-...', name:'Thandi', checkIns:{ 'ses-uuid-...':1 }, attended:1, total:5, completed:false, certificate:null }], totals:{ 'ses-uuid-...':12 } }}]},
{ method:'GET', path:'/api/attendance/:eventId/export?format=xlsx|pdf', auth:'staff+', desc:'Download the register as Excel or PDF' },
{ method:'POST', path:'/api/attendance/:eventId/mark', auth:'supervisor+', desc:'Mark a ticket present/absent for a session (single-date events use sessionId "event")', request:{ body:{ ticketId:'t-uuid-...', sessionId:'ses-uuid-...', present:true }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates', auth:'supervisor+', desc:'Issue certificates now to everyone eligible, or to one ticket (even if not eligible)', request:{ body:{ ticketId:'t-uuid-... (optional)' }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates/send', auth:'supervisor+', desc:'Email/WhatsApp unsent certificates, or one again', request:{ body:{ certificateId:'(optional)' }} },
{ method:'GET', path:'/api/attendance/:eventId/certificates.pdf', auth:'supervisor+', desc:'All of an event\'s certificates in one PDF' },
{ method:'POST', path:'/api/attendance/certificates/preview', auth:'supervisor+', desc:'Sample certificate PDF with unsaved settings', request:{ body:{ title:'Alpha Course', config:{ template:'classic' } }} },
{ method:'POST', path:'/api/attendance/certificates/:id/revoke', auth:'supervisor+', desc:'Revoke a certificate (never re-issued automatically)' },
{ method:'GET', path:'/api/attendance/certificates/mine', auth:'user+', desc:'My certificates' },
{ method:'GET', path:'/api/attendance/certificates/:id/pdf', auth:'user+', desc:'Download a certificate (its person, the booker, or a supervisor)' },
{ method:'GET', path:'/api/attendance/certificates/verify/:code', auth:'public', desc:'Check a certificate code — name, event and dates only', responses:[{ status:200, desc:'Result', body:{ valid:true, name:'Thandi Mokoena', event:'Alpha Course', completedOn:'2026-12-02T18:30:00.000Z' }}] },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
@@ -780,12 +800,21 @@ app.get('/docs', async (req, res) => {
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ 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' }},
{ method:'POST', path:'/api/payments/checkout', auth:'user+', desc:'Start an online checkout (also at /api/payments/yoco-checkout) with body.provider, or the first enabled provider. At least R15 (all providers). 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, provider:'yoco', successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Checkout created — send the payer to redirectUrl', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', provider:'yoco', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
]},
{ method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)',
@@ -995,19 +1024,20 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]},
]},
{ title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [
{ method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]},
{ method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment',
pathParams:{ ':id':'YocoTransaction UUID' },
request:{ body:{ paymentId:'pay-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'YocoTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]},
{ title: 'Provider Transactions', base: '/api/provider-transactions', endpoints: [
{ method:'GET', path:'/api/provider-transactions', auth:'supervisor+', desc:'Transactions payment providers sent by webhook (also at /api/yoco-transactions)',
queryParams:{ provider:'yoco', reconciled:'true|false', ignored:'true|false' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }] }}]},
{ method:'GET', path:'/api/provider-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
queryParams:{ provider:'yoco' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }] }}]},
{ method:'POST', path:'/api/provider-transactions/:id/reconcile', auth:'supervisor+', desc:'Record the transaction as a payment on a registration, or as a donation to an event',
pathParams:{ ':id':'ProviderTransaction UUID' },
request:{ body:{ registrationId:'reg-uuid-...', eventId:'(donation) event-uuid-...', userId:'(donation, optional) user-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ success:true, data:{ transaction:{ id:'ptx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }, payment:{ id:'pay-uuid-...', provider:'yoco' } } }}]},
{ method:'POST', path:'/api/provider-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'ProviderTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ success:true, data:{ id:'ptx-uuid-...', ignored:true } }}]},
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
@@ -1047,6 +1077,7 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
@@ -1055,6 +1086,11 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'PayFast', base: '/api/payfast', endpoints: [
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
@@ -1430,199 +1466,9 @@ app.listen(PORT, () => {
console.warn('[attachments sync] Not scheduled:', e?.message || e);
}
// Schedule daily summaries at 07:00 local time (configurable)
try {
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendDailyEventSummaries } = require('./utils/notifications');
function msUntilNext(hour, minute) {
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next.getTime() - now.getTime();
}
async function scheduleNext() {
const delay = msUntilNext(7, 0);
setTimeout(async () => {
try {
console.log('[daily summaries] Running daily event summaries...');
await sendDailyEventSummaries(new Date());
console.log('[daily summaries] Completed sending daily event summaries.');
} catch (e) {
console.error('[daily summaries] Failed:', e?.message || e);
} finally {
// Schedule next run
scheduleNext();
}
}, delay);
}
scheduleNext();
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
} else {
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
}
} catch (e) {
console.warn('[daily summaries] Not scheduled:', e?.message || e);
}
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
try {
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runWaitlistJob } = require('./utils/waitlist');
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runWaitlistJob();
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
} catch (e) {
console.warn('[waitlist] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runFeedbackJob } = require('./utils/feedback');
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runFeedbackJob();
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
} catch (e) {
console.warn('[feedback] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[feedback] Not scheduled:', e?.message || e);
}
// Daily parental-consent job — reminders for unanswered requests, and purging
// medical/emergency details a set number of days after each event.
try {
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
const runConsentJobs = async () => {
try {
const reminded = await sendConsentReminders(new Date());
const purged = await purgeConsentMedicalData(new Date());
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
} catch (e) {
console.warn('[consent] Daily job failed:', e?.message || e);
}
};
const scheduleConsent = () => {
const now = new Date();
const next = new Date(now);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
};
scheduleConsent();
console.log('[consent] Scheduler initialized (09:00 local time).');
} else {
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[consent] Not scheduled:', e?.message || e);
}
// Daily temp-file cleanup — removes files older than 7 days from /temp
try {
const { cleanupTempFiles } = require('./utils/cleanupTemp');
function scheduleTempCleanup() {
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(() => {
try {
const result = cleanupTempFiles();
if (result.deleted > 0 || result.errors > 0) {
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
}
} catch (e) {
console.warn('[temp cleanup] Failed:', e?.message || e);
} finally {
scheduleTempCleanup();
}
}, next.getTime() - now.getTime());
}
scheduleTempCleanup();
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
} catch (e) {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
// Scheduled emails worker (polling)
try {
@@ -1684,6 +1530,14 @@ app.listen(PORT, () => {
// Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down.
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
process.exit(0);
});
}
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
+271
View File
@@ -0,0 +1,271 @@
/**
* Background jobs — one registry, run by pg-boss (a job queue stored in Postgres).
*
* Why a queue instead of setInterval:
* - Schedules live in the database, so a job runs once per schedule even when more than
* one backend process is running (PM2 cluster, two servers on one database).
* - Every run is recorded (state, start/end, result or error), shown on the admin
* Background jobs page, which can also run a job straight away.
* - A run that fails is retried, and a slow run never overlaps the next one.
*
* Each job can be switched off with its env flag (e.g. WAITLIST_JOB_ENABLED=false), and
* its schedule changed with JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*\/5 * * * *").
* JOBS_ENABLED=false turns all of them off (used by the end-to-end tests).
* If the queue can't start (e.g. the database user can't create the pgboss schema), the
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
const JOBS = [
{
name: 'daily-summary', label: 'Daily event summary email', cron: '0 7 * * *', env: 'DAILY_SUMMARY_ENABLED',
run: async () => { await require('../utils/notifications').sendDailyEventSummaries(new Date()); return { sent: true }; },
},
{
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
run: () => require('../utils/waitlist').runWaitlistJob(),
},
{
name: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED',
run: () => require('../payments/status').runPaymentStatusJob(),
},
{
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
run: () => require('../utils/feedback').runFeedbackJob(),
},
{
name: 'sessions', label: 'Session reminders and certificates', cron: '*/5 * * * *', env: 'SESSION_JOBS_ENABLED',
run: async () => {
const reminders = await require('../utils/sessionReminders').runSessionReminderJob();
const certificates = await require('../utils/certificates').runCertificateJob();
return { reminders, certificates };
},
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
const { sendConsentReminders, purgeConsentMedicalData } = require('../utils/consent');
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
},
},
{
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
},
{
name: 'backup', label: 'Database backup', cron: '0 2 * * *', env: 'BACKUP_ENABLED',
run: async () => ({ file: (await require('../utils/backupUtils').runBackup()).filename }),
},
{
name: 'temp-cleanup', label: 'Delete temp files older than 7 days', cron: '0 3 * * *', env: null,
run: async () => require('../utils/cleanupTemp').cleanupTempFiles(),
},
];
const flag = (name, def = true) => {
const v = process.env[name];
return v === undefined || v === '' ? def : String(v).toLowerCase() !== 'false';
};
/** Whether a job is switched on (JOBS_ENABLED and its own flag). */
function isEnabled(job) {
return flag('JOBS_ENABLED') && (!job.env || flag(job.env));
}
/** A job's cron schedule, with any JOB_SCHEDULE_<NAME> override (checked). */
function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
}
}
return job.cron;
}
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
try {
const s = JSON.stringify(result ?? null);
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
} catch {
return '';
}
}
/** True when a job's result says nothing happened (all zeros/false/empty). */
function isNoop(value) {
if (value === null || value === undefined || value === 0 || value === false || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.values(value).every(isNoop);
return false;
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
// Quiet when nothing happened, like the old timers.
if (!isNoop(result)) console.log(`[jobs] ${job.name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
// ─── Runner ───────────────────────────────────────────────────────────────────
let boss = null;
let mode = 'off'; // 'queue' | 'timers' | 'off'
const timers = new Map();
// Timer mode keeps its own short history so the admin page still has something to show.
const timerHistory = new Map();
function startTimers(jobs) {
mode = 'timers';
for (const job of jobs) {
const cron = cronFor(job);
const scheduleNext = () => {
const delay = Math.max(1000, nextRun(cron).getTime() - Date.now());
timers.set(job.name, setTimeout(async () => {
const entry = { state: 'active', startedOn: new Date(), completedOn: null, output: null };
try {
entry.output = await runJob(job);
entry.state = 'completed';
} catch (e) {
entry.state = 'failed';
entry.output = { message: e?.message || String(e) };
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
} finally {
entry.completedOn = new Date();
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
scheduleNext();
}
}, Math.min(delay, 2 ** 31 - 1)));
};
scheduleNext();
}
console.log(`[jobs] Running ${jobs.length} job(s) on in-process timers.`);
}
/**
* Starts the background jobs. Call once after the server is listening.
* @returns {Promise<'queue'|'timers'|'off'>}
*/
async function startJobs() {
const enabled = JOBS.filter(isEnabled);
if (!flag('JOBS_ENABLED')) {
console.log('[jobs] All background jobs disabled by env JOBS_ENABLED=false');
mode = 'off';
return mode;
}
try {
const PgBoss = require('pg-boss');
boss = new PgBoss({ connectionString: process.env.DATABASE_URL, schema: SCHEMA, application_name: 'hope-events-jobs' });
boss.on('error', (e) => console.warn('[jobs] Queue error:', e?.message || e));
await boss.start();
const existing = new Set((await boss.getQueues()).map(q => q.name));
for (const job of JOBS) {
if (!existing.has(job.name)) {
// stately: at most one waiting and one running, so a slow run never piles up.
await boss.createQueue(job.name, { policy: 'stately', retryLimit: 2, retryDelay: 60, expireInSeconds: 60 * 60 });
}
if (isEnabled(job)) {
await boss.schedule(job.name, cronFor(job), null, { tz: TIMEZONE });
await boss.work(job.name, async () => {
try {
return await runJob(job);
} catch (e) {
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
});
} else {
await boss.unschedule(job.name).catch(() => {});
}
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
} catch (e) {
console.warn('[jobs] Job queue unavailable, falling back to timers:', e?.message || e);
if (boss) { try { await boss.stop({ graceful: false }); } catch { /* ignore */ } }
boss = null;
startTimers(enabled);
}
return mode;
}
async function stopJobs() {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
if (boss) {
await boss.stop({ graceful: true, timeout: 10000 }).catch(() => {});
boss = null;
}
mode = 'off';
}
/** Runs a job now (admin "Run now"). Queue mode: queued for a worker; timer mode: inline. */
async function runNow(name) {
const job = JOBS.find(j => j.name === name);
if (!job) {
const err = new Error('Unknown job');
err.statusCode = 404;
throw err;
}
if (mode === 'queue' && isEnabled(job)) {
const id = await boss.send(job.name, null, { singletonKey: `${job.name}-manual` });
return { queued: true, id };
}
const output = await runJob(job);
const entry = { state: 'completed', startedOn: new Date(), completedOn: new Date(), output };
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
return { queued: false, output };
}
/** Each job with its schedule, next run and recent runs, for the admin page. */
async function jobStatus(prisma) {
const recent = new Map();
if (mode === 'queue') {
const rows = await prisma.$queryRawUnsafe(`
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
ORDER BY "createdOn" DESC`, JOBS.map(j => j.name));
for (const r of rows) recent.set(r.name, [...(recent.get(r.name) || []), r]);
} else {
for (const [name, list] of timerHistory) recent.set(name, list);
}
return {
mode,
timezone: TIMEZONE,
jobs: JOBS.map(job => {
const cron = cronFor(job);
const enabled = isEnabled(job);
return {
name: job.name, label: job.label, cron, enabled, envFlag: job.env,
nextRun: enabled && mode !== 'off' ? nextRun(cron) : null,
recent: recent.get(job.name) || [],
};
}),
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
+96
View File
@@ -0,0 +1,96 @@
/**
* Online checkouts for registrations (the balance, part of it, or the balance plus a
* donation). Used by the payment pages, payment links in emails and invoices, and the
* self-service kiosk. The provider is the one asked for, the only available one, or the
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
* webhook can find it.
*/
const prisma = require('../config/db');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const payments = require('./index');
const CHECKOUT_INCLUDE = {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true,
};
/**
* The checkout's line items for a registration: one per line (with its tranches' prices),
* then bundle savings and the coupon discount as deductions. Callers add payment lines.
* @returns {{ name: string, quantity: number, unitCents: number }[]}
*/
function registrationLineItems(registration) {
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
name: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
unitCents: Math.round(row.unitPrice * 100),
}));
for (const line of discountLines(registration)) items.push({ name: line.label, quantity: 1, unitCents: -Math.round(line.amount * 100) });
return items;
}
/**
* Starts a checkout for a registration. Doesn't check who's asking — callers do.
* @param {object} opts
* @param {number} [opts.amount] - pay only this much of the balance (at least the online minimum)
* @param {number} [opts.donationAmount] - added on top as its own line; the webhook records it
* as a separate donation (it travels in the checkout metadata)
* @param {string} [opts.provider] - payment provider id (default: first available)
* @returns {Promise<{ checkoutId, redirectUrl, provider, amount, registration }>}
*/
async function createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0, amount = null, provider = null } = {}) {
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: CHECKOUT_INCLUDE });
if (!registration) throw payments.paymentError('Registration not found', 404);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remaining = totalDue - totalPaid;
if (remaining <= 0) throw payments.paymentError('This registration is already paid in full');
let charge = remaining;
if (amount !== null && amount !== undefined) {
const requested = parseFloat(amount);
if (!(requested > 0)) throw payments.paymentError('Amount must be greater than 0');
charge = Math.min(requested, remaining);
}
const donationCents = Math.round((Number(donationAmount) || 0) * 100);
const chargeCents = Math.round(charge * 100);
const lineItems = registrationLineItems(registration);
if (totalPaid > 0) lineItems.push({ name: 'Previous Payments', quantity: 1, unitCents: -Math.round(totalPaid * 100) });
// Paying part of the balance: the rest is deferred to a later payment
if (charge < remaining) lineItems.push({ name: 'Partial Payment', quantity: 1, unitCents: -Math.round((remaining - charge) * 100) });
if (donationCents > 0) lineItems.push({ name: 'Donation', quantity: 1, unitCents: donationCents });
const amountCents = chargeCents + donationCents;
const checkout = await payments.createCheckout(provider, {
amountCents,
metadata: {
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
...(donationCents > 0 ? { donationAmount: (donationCents / 100).toFixed(2) } : {}),
},
lineItems,
successUrl, cancelUrl, failureUrl,
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
description: registration.event.title,
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
});
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: checkout.checkoutId, updatedAt: new Date() } });
return {
checkoutId: checkout.checkoutId,
redirectUrl: checkout.redirectUrl,
provider: checkout.provider,
amount: amount !== null && amount !== undefined ? charge : amountCents / 100,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title },
};
}
module.exports = { createRegistrationCheckout, registrationLineItems };
+178
View File
@@ -0,0 +1,178 @@
/**
* Online payment providers.
*
* Every provider implements the same interface (see providers/yoco.js, providers/payfast.js):
* id, label
* capabilities { refund, getTransaction, listTransactions } — what its API supports
* isConfigured() → Promise<boolean> credentials are set
* createCheckout(input) → Promise<{ externalId?, redirectUrl? } | { form: { action, fields } }>
* input: { checkoutId, amountCents, currency, metadata, lineItems,
* successUrl, cancelUrl, failureUrl, payer?, description,
* idempotencyKey }; lineItems: [{ name, quantity, unitCents }]
* (negative unitCents = a deduction). A provider that takes the
* payer with a signed form (PayFast) returns { form }; the payer's
* browser posts it from /pay/<checkoutId>.
* verifyWebhook(req) → Promise<{ ok: true } | { ok: false, status, message, details? }>
* parseWebhook(body) → { type: 'payment.succeeded' | <other>, transaction }
* body: the request body as the provider sent it (JSON or form)
* transaction: { externalId, amountCents, currency, createdDate,
* checkoutId, methodType, metadata, status }
* readWebhookBody(raw) → (optional) turns req.rawBody into the body; default JSON.parse
* getTransaction / refund / listTransactions — only when the capability is true
*
* Every checkout is recorded as a ProviderCheckout (amount, what it's for, return URLs), so
* the payment that comes back can be matched and checked, and so the payer can be sent to
* /pay/<id>: there they choose a provider when several are enabled, or their browser posts a
* provider's form. Admins switch providers on and off and set their order in Site settings →
* Payments (AppSetting `payment_providers`). Provider fees are never deducted or added:
* payments are recorded at the full amount and fees are handled at cashup.
*/
const prisma = require('../config/db');
const { getSetting } = require('../utils/settingsCache');
const PROVIDERS = [require('./providers/yoco'), require('./providers/payfast')];
const BY_ID = new Map(PROVIDERS.map(p => [p.id, p]));
// One minimum for every provider: online payments must be at least R15.
const MIN_ONLINE_AMOUNT = 15;
const SETTINGS_KEY = 'payment_providers';
function paymentError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** A provider by id, or null. */
function getProvider(id) {
return BY_ID.get(String(id || '')) || null;
}
/**
* Enabled flags and order from the stored setting. Pure. Unknown ids are dropped; providers
* not in the setting yet come last — Yoco enabled (it was the only provider before), others off.
* @param {string} raw - JSON: { order: string[], enabled: { [id]: boolean } }
* @param {string[]} [ids] - known provider ids, in default order
* @returns {{ id: string, enabled: boolean }[]}
*/
function parseProviderSettings(raw, ids = PROVIDERS.map(p => p.id)) {
let stored = {};
try { stored = raw ? JSON.parse(raw) : {}; } catch { stored = {}; }
const order = Array.isArray(stored.order) ? stored.order.filter(id => ids.includes(id)) : [];
const all = [...new Set([...order, ...ids])];
const enabled = stored.enabled && typeof stored.enabled === 'object' ? stored.enabled : {};
return all.map(id => ({ id, enabled: typeof enabled[id] === 'boolean' ? enabled[id] : id === 'yoco' }));
}
/** Every provider with its settings and whether it's configured, in display order. */
async function providerSettings() {
const list = parseProviderSettings(await getSetting(SETTINGS_KEY));
return Promise.all(list.map(async ({ id, enabled }) => {
const p = getProvider(id);
return { id, label: p.label, enabled, configured: await p.isConfigured(), capabilities: p.capabilities };
}));
}
/** Providers people can pay with now (enabled and configured), in display order. */
async function availableProviders() {
return (await providerSettings()).filter(p => p.enabled && p.configured);
}
/** The setting value to store for an ordered list of { id, enabled }. */
function serializeProviderSettings(list) {
const ids = list.map(p => p.id).filter(id => getProvider(id));
return JSON.stringify({ order: ids, enabled: Object.fromEntries(list.filter(p => getProvider(p.id)).map(p => [p.id, p.enabled === true])) });
}
/** The payer's page for a checkout: /pay/<id> on the frontend. */
function payPageUrl(checkoutId) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3000').split(',')[0].trim().replace(/\/$/, '');
return `${base}/pay/${encodeURIComponent(checkoutId)}`;
}
/** Throws unless `provider` may take payments now (enabled; and set up, or it explains). */
async function assertUsable(provider) {
const state = (await providerSettings()).find(p => p.id === provider.id);
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
}
/**
* Starts a recorded checkout with a provider. Idempotent for a provider that was already
* started with a redirect (Yoco); form providers rebuild their (deterministic) form.
* @returns {Promise<{ provider, redirectUrl, form? }>}
*/
async function startCheckout(checkout, provider) {
if (checkout.provider === provider.id && checkout.redirectUrl) return { provider: provider.id, redirectUrl: checkout.redirectUrl };
const request = checkout.request || {};
const started = await provider.createCheckout({
...request,
checkoutId: checkout.id,
amountCents: checkout.amountCents,
currency: checkout.currency,
metadata: checkout.metadata,
});
if (started.form) {
if (checkout.provider !== provider.id) await prisma.providerCheckout.update({ where: { id: checkout.id }, data: { provider: provider.id } });
return { provider: provider.id, redirectUrl: payPageUrl(checkout.id), form: started.form };
}
await prisma.providerCheckout.update({
where: { id: checkout.id },
data: { provider: provider.id, externalId: started.externalId || null, redirectUrl: started.redirectUrl },
});
return { provider: provider.id, redirectUrl: started.redirectUrl };
}
/**
* Starts a checkout with `providerId`, or the only available provider; with several
* available, the payer chooses on /pay/<id>.
* @param {object} input - { amountCents, metadata, lineItems, successUrl, cancelUrl, failureUrl,
* payer?: { name, email }, description?, idempotencyKey? }
* @returns {Promise<{ checkoutId, redirectUrl, provider: string|null }>}
*/
async function createCheckout(providerId, input) {
if (!(input.amountCents >= MIN_ONLINE_AMOUNT * 100)) {
throw paymentError(`Online payments must be at least R${MIN_ONLINE_AMOUNT}. Please pay at the door or contact the organiser.`);
}
let provider = null;
const available = await availableProviders();
if (providerId) {
provider = getProvider(providerId);
if (!provider) throw paymentError('Unknown payment provider.');
await assertUsable(provider);
} else if (available.length === 1) {
provider = getProvider(available[0].id);
} else if (available.length === 0) {
// Nothing enabled: say so. Enabled but not set up: the provider explains what's missing.
const enabled = (await providerSettings()).find(p => p.enabled);
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
provider = getProvider(enabled.id);
}
const { amountCents, metadata, currency, ...request } = input;
const checkout = await prisma.providerCheckout.create({
data: { amountCents, currency: currency || 'ZAR', metadata: metadata || {}, request },
});
if (!provider) return { checkoutId: checkout.id, redirectUrl: payPageUrl(checkout.id), provider: null };
try {
const started = await startCheckout(checkout, provider);
return { checkoutId: checkout.id, redirectUrl: started.redirectUrl, provider: started.provider };
} catch (e) {
await prisma.providerCheckout.delete({ where: { id: checkout.id } }).catch(() => {});
throw e;
}
}
/** The checkout a webhook is about: by our id, or by the provider's own checkout id. */
async function findCheckout(providerId, ref) {
if (!ref) return null;
const ours = await prisma.providerCheckout.findUnique({ where: { id: String(ref) } }).catch(() => null);
if (ours) return ours;
return prisma.providerCheckout.findFirst({ where: { provider: providerId, externalId: String(ref) } });
}
module.exports = {
PROVIDERS, MIN_ONLINE_AMOUNT, SETTINGS_KEY, paymentError,
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders,
payPageUrl, assertUsable, createCheckout, startCheckout, findCheckout,
};
+303
View File
@@ -0,0 +1,303 @@
/**
* PayFast as a payment provider (see ../index.js for the interface), using PayFast's
* "custom integration" (developers.payfast.co.za/docs → Custom Integration).
*
* Checkout: the payer's browser posts a signed form to https://<host>/eng/process (from our
* /pay/<checkoutId> page). Fields go in the order PayFast lists them; the signature is the MD5
* of the non-blank fields as name=value pairs (URL-encoded, upper-case escapes, spaces as '+')
* joined with '&', plus &passphrase=… when one is set.
*
* ITN (Instant Transaction Notification): PayFast posts the result, form-encoded, to
* /api/webhooks/payfast. Before trusting it we check, as PayFast requires: the signature, that
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast
* says it's VALID. The amount is checked against the checkout in ../webhook.js.
*
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
* transaction history (its API, signed differently — see apiSignature). Refunds aren't done
* through the API: they're made in the PayFast dashboard and recorded with the manual flow.
*/
const crypto = require('crypto');
const dns = require('dns').promises;
const axios = require('axios');
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
// getTransaction: payments can be looked up in PayFast's transaction history (findPayments).
const capabilities = { refund: false, getTransaction: true, listTransactions: false };
// Hosts PayFast sends ITNs from.
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
async function isConfigured() {
const c = await getPayfastConfig();
return !!(c.merchantId && c.merchantKey);
}
/**
* URL-encodes a value the way PayFast's signature expects (PHP urlencode): every character
* but letters, digits and -_. is %-escaped in upper case, and spaces become '+'. Checked
* against the sandbox: PayFast's own Node sample, which leaves ' ( ) ! * ~ as they are, is
* rejected for names like "Men's Camp (Youth)"; this is accepted.
*/
function pfEncode(value) {
return encodeURIComponent(String(value).trim())
.replace(/[!'()*~]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%20/g, '+');
}
/**
* name=value&… of the pairs, in order. Pure. A checkout signs only the non-blank fields; an
* ITN is checked over every field PayFast posted, blanks included (`allFields`).
*/
function paramString(pairs, { allFields = false } = {}) {
const keep = allFields ? pairs : pairs.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
return keep.map(([k, v]) => `${k}=${pfEncode(v ?? '')}`).join('&');
}
/** PayFast's MD5 signature over ordered pairs (and the passphrase, if any). Pure. */
function signature(pairs, passphrase = '', opts = {}) {
let s = paramString(pairs, opts);
if (passphrase) s += `&passphrase=${pfEncode(passphrase)}`;
return crypto.createHash('md5').update(s).digest('hex');
}
/** Text we send PayFast (item and payer names): one line, within PayFast's length limit. Pure. */
function cleanText(value, max) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
}
/** "Camp ticket x2, T-shirt" → the item description (charged lines only). Pure. */
function describeLines(lineItems) {
return (lineItems || []).filter(l => l.unitCents > 0).map(l => `${l.name}${l.quantity > 1 ? ` x${l.quantity}` : ''}`).join(', ');
}
async function createCheckout({ checkoutId, amountCents, successUrl, cancelUrl, payer, description, lineItems }) {
const c = await getPayfastConfig();
if (!c.merchantId || !c.merchantKey) {
const err = new Error('PayFast isn\'t set up. An admin needs to add the merchant ID and key in Site Settings → Payments → PayFast.');
err.statusCode = 503;
throw err;
}
const notifyUrl = payfastNotifyUrl();
const [first, ...rest] = String(payer?.name || '').trim().split(/\s+/);
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(payer?.email || '')) && !String(payer.email).endsWith('.local') && !String(payer.email).endsWith('.invalid') ? payer.email : '';
const items = describeLines(lineItems);
// In the order PayFast documents them — the signature depends on it.
const pairs = [
['merchant_id', c.merchantId],
['merchant_key', c.merchantKey],
['return_url', successUrl],
['cancel_url', cancelUrl],
['notify_url', notifyUrl],
['name_first', cleanText(first, 100)],
['name_last', cleanText(rest.join(' '), 100)],
['email_address', email],
['m_payment_id', checkoutId],
['amount', (amountCents / 100).toFixed(2)],
['item_name', cleanText(description || items || 'Payment', 100)],
['item_description', cleanText(items, 255)],
].filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
const fields = Object.fromEntries(pairs.map(([k, v]) => [k, String(v).trim()]));
fields.signature = signature(pairs, c.passphrase);
return { form: { action: `https://${payfastHost(c.mode)}/eng/process`, fields } };
}
/** The ITN's name/value pairs, decoded, in the order PayFast sent them. Pure. */
function parsePairs(rawBody) {
return String(rawBody || '').split('&').filter(Boolean).map(kv => {
const i = kv.indexOf('=');
const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
return i < 0 ? [dec(kv), ''] : [dec(kv.slice(0, i)), dec(kv.slice(i + 1))];
});
}
function readWebhookBody(rawBody) {
return Object.fromEntries(parsePairs(rawBody));
}
/** The caller's IP, without IPv4-mapped-IPv6 noise (needs TRUST_PROXY behind a proxy). */
function requestIp(req) {
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
}
async function payfastIps() {
const all = await Promise.all(VALID_HOSTS.map(h => dns.lookup(h, { all: true }).catch(() => [])));
return new Set(all.flat().map(a => a.address));
}
/** PayFast's four ITN checks, less the amount (checked against the checkout in ../webhook.js). */
async function verifyWebhook(req, { lookupIps = payfastIps, post = axios.post } = {}) {
const pairs = parsePairs(req.rawBody);
const data = Object.fromEntries(pairs);
if (!data.signature || !data.pf_payment_id) return { ok: false, status: 400, message: 'Not a PayFast notification' };
const c = await getPayfastConfig();
if (!c.merchantId) return { ok: false, status: 500, message: 'PayFast isn\'t set up' };
if (data.merchant_id && data.merchant_id !== c.merchantId) return { ok: false, status: 403, message: 'Notification for a different PayFast merchant' };
// 1. Signature: every posted field before the signature, in order, plus our passphrase.
const signed = [];
for (const pair of pairs) { if (pair[0] === 'signature') break; signed.push(pair); }
if (signature(signed, c.passphrase, { allFields: true }) !== data.signature) {
console.error('PayFast ITN: invalid signature');
return { ok: false, status: 403, message: 'Invalid signature' };
}
// 2. It came from PayFast.
if (process.env.PAYFAST_SKIP_IP_CHECK !== 'true') {
const ip = requestIp(req);
if (!(await lookupIps()).has(ip)) {
console.error(`PayFast ITN from ${ip}, which isn't a PayFast address (behind a proxy? set TRUST_PROXY)`);
return { ok: false, status: 403, message: 'Notification did not come from PayFast' };
}
}
// 3. PayFast confirms the details.
let reply = '';
try {
const res = await post(`https://${payfastHost(c.mode)}/eng/query/validate`, paramString(signed, { allFields: true }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 });
reply = String(res.data || '').trim();
} catch (e) {
console.error('PayFast ITN: could not confirm with PayFast:', e.message);
return { ok: false, status: 502, message: 'Could not confirm the notification with PayFast' };
}
if (reply !== 'VALID') {
console.error('PayFast ITN: PayFast did not confirm it:', reply.slice(0, 100));
return { ok: false, status: 403, message: 'PayFast did not confirm the notification' };
}
return { ok: true, webhookId: data.pf_payment_id, timestamp: null };
}
/** An ITN body → { type, transaction }. Pure. */
function parseWebhook(body) {
const status = String(body?.payment_status || '').toUpperCase();
const gross = parseFloat(body?.amount_gross);
return {
type: status === 'COMPLETE' ? 'payment.succeeded' : `payment.${status.toLowerCase() || 'unknown'}`,
transaction: {
externalId: body?.pf_payment_id || null,
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
currency: 'ZAR',
createdDate: null,
checkoutId: body?.m_payment_id || null,
// PayFast doesn't say how the payer paid; it settles like a card payment.
methodType: null,
metadata: {},
status: status === 'COMPLETE' ? 'succeeded' : status.toLowerCase(),
},
};
}
// ─── PayFast API (developers.payfast.co.za/api) ──────────────────────────────
const API_BASE = 'https://api.payfast.co.za';
/**
* The API signature: every header and query variable plus the passphrase, sorted
* alphabetically, URL-encoded, joined with '&' (not the payment form's field order). Pure.
*/
function apiSignature(vars) {
const s = Object.keys(vars).sort()
.filter(k => vars[k] !== undefined && vars[k] !== null && String(vars[k]) !== '')
.map(k => `${k}=${pfEncode(vars[k])}`).join('&');
return crypto.createHash('md5').update(s).digest('hex');
}
/** A signed GET to the PayFast API; the sandbox adds ?testing=true (left out of the signature). */
async function apiGet(path, query = {}, { get = axios.get } = {}) {
const c = await getPayfastConfig();
const headers = { 'merchant-id': c.merchantId, version: 'v1', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') };
const signature = apiSignature({ ...headers, ...query, ...(c.passphrase ? { passphrase: c.passphrase } : {}) });
const params = { ...query, ...(c.mode === 'live' ? {} : { testing: 'true' }) };
try {
const res = await get(`${API_BASE}${path}`, { params, headers: { ...headers, signature }, timeout: 20000, responseType: 'text', transformResponse: r => r });
return res.data;
} catch (e) {
const status = e.response?.status;
let detail = '';
try { detail = JSON.parse(e.response?.data || '{}')?.data?.response || ''; } catch { /* not JSON */ }
const err = new Error(status === 401
? 'PayFast refused the merchant ID or passphrase (check they match your PayFast dashboard, and the mode).'
: `Could not reach PayFast${detail ? `: ${detail}` : status ? ` (${status})` : `: ${e.message}`}`);
err.statusCode = status === 401 ? 400 : 502;
throw err;
}
}
/** Checks the credentials with PayFast (GET /ping, signed). */
async function testConnection(deps) {
await apiGet('/ping', {}, deps);
return true;
}
/** Rows of a CSV with a header line, as objects keyed by the header. Pure. */
function parseCsv(text) {
const rows = [];
let row = [], field = '', quoted = false;
const s = String(text || '');
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (quoted) {
if (ch === '"' && s[i + 1] === '"') { field += '"'; i++; }
else if (ch === '"') quoted = false;
else field += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') { row.push(field); field = ''; }
else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && s[i + 1] === '\n') i++;
row.push(field); field = '';
if (row.some(v => v !== '')) rows.push(row);
row = [];
} else field += ch;
}
row.push(field);
if (row.some(v => v !== '')) rows.push(row);
const [header, ...data] = rows;
return header ? data.map(r => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))) : [];
}
const day = (d) => new Date(d).toISOString().slice(0, 10);
/**
* Payments PayFast received for our checkouts (from its transaction history, matched on
* "M Payment ID" = our checkout id). For notifications (ITNs) that never arrived.
* @param {object[]} checkouts - ProviderCheckout rows
* @returns {Promise<Map<string, object>>} checkout id → transaction (as parseWebhook gives)
*/
async function findPayments(checkouts, deps) {
const found = new Map();
if (!checkouts.length) return found;
const ids = new Set(checkouts.map(c => c.id));
const from = day(Math.min(...checkouts.map(c => new Date(c.createdAt).getTime())) - 24 * 60 * 60 * 1000);
const to = day(Date.now() + 24 * 60 * 60 * 1000);
const PAGE = 1000;
for (let offset = 0; offset < 20 * PAGE; offset += PAGE) {
const rows = parseCsv(await apiGet('/transactions/history', { from, to, limit: PAGE, offset }, deps));
for (const r of rows) {
const id = r['M Payment ID'];
if (!ids.has(id) || r.Type !== 'FUNDS_RECEIVED' || found.has(id)) continue;
const gross = parseFloat(String(r.Gross).replace(/,/g, ''));
found.set(id, {
externalId: r['PF Payment ID'] || null,
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
currency: r.Currency || 'ZAR',
// PayFast's history times are South African time
createdDate: r.Date ? new Date(`${r.Date.replace(' ', 'T')}+02:00`) : null,
checkoutId: id,
methodType: null,
metadata: {},
status: 'succeeded',
});
}
if (rows.length < PAGE || found.size === ids.size) break;
}
return found;
}
module.exports = {
id: 'payfast', label: 'PayFast', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
// for tests
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
};
+107
View File
@@ -0,0 +1,107 @@
/**
* Yoco as a payment provider (see ../index.js for the interface).
*
* Checkouts: POST /checkouts on the Yoco Checkout API; Yoco redirects the payer back and
* posts a `payment.succeeded` webhook to /api/webhooks/yoco. Webhooks are signed with the
* webhook secret (Standard Webhooks: HMAC-SHA256 over "id.timestamp.body").
* Keys and webhooks are managed in Site settings → Payments → Yoco (utils/yoco.js).
* Refunds and transaction lookups aren't done through Yoco's API here: refunds are made in
* the Yoco portal and recorded with the manual refund flow.
*/
const crypto = require('crypto');
const { getYocoSecretKey, getYocoWebhookSecret, yocoRequest } = require('../../utils/yoco');
// Yoco recommends rejecting webhooks more than 3 minutes old (replays).
const WEBHOOK_TOLERANCE_MS = 3 * 60 * 1000;
const capabilities = { refund: false, getTransaction: false, listTransactions: false };
async function isConfigured() {
return !!(await getYocoSecretKey());
}
/** Our line items → Yoco's (amounts in cents; a negative price is a deduction line). */
function toYocoLineItems(lineItems) {
return (lineItems || []).map(l => ({ displayName: l.name, quantity: l.quantity, pricingDetails: { price: l.unitCents } }));
}
async function createCheckout({ amountCents, currency, metadata, lineItems, successUrl, cancelUrl, failureUrl, idempotencyKey }) {
const data = await yocoRequest('post', '/checkouts', {
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
return { externalId: data.id, redirectUrl: data.redirectUrl };
}
/**
* The signature Yoco sends for a webhook. Pure.
* @param {string} secret - whsec_<base64>
*/
function webhookSignature(secret, id, timestamp, rawBody) {
const secretBytes = Buffer.from(String(secret).split('_')[1] || '', 'base64');
return crypto.createHmac('sha256', secretBytes).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
}
/** Checks the webhook headers, age and signature of an incoming request (req.rawBody). */
async function verifyWebhook(req, now = Date.now()) {
const headers = req.headers || {};
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const rawSigHeader = headers['webhook-signature'];
if (!id || !timestamp || !rawSigHeader) {
return {
ok: false, status: 400, message: 'Missing webhook headers',
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) },
};
}
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
if (isNaN(webhookTimeMs) || Math.abs(now - webhookTimeMs) > WEBHOOK_TOLERANCE_MS) {
return { ok: false, status: 400, message: 'Webhook timestamp outside acceptable window' };
}
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('Yoco webhook secret is not configured (Site Settings → Payments → Yoco, or YOCO_WEBHOOK_SECRET)');
return { ok: false, status: 500, message: 'Server configuration error', details: { error: 'Missing webhook secret configuration' } };
}
const expected = webhookSignature(secret, id, timestamp, req.rawBody);
// Accept if any of the space-separated signatures match (supports key rotation)
const signatures = String(rawSigHeader).split(' ').map(s => s.split(',')[1]).filter(Boolean);
const valid = signatures.some(sig => {
try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); } catch { return false; }
});
if (!valid) {
console.error('Invalid webhook signature');
return { ok: false, status: 403, message: 'Invalid signature', details: { error: 'Webhook signature verification failed' } };
}
return { ok: true, webhookId: id, timestamp };
}
/** A Yoco webhook body → { type, transaction }. Pure. */
function parseWebhook(body) {
const payload = body?.payload || {};
const metadata = payload.metadata || {};
const createdDate = payload.createdDate || body?.createdDate;
return {
type: body?.type,
transaction: {
externalId: payload.id || body?.id || null,
amountCents: typeof payload.amount === 'number' ? payload.amount : null,
currency: payload.currency || null,
createdDate: createdDate ? new Date(createdDate) : null,
checkoutId: payload.checkoutId || metadata.checkoutId || metadata.checkout_id || null,
methodType: payload.paymentMethodDetails?.type || null,
metadata,
status: payload.status || null,
},
};
}
module.exports = {
id: 'yoco', label: 'Yoco', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook,
// for tests
webhookSignature, toYocoLineItems,
};
+99
View File
@@ -0,0 +1,99 @@
/**
* Payment status checks — for when a provider's notification (webhook / ITN) never arrives.
*
* An open checkout is one we started with a provider that has no payment recorded yet. A
* provider that can look payments up (capabilities.getTransaction, findPayments) is asked
* whether it received one; anything it found is settled exactly as its webhook would have been
* (same amount check, same recording, same emails). Run every 15 minutes by the
* 'payment-status' job for recent checkouts, and on demand from the Payments page.
* Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their
* payments are found in the provider's portal and recorded with Reconcile.
*/
const prisma = require('../config/db');
const { getProvider } = require('./index');
const { settlePayment } = require('./webhook');
/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */
async function checkoutPaid(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
if (tx) return true;
const registrationId = checkout.metadata?.registrationId;
if (!registrationId) return false;
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
return reg?.status === 'paid';
}
/** A payment held for review on this checkout (paid a different amount), if any. */
async function heldForReview(checkout) {
const refs = [checkout.id, checkout.externalId].filter(Boolean);
return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } });
}
/**
* Asks each checkout's provider whether it was paid and records what it finds.
* @returns {Promise<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
*/
async function checkCheckouts(checkouts) {
const out = new Map();
const byProvider = new Map();
for (const c of checkouts) {
if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; }
const provider = getProvider(c.provider);
if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; }
if (!byProvider.has(provider.id)) byProvider.set(provider.id, []);
byProvider.get(provider.id).push(c);
}
for (const [providerId, list] of byProvider) {
const provider = getProvider(providerId);
const found = await provider.findPayments(list);
for (const c of list) {
const tx = found.get(c.id);
if (!tx) {
const held = await heldForReview(c);
out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' });
continue;
}
const event = {
type: 'payment.succeeded',
transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } },
checkoutRefs: [c.id, c.externalId].filter(Boolean),
};
const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c);
out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' });
}
}
return out;
}
/** Recent checkouts started with a provider and not paid yet, newest first. */
async function openCheckouts({ since, until = new Date() } = {}) {
const rows = await prisma.providerCheckout.findMany({
where: { provider: { not: null }, createdAt: { gte: since, lte: until } },
orderBy: { createdAt: 'desc' },
take: 200,
});
const open = [];
for (const c of rows) if (!(await checkoutPaid(c))) open.push(c);
return open;
}
/**
* Background job: checks checkouts started between 3 days and 10 minutes ago (giving the
* webhook time to arrive first) with providers that can be asked.
*/
async function runPaymentStatusJob(now = new Date()) {
const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const until = new Date(now.getTime() - 10 * 60 * 1000);
const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments);
if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 };
const results = [...(await checkCheckouts(checkable)).values()];
return {
checked: checkable.length,
recorded: results.filter(r => r.status === 'recorded').length,
needsReview: results.filter(r => r.status === 'needs_review').length,
};
}
module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob };
+301
View File
@@ -0,0 +1,301 @@
/**
* Payment webhooks, for every provider: the provider checks the signature and reads the
* body (providers/*.js); everything after that is shared here.
*
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
* Payments page.
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
* different amount isn't recorded — it waits on the Payments page with a note.
* - payment.succeeded for a registration or donation records a Payment at the full amount
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
* updates the registration's status and generates tickets once it's paid, then emails.
* - A provider that has been switched off still has its webhooks processed, so checkouts
* started before it was turned off complete.
*/
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { getProvider, findCheckout } = require('./index');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
/** Express handler for POST /api/webhooks/<providerId> (needs req.rawBody). */
const handleProviderWebhook = (providerId) => async (req, res) => {
try {
const provider = getProvider(providerId);
if (!provider) return res.status(404).json({ success: false, message: 'Unknown payment provider' });
const verified = await provider.verifyWebhook(req);
if (!verified.ok) {
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
}
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
const event = provider.parseWebhook(body);
const tx = event.transaction;
const receivedCheckoutId = tx.checkoutId;
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
// A registration remembers our checkout id (older ones: the provider's)
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
if (allowedIds.length > 0 || allowedPrefixes.length > 0) {
const idMatches = receivedCheckoutId && (allowedIds.includes(receivedCheckoutId) || allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)));
if (!idMatches) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store filtered webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: { filtered: true, receivedCheckoutId, allowedIdsCount: allowedIds.length, allowedPrefixesCount: allowedPrefixes.length },
});
}
}
} catch (filterErr) {
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Not one of our registration checkouts and not a donation → keep it for reconciling
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
if (!existingRegistration) {
const meta = tx.metadata || {};
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
if (!isDonation) {
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: { filtered: true, reason: 'no_registration_for_checkoutId', receivedCheckoutId },
});
}
}
}
} catch (dbFilterErr) {
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
if (event.type !== 'payment.succeeded') {
console.log(`Unhandled ${provider.id} webhook event type: ${event.type}`);
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store unhandled webhook event:', e?.message || e); }
return res.status(200).json({
success: true,
message: 'Webhook received but event type not handled',
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp },
});
}
const settled = await settlePayment(provider, event, body, checkout);
if (settled.needsReview) {
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
}
const processedData = settled.processedData;
return res.status(200).json({
success: true,
message: `Successfully processed ${event.type} webhook`,
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp, processedData },
});
} catch (error) {
console.error('Webhook error:', error);
return res.status(500).json({ success: false, message: error.message, details: { error: error.stack, timestamp: new Date().toISOString() } });
}
};
/**
* A successful payment for one of our checkouts (from a webhook, or found by a status check):
* held for review with a note when the amount differs from the checkout, otherwise recorded.
* @returns {Promise<{ needsReview: true, note } | { needsReview: false, processedData }>}
*/
async function settlePayment(provider, event, body, checkout) {
const tx = event.transaction;
if (checkout && tx.amountCents !== checkout.amountCents) {
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
return { needsReview: true, note };
}
return { needsReview: false, processedData: await recordSucceededPayment(provider.id, event, body) };
}
const REG_INCLUDE ={ event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
async function recordSucceededPayment(providerId, event, body) {
const { externalId, amountCents, status, metadata = {}, methodType, checkoutId } = event.transaction;
console.log(`Payment succeeded (${providerId}):`, externalId);
const existingPayment = await prisma.payment.findFirst({ where: { externalId } });
if (existingPayment) {
console.log(`Payment ${externalId} already processed`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existingPayment.id }); } catch (e) { console.warn('Failed to reconcile transaction for existing payment:', e?.message || e); }
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
}
let registration = null;
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
let resolvedUserId = registration?.userId || metadata.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch { /* payment will fail below if still null */ }
}
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
// recorded as its own donation payment so it never counts towards the registration.
const grossAmount = amountCents / 100;
const donationPart = registration?.id ? Math.min(Math.max(Number(metadata.donationAmount) || 0, 0), grossAmount) : 0;
const method = methodType || 'card';
const paymentStatus = status === 'succeeded' ? 'completed' : status;
const payment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: Math.round((grossAmount - donationPart) * 100) / 100,
method,
provider: providerId,
status: paymentStatus,
externalId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata.eventId || null,
isDonation: !registration?.id,
...(!registration?.id && metadata.type === 'sponsorship' ? {
isSponsorship: true,
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
} : {}),
},
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(), amount: donationPart, method, provider: providerId, status: paymentStatus,
userId: resolvedUserId, recordedById: resolvedUserId, registrationId: null, eventId: registration.eventId,
isDonation: true, originalPaymentId: payment.id,
},
});
}
let updatedRegistration = null;
let generatedTickets = [];
if (registration) {
updatedRegistration = await updateRegistrationStatus(registration.id);
if (updatedRegistration.status === 'paid') {
generatedTickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
select: { id: true, qrCode: true, isUsed: true, registrationOptionId: true, eventId: true },
});
}
}
console.log(`Payment ${externalId} processed successfully`);
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: payment.id }); } catch (e) { console.warn('Failed to reconcile transaction after payment create:', e?.message || e); }
// Emails: payment confirmation first, then tickets (guarantees order)
const regUserId = registration?.userId;
const regId = registration?.id;
const sendTickets = generatedTickets.length > 0;
(async () => {
const { sendPaymentEmails } = require('../utils/notifications');
try { await sendPaymentEmails(payment.id); } catch (e) { console.error('Failed to send webhook payment emails:', e); }
if (donationPayment) {
try { await sendPaymentEmails(donationPayment.id); } catch (e) { console.error('Failed to send webhook donation emails:', e); }
}
if (sendTickets && regUserId && regId) {
const { emailTickets } = require('../controllers/ticketController');
const mockReq = { user: { id: regUserId }, body: { registrationId: regId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
}
})();
return {
status: 'success',
payment: { id: payment.id, externalId, amount: payment.amount, method: payment.method, status: payment.status, createdAt: payment.createdAt },
registration: registration ? {
id: registration.id,
status: updatedRegistration ? updatedRegistration.status : registration.status,
eventId: registration.eventId,
eventTitle: registration.event?.title,
userId: registration.userId,
userName: registration.user?.name,
} : null,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
};
}
/** Sets a registration's status from its payments; generates tickets once it's paid. */
async function updateRegistrationStatus(registrationId) {
// Refresh early-bird pricing first so a registration is never marked paid on stale prices.
try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('[webhook] Price refresh failed:', e?.message); }
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
});
if (!registration) throw new Error(`Registration ${registrationId} not found`);
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const newStatus = totalPaid >= totalDue ? 'paid' : totalPaid > 0 ? 'partial_paid' : 'pending';
const updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
// Tickets are generated here; the caller emails them after the payment email.
let generatedTickets = [];
if (newStatus === 'paid') {
try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); }
}
return { ...updatedRegistration, totalPaid, totalDue, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined };
}
/** Stores (or updates) the transaction behind a webhook event. Idempotent per provider + id. */
async function saveProviderTransaction(providerId, event, body, updates = {}) {
const tx = event.transaction;
const externalId = tx.externalId;
if (!externalId) throw new Error('Missing external id in webhook payload');
const where = { provider_externalId: { provider: providerId, externalId } };
const touch = (existing) => (Object.keys(updates).length
? prisma.providerTransaction.update({ where: { id: existing.id }, data: updates })
: existing);
try {
const existing = await prisma.providerTransaction.findUnique({ where });
if (existing) return touch(existing);
return await prisma.providerTransaction.create({
data: {
provider: providerId, externalId,
amount: tx.amountCents, currency: tx.currency, createdDate: tx.createdDate,
checkoutId: tx.checkoutId, methodType: tx.methodType,
reconciled: false, raw: body, ...updates,
},
});
} catch (e) {
// Unique constraint race: another request stored it first
if (e?.code === 'P2002') {
const existing = await prisma.providerTransaction.findUnique({ where });
return existing ? touch(existing) : null;
}
throw e;
}
}
module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
+3
View File
@@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { listJobs, runJobNow } = require('../controllers/jobsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/jobs', protect, admin, listJobs);
router.post('/jobs/:name/run', protect, admin, runJobNow);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const {
getRegister, markAttendance, exportRegister,
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
myCertificates, downloadCertificate, verifyCertificate,
} = require('../controllers/attendanceController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
// Certificates — fixed paths first so they aren't taken as an :eventId
router.get('/certificates/verify/:code', verifyCertificate);
router.get('/certificates/mine', protect, myCertificates);
router.post('/certificates/preview', protect, supervisor, previewCertificate);
router.get('/certificates/:id/pdf', protect, downloadCertificate);
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
// Attendance register
router.get('/:eventId', protect, staff, getRegister);
router.get('/:eventId/export', protect, staff, exportRegister);
router.post('/:eventId/mark', protect, supervisor, markAttendance);
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.post('/test', protect, admin, testConnection);
module.exports = router;
+13 -2
View File
@@ -9,17 +9,28 @@ const {
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createCheckout,
sendPaymentLink,
createRefund,
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
// Protected routes
router.post('/', protect, supervisor, createPayment);
router.post('/yoco-checkout', protect, createYocoCheckout);
router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider);
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout);
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
// Older names, kept for existing clients
router.post('/yoco-checkout', protect, createCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
@@ -0,0 +1,20 @@
const express = require('express');
const router = express.Router();
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
const { protect, supervisor} = require('../middleware/authMiddleware');
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
// GET all payment provider transactions (admin/supervisor)
router.get('/', protect, supervisor, getAllProviderTransactions);
// GET all unreconciled transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
// POST reconcile a transaction
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
// POST ignore a transaction
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
module.exports = router;
+3 -1
View File
@@ -1,9 +1,11 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Yoco webhook endpoint
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
module.exports = router;
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController');
const { protect, supervisor} = require('../middleware/authMiddleware');
// GET all Yoco transactions (admin/supervisor)
router.get('/', protect, supervisor, getAllYocoTransactions);
// GET all unreconciled Yoco transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions);
// POST reconcile a Yoco transaction
router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction);
// POST ignore a Yoco transaction
router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction);
module.exports = router;
+365
View File
@@ -0,0 +1,365 @@
/**
* Completion certificates — optional per event (Event.certificatesEnabled).
*
* Rules:
* - One certificate per main ticket (one person). Add-on tickets never get one.
* - Eligible when the ticket's attendance meets the event's completion rule
* (multi-day: sessions attended out of sessions held; single date: checked in).
* - Issued automatically once the event has ended, or by a supervisor at any time
* ("Issue now" for everyone eligible, or one person manually even if not eligible).
* - A revoked certificate stays revoked: the automatic job never re-issues it.
* - Each certificate has a short code that the public verify page checks.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const prisma = require('../config/db');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const AUTO_ISSUE_DAYS = 14;
/** A random code like "K7M2Q-9XRTB" (no 0/O/1/I to avoid misreading). */
function newCode() {
const bytes = crypto.randomBytes(10);
const chars = Array.from(bytes, b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
return `${chars.slice(0, 5)}-${chars.slice(5)}`;
}
/** Fills {name}, {event}, {sessionsAttended}, {totalSessions}, {endDate} in certificate text. */
function fillPlaceholders(text, values) {
return String(text || '').replace(/\{(name|event|sessionsAttended|totalSessions|endDate)\}/g, (_, k) => String(values[k] ?? ''));
}
function fmtLongDate(d) {
return new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, day: 'numeric', month: 'long', year: 'numeric' });
}
function ticketName(t) {
return t.attendee?.name || t.holder?.name || t.user?.name || 'Attendee';
}
/**
* Attendance per main ticket of an event: [{ ticket, attended, total }]. Multi-day events
* count sessions attended (cancelled sessions left out); single-date events count 1 when
* checked in.
*/
async function attendanceForCertificates(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { sessions: true } });
if (!event) return { event: null, rows: [] };
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
include: {
attendee: { select: { name: true } },
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true } },
},
orderBy: { createdAt: 'asc' },
});
if (event.scheduleType === 'sessions') {
const active = activeSessions(event.sessions);
const activeIds = new Set(active.map(s => s.id));
const bySession = await attendanceBySession(eventId);
// A picked-session booking (one ticket per session) is one person: its first ticket
// carries the certificate, and it's measured against the sessions it booked.
return {
event,
rows: groupTickets(tickets).map(g => {
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
const attendedIds = new Set();
for (const t of g.tickets) {
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
}
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
}),
};
}
return { event, rows: tickets.map(t => ({ ticket: t, attended: t.usages.length > 0 ? 1 : 0, total: 1 })) };
}
/**
* Issues certificates for everyone who meets the completion rule and doesn't have one yet
* (revoked ones are left alone). Returns the new certificates.
*/
async function issueEligible(eventId, { issuedById = null } = {}) {
const { event, rows } = await attendanceForCertificates(eventId);
if (!event || !event.certificatesEnabled) return [];
const config = normalizeCertificateConfig(event.certificateConfig);
const existing = new Set((await prisma.certificate.findMany({ where: { eventId }, select: { ticketId: true } })).map(c => c.ticketId));
const created = [];
for (const r of rows) {
if (existing.has(r.ticket.id) || !completionMet(config.rule, r.attended, r.total)) continue;
try {
created.push(await prisma.certificate.create({
data: {
eventId, ticketId: r.ticket.id, userId: r.ticket.holderUserId || r.ticket.userId,
name: ticketName(r.ticket), code: newCode(), sessionsAttended: r.attended, totalSessions: r.total,
issuedById,
},
}));
} catch (e) {
if (e?.code !== 'P2002') throw e; // issued at the same moment by the job — fine
}
}
return created;
}
/** Issues (or re-issues a revoked) certificate for one ticket, eligible or not. */
async function issueManual(eventId, ticketId, issuedById) {
const { event, rows } = await attendanceForCertificates(eventId);
const r = rows.find(x => x.ticket.id === ticketId);
if (!event || !r) {
const err = new Error('Ticket not found for this event');
err.statusCode = 404;
throw err;
}
const data = { name: ticketName(r.ticket), sessionsAttended: r.attended, totalSessions: r.total, manual: true, issuedById, revokedAt: null, issuedAt: new Date() };
return prisma.certificate.upsert({
where: { eventId_ticketId: { eventId, ticketId } },
create: { eventId, ticketId, userId: r.ticket.holderUserId || r.ticket.userId, code: newCode(), ...data },
update: data,
});
}
// ─── PDF ──────────────────────────────────────────────────────────────────────
const PAGE = { size: 'A4', layout: 'landscape', margin: 0 };
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return [30, 58, 95];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function lighten(hex, amount) {
const [r, g, b] = hexToRgb(hex);
const mix = (c) => Math.round(c + (255 - c) * amount);
return `#${[mix(r), mix(g), mix(b)].map(c => c.toString(16).padStart(2, '0')).join('')}`;
}
function uploadPath(url) {
if (!url || /^https?:\/\//i.test(url)) return null;
const p = path.join(__dirname, '..', '..', 'public', String(url).replace(/^\//, ''));
return fs.existsSync(p) ? p : null;
}
function safeImage(doc, file, x, y, opts) {
if (!file) return false;
try { doc.image(file, x, y, opts); return true; } catch { return false; }
}
// Shared bottom block: signature (left), date (middle), verification QR + code (right).
function drawFooter(doc, { cert, config, branding, qr, color, font, bold, y }) {
const W = doc.page.width;
const colW = 200;
const sigX = 90;
const sigImg = uploadPath(config.signatureImage);
if (sigImg) safeImage(doc, sigImg, sigX + 20, y - 46, { fit: [160, 42] });
doc.moveTo(sigX, y).lineTo(sigX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
doc.font(bold).fontSize(11).fillColor('#1e293b').text(config.signatureName || branding.name, sigX, y + 6, { width: colW, align: 'center' });
if (config.signatureRole) doc.font(font).fontSize(9).fillColor('#64748b').text(config.signatureRole, sigX, y + 21, { width: colW, align: 'center' });
const dateX = W / 2 - colW / 2;
doc.moveTo(dateX, y).lineTo(dateX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
doc.font(bold).fontSize(11).fillColor('#1e293b').text(fmtLongDate(cert.issuedAt), dateX, y + 6, { width: colW, align: 'center' });
doc.font(font).fontSize(9).fillColor('#64748b').text('Date', dateX, y + 21, { width: colW, align: 'center' });
const qrSize = 64;
const qrX = W - 90 - qrSize;
safeImage(doc, qr, qrX, y - 50, { width: qrSize });
doc.font(font).fontSize(7.5).fillColor('#64748b').text(`Verify: ${cert.code}`, qrX - 40, y + 18, { width: qrSize + 80, align: 'center' });
doc.fillColor(color);
}
function drawTemplate(doc, template, ctx) {
const { cert, config, branding, event, title, body } = ctx;
const W = doc.page.width;
const H = doc.page.height;
const color = branding.brandColor;
const logo = branding.logoPath;
if (template === 'modern') {
doc.rect(0, 0, W, H).fill('#ffffff');
doc.rect(0, 0, 28, H).fill(color);
doc.rect(28, 0, 6, H).fill(lighten(color, 0.6));
const x = 90;
safeImage(doc, logo, x, 70, { fit: [120, 50] });
doc.font('Helvetica').fontSize(11).fillColor(color).text(branding.name.toUpperCase(), x, 150, { characterSpacing: 2 });
doc.font('Helvetica-Bold').fontSize(36).fillColor('#0f172a').text(title, x, 175, { width: W - x - 80 });
doc.font('Helvetica').fontSize(13).fillColor('#64748b').text('Presented to', x, 260);
doc.font('Helvetica-Bold').fontSize(32).fillColor(color).text(cert.name, x, 280, { width: W - x - 80 });
doc.font('Helvetica').fontSize(14).fillColor('#334155').text(body, x, 340, { width: W - x - 140, lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 80 });
return;
}
if (template === 'church') {
doc.rect(0, 0, W, H).fill(lighten(color, 0.94));
doc.rect(30, 30, W - 60, H - 60).lineWidth(1).strokeColor(lighten(color, 0.4)).stroke();
const hasLogo = safeImage(doc, logo, W / 2 - 50, 55, { fit: [100, 70], align: 'center' });
const top = hasLogo ? 150 : 115;
doc.font('Times-Italic').fontSize(14).fillColor(color).text(branding.name, 0, top, { width: W, align: 'center' });
doc.font('Times-Bold').fontSize(34).fillColor('#1e293b').text(title, 60, top + 26, { width: W - 120, align: 'center' });
doc.moveTo(W / 2 - 60, top + 74).lineTo(W / 2 + 60, top + 74).lineWidth(1.5).strokeColor(color).stroke();
doc.font('Times-Italic').fontSize(14).fillColor('#475569').text('This is to certify that', 0, top + 88, { width: W, align: 'center' });
doc.font('Times-Bold').fontSize(30).fillColor(color).text(cert.name, 60, top + 110, { width: W - 120, align: 'center' });
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, top + 162, { width: W - 240, align: 'center', lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
return;
}
if (template === 'kids') {
const bright = ['#f59e0b', '#ec4899', '#22c55e', '#3b82f6', '#a855f7'];
doc.rect(0, 0, W, H).fill('#fffdf5');
doc.roundedRect(22, 22, W - 44, H - 44, 28).lineWidth(10).strokeColor(color).stroke();
// Confetti dots around the edge.
let seed = parseInt(cert.code.replace(/[^A-Z0-9]/g, '').slice(0, 6), 36) || 7;
const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
// Confetti in the top band and down the sides — never over the text or the footer.
for (let i = 0; i < 46; i++) {
const top = rnd() < 0.45;
const x = top ? 50 + rnd() * (W - 100) : (rnd() < 0.5 ? 48 + rnd() * 45 : W - 93 + rnd() * 45);
const y = top ? 48 + rnd() * 50 : 110 + rnd() * (H - 260);
doc.circle(x, y, 3 + rnd() * 5).fill(bright[i % bright.length]);
}
safeImage(doc, logo, W / 2 - 40, 100, { fit: [80, 50], align: 'center' });
doc.font('Helvetica-Bold').fontSize(38).fillColor(color).text(title, 100, 165, { width: W - 200, align: 'center' });
doc.font('Helvetica-Bold').fontSize(15).fillColor('#f59e0b').text('Well done,', 0, 230, { width: W, align: 'center' });
doc.font('Helvetica-Bold').fontSize(36).fillColor('#1e293b').text(cert.name, 100, 250, { width: W - 200, align: 'center' });
doc.font('Helvetica').fontSize(15).fillColor('#334155').text(body, 130, 310, { width: W - 260, align: 'center', lineGap: 5 });
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 90 });
return;
}
// classic
doc.rect(0, 0, W, H).fill('#fffefa');
doc.rect(24, 24, W - 48, H - 48).lineWidth(3).strokeColor(color).stroke();
doc.rect(34, 34, W - 68, H - 68).lineWidth(0.8).strokeColor(color).stroke();
for (const [cx, cy] of [[34, 34], [W - 34, 34], [34, H - 34], [W - 34, H - 34]]) {
doc.circle(cx, cy, 6).fill(color);
}
safeImage(doc, logo, W / 2 - 45, 70, { fit: [90, 55], align: 'center' });
doc.font('Times-Bold').fontSize(38).fillColor('#1e293b').text(title.toUpperCase(), 60, 150, { width: W - 120, align: 'center', characterSpacing: 1.5 });
doc.font('Times-Italic').fontSize(14).fillColor('#64748b').text('This certificate is proudly presented to', 0, 212, { width: W, align: 'center' });
doc.font('Times-BoldItalic').fontSize(34).fillColor(color).text(cert.name, 60, 236, { width: W - 120, align: 'center' });
doc.moveTo(W / 2 - 170, 284).lineTo(W / 2 + 170, 284).lineWidth(0.8).strokeColor('#cbd5e1').stroke();
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, 300, { width: W - 240, align: 'center', lineGap: 5 });
doc.font('Times-Italic').fontSize(11).fillColor('#64748b').text(branding.name, 0, H - 132, { width: W, align: 'center' });
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
void event;
}
/**
* Renders certificates (one page each) to a PDF Buffer.
* @param {object[]} certs - Certificate rows ({ name, code, issuedAt, sessionsAttended, totalSessions })
* @param {object} event - { title, endDate, certificateConfig }
* @param {object} [configOverride] - certificate settings to use instead (editor preview)
*/
async function renderCertificatesPdf(certs, event, configOverride = null) {
const { getBranding } = require('./pdfDocs');
const branding = await getBranding();
const config = normalizeCertificateConfig(configOverride || event.certificateConfig);
const doc = new PDFDocument({ ...PAGE, autoFirstPage: false, info: { Title: `${config.title} — ${event.title}` } });
const chunks = [];
doc.on('data', c => chunks.push(c));
const done = new Promise((resolve, reject) => { doc.on('end', resolve); doc.on('error', reject); });
for (const cert of certs) {
doc.addPage(PAGE);
const values = { name: cert.name, event: event.title, sessionsAttended: cert.sessionsAttended, totalSessions: cert.totalSessions, endDate: fmtLongDate(event.endDate) };
const qrData = await QRCode.toDataURL(`${branding.url}/certificates/verify/${encodeURIComponent(cert.code)}`, { margin: 0, width: 200 });
const qr = Buffer.from(qrData.split(',')[1], 'base64');
drawTemplate(doc, config.template, {
cert, config, branding, event, qr,
title: fillPlaceholders(config.title, values),
body: fillPlaceholders(config.body, values),
});
}
doc.end();
await done;
return Buffer.concat(chunks);
}
/** A sample certificate for the event editor's preview button. */
function sampleCertificate() {
return { name: 'Thandi Mokoena', code: 'SAMPL-E2026', issuedAt: new Date(), sessionsAttended: 5, totalSessions: 5 };
}
// ─── Sending ──────────────────────────────────────────────────────────────────
/** Emails (and WhatsApps, per preference) each certificate to its person. Returns how many went out. */
async function sendCertificates(certIds) {
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
const { shouldEmail, waPdf, waPdfAny } = require('./notify');
const certs = await prisma.certificate.findMany({
where: { id: { in: certIds }, revokedAt: null },
include: {
event: { select: { id: true, title: true, endDate: true, certificateConfig: true } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
},
});
const org = getOrg();
let sent = 0;
for (const cert of certs) {
const user = cert.user;
if (!user) continue;
try {
const pdf = await renderCertificatesPdf([cert], cert.event);
const filename = `Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + '.pdf';
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
const jobs = [];
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p>Hi ${user.name?.split(' ')[0] || 'there'},</p>
<p>Congratulations on completing <strong>${cert.event.title}</strong>! Your certificate for <strong>${cert.name}</strong> is attached.</p>
${ctaButton('View my events', `${org.url}/dashboard/user`)}
<p style="font-size:13px;color:#64748b">Anyone can check it at ${org.url}/certificates/verify/${cert.code}</p>`, { preheader: `Your certificate for ${cert.event.title}` });
jobs.push(sendMail({ to: user.email, subject: `Your certificate — ${cert.event.title}`, html, text: `Congratulations on completing ${cert.event.title}! Your certificate is attached.`, attachments: [{ filename, content: pdf, contentType: 'application/pdf' }] }));
}
const { tempDir } = require('./pdfDocs');
const tmp = path.join(tempDir(), `certificate-${cert.id}.pdf`);
fs.writeFileSync(tmp, pdf);
const caption = `🎓 Your certificate for ${cert.event.title}`;
jobs.push(hasEmail ? waPdf(user, tmp, filename, caption) : waPdfAny(user, tmp, filename, caption));
await Promise.all(jobs);
await prisma.certificate.update({ where: { id: cert.id }, data: { emailedAt: new Date() } });
sent += 1;
} catch (e) {
console.warn('[certificates] Send failed:', e?.message || e);
}
}
return sent;
}
/**
* Background job: for events with certificates on that ended within the last 14 days,
* issue certificates to everyone eligible, and send them when the event says so.
*/
async function runCertificateJob(now = new Date()) {
const since = new Date(now.getTime() - AUTO_ISSUE_DAYS * 24 * 60 * 60 * 1000);
const events = await prisma.event.findMany({
where: { certificatesEnabled: true, endDate: { lte: now, gte: since } },
select: { id: true, certificateConfig: true },
});
let issued = 0;
let sent = 0;
for (const ev of events) {
const created = await issueEligible(ev.id);
issued += created.length;
if (created.length && normalizeCertificateConfig(ev.certificateConfig).sendOnIssue) {
sent += await sendCertificates(created.map(c => c.id));
}
}
return { issued, sent };
}
module.exports = {
newCode, fillPlaceholders, attendanceForCertificates, issueEligible, issueManual,
renderCertificatesPdf, sampleCertificate, sendCertificates, runCertificateJob,
};
+1
View File
@@ -423,6 +423,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
module.exports = {
sendMail,
isSmtpConfigured,
getOrg,
emailWrapper,
ctaButton,
fallbackLink,
+35 -15
View File
@@ -1,4 +1,4 @@
const { createEvent } = require('ics');
const { createEvent, createEvents } = require('ics');
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
// datetime-local input is parsed in the browser's local time before being sent as an
@@ -9,26 +9,46 @@ function toUtcArray(date) {
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
}
/**
* Build an RFC 5545 .ics file (as a string) for a single event.
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const { error, value } = createEvent({
title: event.title,
start: toUtcArray(event.startDate),
end: toUtcArray(event.endDate),
function icsEntry(event, eventUrl, { start, end, title, location }) {
return {
title,
start: toUtcArray(start),
end: toUtcArray(end),
startInputType: 'utc',
endInputType: 'utc',
startOutputType: 'utc',
endOutputType: 'utc',
location: event.location || undefined,
location: location || event.location || undefined,
description: event.description || undefined,
url: eventUrl,
});
};
}
/**
* Build an RFC 5545 .ics file (as a string) for an event. A multi-session event gets one
* calendar entry per session that isn't cancelled (not a repeat rule, so moved or skipped
* sessions are exact).
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?,
* scheduleType?, sessions?[]
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const sessions = event.scheduleType === 'sessions'
? (event.sessions || []).filter(s => !s.cancelledAt).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
: [];
if (sessions.length > 0) {
const { error, value } = createEvents(sessions.map((s, i) => icsEntry(event, eventUrl, {
start: s.startsAt,
end: s.endsAt,
title: `${event.title} — ${s.label || `Session ${i + 1}`}`,
location: s.location,
})));
if (error) throw error;
return value;
}
const { error, value } = createEvent(icsEntry(event, eventUrl, { start: event.startDate, end: event.endDate, title: event.title }));
if (error) throw error;
return value;
}
+5 -5
View File
@@ -955,12 +955,12 @@ function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0)
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* reusing (or, if none was supplied, creating) a checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails — callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* Resolves a checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
@@ -968,16 +968,16 @@ async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHi
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const { createRegistrationCheckout } = require('../payments/checkouts');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const checkout = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
console.warn('Could not create a checkout for the invoice PDF:', e?.message || e);
return null;
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* PayFast configuration (Admin → Site Settings → Payments → PayFast).
*
* Stored in AppSetting: payfast_merchant_id, payfast_merchant_key, payfast_passphrase
* (AES-256-GCM encrypted — it signs every payment) and payfast_mode ('sandbox' | 'live',
* default sandbox). PAYFAST_MERCHANT_ID / PAYFAST_MERCHANT_KEY / PAYFAST_PASSPHRASE /
* PAYFAST_MODE in .env are fallbacks when the database has no value. The merchant key isn't
* secret: it travels in the payment form the payer's browser posts to PayFast.
*/
const { getSetting } = require('./settingsCache');
const KEY_MERCHANT_ID = 'payfast_merchant_id';
const KEY_MERCHANT_KEY = 'payfast_merchant_key';
const KEY_PASSPHRASE = 'payfast_passphrase';
const KEY_MODE = 'payfast_mode';
const HOSTS = { live: 'www.payfast.co.za', sandbox: 'sandbox.payfast.co.za' };
async function getPayfastConfig() {
const [merchantId, merchantKey, passphrase, mode] = await Promise.all([
getSetting(KEY_MERCHANT_ID, process.env.PAYFAST_MERCHANT_ID),
getSetting(KEY_MERCHANT_KEY, process.env.PAYFAST_MERCHANT_KEY),
getSetting(KEY_PASSPHRASE, process.env.PAYFAST_PASSPHRASE),
getSetting(KEY_MODE, process.env.PAYFAST_MODE),
]);
return {
merchantId: String(merchantId || '').trim(),
merchantKey: String(merchantKey || '').trim(),
passphrase: String(passphrase || ''),
mode: mode === 'live' ? 'live' : 'sandbox',
};
}
/** PayFast's host for a mode: where payers are sent and where ITNs are confirmed. */
const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
function notifyUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Set BACKEND_URL on the server so PayFast knows where to send payment notifications.'; }
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'PayFast can\'t reach localhost, so payments won\'t be confirmed automatically. Use a public BACKEND_URL (or a tunnel) to test notifications.';
}
return null;
}
module.exports = {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastHost, payfastNotifyUrl, notifyUrlProblem,
};
+1 -1
View File
@@ -387,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber, tempDir };
+1 -1
View File
@@ -1,5 +1,5 @@
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
// Async because {{payment.link}} needs to create a live checkout session per recipient —
// every other placeholder is a plain synchronous string replace.
async function replacePlaceholders(str, ctx = {}) {
if (!str) return str;
+4 -2
View File
@@ -30,6 +30,7 @@
*/
const prisma = require('../config/db');
const { withLatePrice } = require('./sessions');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
@@ -91,8 +92,9 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
// Late joiners on a multi-day event pay a lower main-ticket price (utils/sessions.js).
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
return withLatePrice(option, withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user)));
}
async function resolveOptionTierPrice(option, requestedQty) {
@@ -147,7 +149,7 @@ async function resolveOptionTierPrice(option, requestedQty) {
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
return withLatePrice(option, withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user)));
}
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
+29 -5
View File
@@ -14,6 +14,8 @@
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
* scans are still recorded, and the report is flagged.
* - Multi-day events: the snapshot counts what's left for the session running when it was
* downloaded, and each synced scan counts for the session at its scan time.
*/
const prisma = require('../config/db');
@@ -53,6 +55,8 @@ function lockCovers(lock, { sectionId = null, optionId }) {
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
@@ -69,6 +73,8 @@ const CONFLICT_TEXT = {
already_used: 'Ticket had already been fully used',
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
wrong_session: 'Ticket is for a different session',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
@@ -123,7 +129,7 @@ async function snapshotFor(lock) {
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
},
include: {
usages: { select: { quantityRedeemed: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
attendee: { select: { name: true } },
user: { select: { name: true } },
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
@@ -133,10 +139,19 @@ async function snapshotFor(lock) {
const consent = await consentStatusForTickets(tickets.map(t => t.id));
const snapshotAt = new Date();
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
const sessionsUtil = require('./sessions');
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
const session = schedule?.scheduleType === 'sessions' ? sessionsUtil.currentSession(schedule.sessions, snapshotAt, schedule.checkInWindow) : null;
return {
snapshotAt,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
tickets: tickets.map(t => {
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
// A ticket for other sessions (picked, or ticked on an all-sessions ticket) has nothing left today.
const otherSession = !!(session ? !sessionsUtil.lineCovers(t.registrationOption, session.id) : t.registrationOption.sessionId);
const redeemed = schedule?.scheduleType === 'sessions'
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
qrCode: t.qrCode,
@@ -161,6 +176,9 @@ async function syncScans(lock, scans) {
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
const { consentStatusForTickets } = require('./consent');
const checkIns = [];
const sessionsUtil = require('./sessions');
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
const hasSessions = schedule?.scheduleType === 'sessions';
for (const scan of (scans || []).slice(0, 2000)) {
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
@@ -178,9 +196,15 @@ async function syncScans(lock, scans) {
const { findTransferByOldCode } = require('./ticketTransfer');
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
} else if (ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const session = hasSessions ? sessionsUtil.currentSession(schedule.sessions, scannedAt, schedule.checkInWindow) : null;
const redeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
sessionId: session?.id || null,
noSession: hasSessions && !session,
wrongSession: !!(session && !sessionsUtil.lineCovers(ticket.registrationOption, session.id)),
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
@@ -190,9 +214,9 @@ async function syncScans(lock, scans) {
const verdict = judgeSyncedScan(state, qty);
if (verdict.redeem > 0) {
await prisma.ticketUsage.create({
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date(), sessionId: state.sessionId },
});
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: !hasSessions && state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
results.accepted += 1;
if (ticket.registrationOption.eventOption.isMainTicket) {
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
+122
View File
@@ -0,0 +1,122 @@
/**
* Session reminders — optional per multi-day event (Event.sessionReminders), e.g. "3 h
* before" and "1 day before" each session, by email and/or WhatsApp.
*
* Rules:
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
* an account), skipping cancelled registrations. One message per person per session.
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
* already due — e.g. reminders were switched on 2 h before a session — only the
* closest one is sent and the others are logged as skipped, so nobody gets two at once.
* - Cancelled sessions get no reminders. Email/WhatsApp follow each person's
* notification preference.
*/
const prisma = require('../config/db');
const { activeSessions, sessionLabel, TIMEZONE } = require('./sessions');
const MAX_OFFSET_MIN = 1440;
function fmtWhen(d) {
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
}
/** People to remind about a session: whole-course tickets (all sessions, or this one ticked) plus picked places on it. Map userId → user. */
async function recipientsFor(eventId, sessionId) {
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null, sessionIds: { isEmpty: true } }, { sessionId: null, sessionIds: { has: sessionId } }, { sessionId }] } },
select: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
},
});
const out = new Map();
for (const t of tickets) {
for (const u of [t.user, t.holder]) if (u && !out.has(u.id)) out.set(u.id, u);
}
return out;
}
async function sendReminder(user, event, session, sessions) {
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
const { shouldEmail, waText, waTextAny } = require('./notify');
const org = getOrg();
const label = sessionLabel(session, sessions);
const when = fmtWhen(session.startsAt);
const where = session.location || event.location || '';
const first = (user.name || '').split(' ')[0] || 'there';
const url = `${org.url}/events/${event.id}`;
const jobs = [];
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p>Hi ${first},</p>
<p>A reminder that <strong>${event.title} — ${label}</strong> is on <strong>${when}</strong>${where ? ` at <strong>${where}</strong>` : ''}.</p>
<p>Bring your ticket QR code (it's the same one for every session).</p>
${ctaButton('View event', url)}`, { preheader: `${label} — ${when}` });
jobs.push(sendMail({ to: user.email, subject: `Reminder: ${event.title} — ${label}`, html, text: `Reminder: ${event.title} — ${label} is on ${when}${where ? ` at ${where}` : ''}. ${url}` }));
}
const wa = `⏰ *Reminder*\n\nHi ${first}, *${event.title} — ${label}* is on ${when}${where ? `\n📍 ${where}` : ''}.\n\nBring your ticket QR code — it's the same one for every session.\n${url}`;
jobs.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
await Promise.all(jobs);
}
/**
* Sends the reminders that are due. Returns { sessions, sent } counts.
*/
async function runSessionReminderJob(now = new Date()) {
// (Filtering a Json column on null is awkward in Prisma, so events without reminders
// are skipped below instead.)
const events = await prisma.event.findMany({
where: { scheduleType: 'sessions', isActive: true, endDate: { gte: now } },
include: {
sessions: {
where: { cancelledAt: null, startsAt: { gt: now, lte: new Date(now.getTime() + MAX_OFFSET_MIN * 60 * 1000) } },
include: { reminders: true },
},
},
});
let sessionsDone = 0;
let sent = 0;
for (const event of events) {
if (!event.sessionReminders || event.sessions.length === 0) continue;
const config = event.sessionReminders;
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
if (offsets.length === 0) continue;
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
for (const session of event.sessions) {
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
const due = offsets.filter(o => minutesLeft <= o && !loggedOffsets.has(o));
if (due.length === 0) continue;
// Claim every due offset first (unique per session+offset), so a second server
// process running the same job can't send them again.
const claimed = [];
for (const o of due) {
try {
await prisma.sessionReminderLog.create({ data: { sessionId: session.id, offset: o } });
claimed.push(o);
} catch (e) {
if (e?.code !== 'P2002') throw e;
}
}
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
const recipients = await recipientsFor(event.id, session.id);
let count = 0;
for (const user of recipients.values()) {
try {
await sendReminder(user, event, session, activeSessions(allSessions));
count += 1;
} catch (e) {
console.warn('[session reminders] Send failed:', e?.message || e);
}
}
await prisma.sessionReminderLog.update({ where: { sessionId_offset: { sessionId: session.id, offset: due[0] } }, data: { sent: count } });
sessionsDone += 1;
sent += count;
}
}
return { sessions: sessionsDone, sent };
}
module.exports = { runSessionReminderJob };
+578
View File
@@ -0,0 +1,578 @@
/**
* Multi-day events — one event running on several dates ("sessions"), e.g. every
* Wednesday for 5 weeks.
*
* Rules:
* - Event.startDate/endDate always mirror the first active session's start and the last
* active session's end, so everything that only reads those keeps working.
* - A cancelled session is kept (it may have check-ins) but is hidden from the schedule
* and left out of totals. A session with check-ins can't be deleted, only cancelled.
* - Check-in is once per session. "Whole day" accepts a scan any time on the session's
* date (in the church's timezone); "session only" from 2 h before the start until the end.
* - Registration closes when the first session starts, or — with late joining — when
* session N starts. A registration deadline, if set, still wins when it's earlier.
* - Late pricing applies to main tickets only, for people registering after the first
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
* bought per session — one registration line (and ticket) per session, priced per
* session, bookable until that session starts. `all` options cover every session and
* follow the whole-course rules above. A session's capacity counts picked places plus
* whole-course main tickets (and places held for waitlist offers); a pick option's stock
* limit is places per session. The waitlist for a pick option is per session.
* `all` options on such an event also let people tick the sessions they'll come to
* (RegistrationOption.sessionIds): one line, one ticket and one price, but the ticket
* scans, counts towards capacity and gets reminders for those sessions only. No
* sessions ticked = every session (the whole-course rules).
* Not available on youth (parental consent) events, and picked lines take no names.
*/
const prisma = require('../config/db');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const EARLY_CHECK_IN_MS = 2 * 60 * 60 * 1000;
const MAX_SESSIONS = 200;
const REMINDER_OFFSETS = [60, 180, 1440]; // minutes before start: 1 h, 3 h, 1 day
const CERTIFICATE_TEMPLATES = ['classic', 'modern', 'church', 'kids'];
function sessionError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** 'YYYY-MM-DD' of a moment in the church's timezone. */
function dayKey(date, timeZone = TIMEZONE) {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
}
/** Sessions that aren't cancelled, earliest first. */
function activeSessions(sessions) {
return (sessions || [])
.filter(s => !s.cancelledAt)
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
}
/** { startDate, endDate } covering the active sessions, or null when there are none. */
function scheduleBounds(sessions) {
const active = activeSessions(sessions);
if (active.length === 0) return null;
const endDate = active.reduce((max, s) => (new Date(s.endsAt) > max ? new Date(s.endsAt) : max), new Date(active[0].endsAt));
return { startDate: new Date(active[0].startsAt), endDate };
}
/**
* Checks and tidies the sessions sent by the event editor.
* Each: { id?, label?, startsAt, endsAt, location?, cancelled? }.
*/
function normalizeSessionsInput(list) {
if (!Array.isArray(list)) throw sessionError('Sessions must be a list');
if (list.length > MAX_SESSIONS) throw sessionError(`An event can have at most ${MAX_SESSIONS} sessions`);
const out = list.map((s, i) => {
const startsAt = new Date(s?.startsAt);
const endsAt = new Date(s?.endsAt);
if (isNaN(startsAt) || isNaN(endsAt)) throw sessionError(`Session ${i + 1} needs a start and end date`);
if (endsAt <= startsAt) throw sessionError(`Session ${i + 1} must end after it starts`);
return {
id: s.id ? String(s.id) : null,
label: s.label ? String(s.label).trim().slice(0, 120) || null : null,
startsAt,
endsAt,
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
cancelled: s.cancelled === true,
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
};
});
out.sort((a, b) => a.startsAt - b.startsAt);
if (!out.some(s => !s.cancelled)) throw sessionError('A multi-session event needs at least one session that isn\'t cancelled');
return out;
}
/** Whether a check-in for `session` is allowed at `now`. */
function sessionAcceptsCheckIn(session, now = new Date(), window = 'whole_day') {
if (!session || session.cancelledAt) return false;
const t = new Date(now);
if (window === 'session_only') {
return t >= new Date(new Date(session.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(session.endsAt);
}
const today = dayKey(t);
return today >= dayKey(session.startsAt) && today <= dayKey(session.endsAt);
}
/**
* The session a scan at `now` counts for, or null. On a day with more than one session:
* the one happening now (from 2 h before), else the next one today, else the last one.
*/
function currentSession(sessions, now = new Date(), window = 'whole_day') {
const t = new Date(now);
const open = activeSessions(sessions).filter(s => sessionAcceptsCheckIn(s, t, window));
if (open.length <= 1) return open[0] || null;
const running = open.find(s => t >= new Date(new Date(s.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(s.endsAt));
if (running) return running;
return open.find(s => new Date(s.startsAt) > t) || open[open.length - 1];
}
/** When public registration closes for an event (Date). */
/** When whole-course (all-sessions) tickets stop selling (Date). */
function wholeCourseClosesAt(event, sessions) {
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
let closes = new Date(event.endDate);
if (event.scheduleType === 'sessions') {
const active = activeSessions(sessions);
if (active.length > 0) {
const until = event.lateJoinEnabled ? active.find(s => s.id === event.lateJoinUntilSessionId) : null;
closes = new Date((until || active[0]).startsAt);
}
}
return deadline && deadline < closes ? deadline : closes;
}
/**
* When public registration closes (Date). "Pick sessions" events stay open until the
* last session starts (each session can be booked until it starts).
*/
function registrationClosesAt(event, sessions) {
const whole = wholeCourseClosesAt(event, sessions);
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
const active = activeSessions(sessions);
if (active.length === 0) return whole;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const last = new Date(active[active.length - 1].startsAt);
const closes = last > whole ? last : whole;
return deadline && deadline < closes ? deadline : closes;
}
/** Whether people choose sessions on this event (attendance mode "pick sessions"). */
function choosesSessions(event) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick';
}
/** Whether an option is bought per session (only in "pick sessions" events). */
function isPickOption(event, option) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
}
/**
* Turns requested lines into registration lines: a pick option with sessionIds becomes
* one line per session (same quantity); an all-sessions option on a pick-sessions event
* keeps its sessionIds (the sessions ticked, empty = every session) on one line. Checks
* the sessions can still be booked: public — before the session starts; staff — until it ends.
* @param {object} event - with eventOptions and sessions
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
*/
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
const out = [];
const bookable = (option, id) => {
const session = (event.sessions || []).find(s => s.id === id);
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
};
for (const line of lines || []) {
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
const pick = isPickOption(event, option);
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
const { sessionIds, ...rest } = line;
void sessionIds;
if (!pick) {
if (ids.length && !choosesSessions(event)) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
for (const id of ids) bookable(option, id);
out.push({ ...rest, sessionId: null, sessionIds: ids });
continue;
}
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
for (const id of ids) {
bookable(option, id);
out.push({ ...rest, sessionId: id });
}
}
return out;
}
/**
* The sessions a line attends: its picked session, the sessions ticked on an
* all-sessions ticket, or null for every session.
* @param {{ sessionId?: string|null, sessionIds?: string[] }} line
* @returns {string[]|null}
*/
function lineSessions(line) {
if (line?.sessionId) return [line.sessionId];
return line?.sessionIds?.length ? line.sessionIds : null;
}
/** Whether a ticket's line covers `sessionId` (picked session, ticked sessions, or every session). */
function lineCovers(line, sessionId) {
const ids = lineSessions(line);
return !ids || ids.includes(sessionId);
}
/** Merges the ticked sessions of two all-sessions lines (empty = every session wins). */
function mergeSessionIds(a, b) {
if (!a?.length || !b?.length) return [];
return [...new Set([...a, ...b])];
}
/**
* People booked per session: picked places on that session plus whole-course main
* tickets (which attend every session). Cancelled registrations don't count; places
* held for a live waitlist offer do (except `excludeEntryId`, the offer being claimed).
* @returns {Promise<Map<string, number>>}
*/
async function sessionBookings(eventId, db = prisma, { excludeEntryId = null } = {}) {
const [booked, held] = await Promise.all([
db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true, sessionIds: true },
}),
db.waitlistEntry.findMany({
where: {
eventId, status: 'offered', offerExpiresAt: { gt: new Date() }, eventOption: { isMainTicket: true },
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
},
select: { offeredQuantity: true, sessionId: true },
}),
]);
const lines = [...booked, ...held.map(h => ({ quantity: h.offeredQuantity, sessionId: h.sessionId }))];
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
const out = new Map(sessions.map(s => [s.id, 0]));
for (const l of lines) {
for (const id of lineSessions(l) || out.keys()) if (out.has(id)) out.set(id, out.get(id) + (l.quantity || 0));
}
return out;
}
/**
* Session places free for a main-ticket line: its own session (picked) or, for a
* whole-course ticket, the fullest session with a capacity. Pure.
* @param {object[]} sessions - the event's sessions (with capacity)
* @param {Map<string, number>} booked - from sessionBookings
* @param {string|null} sessionId - the picked session, or null for whole-course
* @returns {number|null} null when no session limits it
*/
function sessionPlacesFree(sessions, booked, sessionId = null) {
const limited = activeSessions(sessions).filter(s => s.capacity > 0 && (!sessionId || s.id === sessionId));
if (limited.length === 0) return null;
return Math.max(0, Math.min(...limited.map(s => s.capacity - (booked.get(s.id) || 0))));
}
/**
* Checks requested lines fit each session's capacity. Whole-course main tickets count
* against every session; picked main tickets against their own.
* @param {{ excludeEntryId?: string }} [opts] - the waitlist offer being claimed
*/
async function assertSessionCapacity(event, lines, db = prisma, { excludeEntryId = null } = {}) {
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
if (limited.length === 0) return;
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
const wanted = new Map();
for (const s of limited) wanted.set(s.id, 0);
for (const l of lines) {
if (!mains.has(l.eventOptionId)) continue;
for (const id of lineSessions(l) || wanted.keys()) if (wanted.has(id)) wanted.set(id, wanted.get(id) + (Number(l.quantity) || 0));
}
if ([...wanted.values()].every(n => n === 0)) return;
const booked = await sessionBookings(event.id, db, { excludeEntryId });
for (const s of limited) {
const left = s.capacity - (booked.get(s.id) || 0);
if ((wanted.get(s.id) || 0) > left) {
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
}
}
}
/**
* Late-joiner pricing state at `now`: active once the first session has started (for
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
*/
function lateInfo(event, sessions, now = new Date()) {
const active = activeSessions(sessions);
const base = { active: false, mode: event.latePricing || 'full', sessionsLeft: active.length, totalSessions: active.length, fixedPrice: event.lateFixedPrice ?? null };
if (event.scheduleType !== 'sessions' || !event.lateJoinEnabled || active.length === 0) return base;
const t = new Date(now);
const sessionsLeft = active.filter(s => new Date(s.startsAt) > t).length;
const started = t >= new Date(active[0].startsAt);
const priced = base.mode === 'prorata' || (base.mode === 'fixed' && base.fixedPrice !== null);
return { ...base, sessionsLeft, active: started && priced };
}
/** A main ticket's price for a late joiner. Never more than the normal price. */
function latePrice(price, info) {
if (!info || !info.active || !(price > 0)) return price;
if (info.mode === 'fixed') return Math.min(price, Math.max(0, Number(info.fixedPrice)));
if (info.mode === 'prorata') {
if (!info.totalSessions) return price;
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
return Math.min(price, Math.max(10, rounded));
}
return price;
}
/** Whether `attended` of `total` sessions meets a certificate completion rule. */
function completionMet(rule, attended, total) {
if (total <= 0 || attended <= 0) return false;
const type = rule?.type || 'all';
const value = Number(rule?.value) || 0;
if (type === 'count') return attended >= Math.min(Math.max(1, value), total);
if (type === 'percent') return attended / total * 100 >= Math.min(Math.max(1, value), 100) - 1e-9;
return attended >= total;
}
/** Cleans the reminder settings from the editor; null = off. */
function normalizeReminders(input) {
if (!input || input.enabled === false) return null;
const offsets = [...new Set((input.offsets || []).map(Number).filter(n => REMINDER_OFFSETS.includes(n)))].sort((a, b) => b - a);
const channels = [...new Set((input.channels || []).filter(c => ['email', 'whatsapp'].includes(c)))];
if (offsets.length === 0 || channels.length === 0) return null;
return { offsets, channels };
}
/** Cleans the certificate settings from the editor. */
function normalizeCertificateConfig(input) {
const c = input && typeof input === 'object' ? input : {};
const ruleType = ['all', 'count', 'percent'].includes(c.rule?.type) ? c.rule.type : 'all';
const str = (v, max) => (v ? String(v).trim().slice(0, max) : '');
return {
template: CERTIFICATE_TEMPLATES.includes(c.template) ? c.template : 'classic',
title: str(c.title, 120) || 'Certificate of Completion',
body: str(c.body, 1000) || 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
signatureName: str(c.signatureName, 120),
signatureRole: str(c.signatureRole, 120),
signatureImage: str(c.signatureImage, 500),
rule: { type: ruleType, value: ruleType === 'all' ? null : Math.max(1, parseInt(c.rule?.value, 10) || 1) },
sendOnIssue: c.sendOnIssue === true,
};
}
// ─── Database helpers ─────────────────────────────────────────────────────────
/**
* Saves the editor's session list for an event: updates rows it kept, creates new ones,
* deletes rows it dropped (refused when they have check-ins — cancel those instead), and
* moves Event.startDate/endDate to match. Run inside a transaction (`tx`).
* @returns {Promise<{ sessions, changes: { added, moved, cancelled, restored, removed } }>}
*/
async function syncEventSessions(tx, eventId, input) {
const wanted = normalizeSessionsInput(input);
const existing = await tx.eventSession.findMany({ where: { eventId }, include: { _count: { select: { usages: true } } } });
const byId = new Map(existing.map(s => [s.id, s]));
const changes = { added: 0, moved: 0, cancelled: 0, restored: 0, removed: 0 };
const keep = new Set(wanted.filter(w => w.id && byId.has(w.id)).map(w => w.id));
for (const s of existing) {
if (keep.has(s.id)) continue;
if (s._count.usages > 0) {
throw sessionError(`"${s.label || dayKey(s.startsAt)}" already has check-ins, so it can't be removed. Cancel it instead.`);
}
await tx.eventSession.delete({ where: { id: s.id } });
changes.removed += 1;
}
for (let i = 0; i < wanted.length; i++) {
const w = wanted[i];
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
const old = w.id ? byId.get(w.id) : null;
if (old) {
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
if (+old.startsAt !== +w.startsAt || +old.endsAt !== +w.endsAt) changes.moved += 1;
if (!old.cancelledAt && cancelledAt) changes.cancelled += 1;
if (old.cancelledAt && !cancelledAt) changes.restored += 1;
await tx.eventSession.update({ where: { id: old.id }, data: { ...data, cancelledAt } });
} else {
await tx.eventSession.create({ data: { ...data, eventId, cancelledAt: w.cancelled ? new Date() : null } });
changes.added += 1;
}
}
const sessions = await tx.eventSession.findMany({ where: { eventId }, orderBy: { startsAt: 'asc' } });
const bounds = scheduleBounds(sessions);
await tx.event.update({ where: { id: eventId }, data: { startDate: bounds.startDate, endDate: bounds.endDate } });
return { sessions, changes };
}
const SCHEDULE_SELECT = {
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
sessions: { orderBy: { startsAt: 'asc' } },
};
// Late pricing is looked up while pricing every option line, so keep each event's
// schedule for a few seconds rather than querying it per line.
const scheduleCache = new Map();
const SCHEDULE_TTL_MS = 5000;
async function eventSchedule(eventId) {
const hit = scheduleCache.get(eventId);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await prisma.event.findUnique({ where: { id: eventId }, select: SCHEDULE_SELECT });
scheduleCache.set(eventId, { value, expires: Date.now() + SCHEDULE_TTL_MS });
if (scheduleCache.size > 500) scheduleCache.delete(scheduleCache.keys().next().value);
return value;
}
function forgetSchedule(eventId) {
scheduleCache.delete(eventId);
}
/** Label for a session in messages: its own label, else "Session N" by position. */
function sessionLabel(session, sessions) {
if (session.label) return session.label;
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
return `Session ${idx >= 0 ? idx + 1 : ''}`.trim();
}
/**
* Which session a scan on a multi-session event counts for. `requestedId` is the session
* the scanner picked (optional — defaults to today's). Returns { session, error } with
* session null and an error message when check-in isn't possible now.
*/
function scanSession(event, sessions, requestedId = null, now = new Date()) {
const window = event.checkInWindow || 'whole_day';
if (requestedId) {
const session = (sessions || []).find(s => s.id === requestedId);
if (!session) return { session: null, error: 'That session isn\'t part of this event' };
if (session.cancelledAt) return { session: null, error: `${sessionLabel(session, sessions)} was cancelled` };
if (!sessionAcceptsCheckIn(session, now, window)) {
return { session: null, error: `Check-in for ${sessionLabel(session, sessions)} is only open ${window === 'session_only' ? 'from 2 hours before it starts until it ends' : 'on the day of the session'}` };
}
return { session, error: null };
}
const session = currentSession(sessions, now, window);
if (!session) {
const next = activeSessions(sessions).find(s => new Date(s.startsAt) > new Date(now));
return { session: null, error: next ? `No session today — the next one is ${sessionLabel(next, sessions)} on ${dayKey(next.startsAt)}` : 'All sessions of this event are over' };
}
return { session, error: null };
}
/**
* How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec"
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
* Returns null for single-date events (callers keep their own date format).
*/
function ticketDateText(event, style = 'short', session = null, sessions = null, chosenIds = null) {
if (event?.scheduleType !== 'sessions') return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
// An all-sessions ticket with sessions ticked: those sessions.
const chosen = chosenIds?.length ? activeSessions(event.sessions).filter(s => chosenIds.includes(s.id)) : [];
if (!session && chosen.length === 1) session = chosen[0];
else if (!session && chosen.length > 1) {
return style === 'long'
? `${chosen.map(s => fmt(s.startsAt, { weekday: 'short', day: 'numeric', month: 'short' })).join(', ')} (${chosen.length} sessions — one ticket for all)`
: `${chosen.length} sessions · ${chosen.map(s => fmt(s.startsAt, { day: '2-digit', month: 'short' })).join(', ')}`;
}
// A picked-session ticket is for its own session only.
if (session) {
const label = sessionLabel(session, event.sessions);
return style === 'long'
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
}
if (sessions && sessions.length > 1 && style === 'long') {
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
}
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
if (style === 'long') {
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
}
const short = { day: '2-digit', month: 'short' };
return active.length === 1
? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' })
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
}
/**
* scanSession for a ticket: a picked-session ticket only checks in on its own session
* (whatever the scanner has selected); an all-sessions ticket with sessions ticked
* (`chosenIds`) only on those.
*/
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date(), chosenIds = null) {
if (!pinnedSessionId && chosenIds?.length) {
const found = scanSession(event, sessions, requestedId, now);
if (!found.session || chosenIds.includes(found.session.id)) return found;
// Another of their sessions may be open today (a day with more than one session).
const own = requestedId ? null : currentSession(activeSessions(sessions).filter(s => chosenIds.includes(s.id)), now, event.checkInWindow || 'whole_day');
if (own) return { session: own, error: null };
return { session: null, error: `This ticket isn't booked for ${sessionLabel(found.session, sessions)} (${dayKey(found.session.startsAt)})` };
}
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
const label = sessionLabel(pinned, sessions);
if (requestedId && requestedId !== pinnedSessionId) {
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
}
const found = scanSession(event, sessions, pinnedSessionId, now);
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
}
/**
* Groups an event's tickets into people for the register and certificates: a
* picked-session booking has one ticket per session, so its tickets (same registration,
* option and variant) form one group. Every other ticket is its own group.
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId, sessionIds }.
* An all-sessions ticket with sessions ticked is its own group, limited to those sessions.
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
*/
function groupTickets(tickets) {
const groups = new Map();
for (const t of tickets) {
const ro = t.registrationOption || {};
if (!ro.sessionId) {
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: ro.sessionIds?.length ? [...ro.sessionIds] : null });
continue;
}
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
g.tickets.push(t);
g.sessionIds.push(ro.sessionId);
groups.set(key, g);
}
return [...groups.values()];
}
/** Places already checked in for one session, from a ticket's usages. */
function redeemedForSession(usages, sessionId) {
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
}
/** Applies late pricing to a resolved option price ({ price, tierId }). */
async function withLatePrice(option, resolved) {
// Picked sessions are priced per session, so late joining doesn't change them.
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
const event = await eventSchedule(option.eventId);
if (!event || event.scheduleType !== 'sessions') return resolved;
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
return price === resolved.price ? resolved : { ...resolved, price };
}
/**
* Check-ins per ticket per session for an event: Map ticketId → Map sessionId → qty.
* Cancelled sessions are included; callers drop them when counting.
*/
async function attendanceBySession(eventId) {
const usages = await prisma.ticketUsage.findMany({
where: { ticket: { eventId }, sessionId: { not: null } },
select: { ticketId: true, sessionId: true, quantityRedeemed: true },
});
const out = new Map();
for (const u of usages) {
if (!out.has(u.ticketId)) out.set(u.ticketId, new Map());
const m = out.get(u.ticketId);
m.set(u.sessionId, (m.get(u.sessionId) || 0) + (u.quantityRedeemed || 1));
}
return out;
}
module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, choosesSessions, isPickOption, expandPickLines, lineSessions, lineCovers, mergeSessionIds, sessionBookings, sessionPlacesFree, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+1 -1
View File
@@ -11,7 +11,7 @@ const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret', 'payfast_passphrase']);
let _cache = {};
let _lastFetch = 0;
+11 -7
View File
@@ -3,18 +3,20 @@
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
* option the limit is per session (pass sessionId).
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
* variantId given → that variant only; sessionId given → that session's picked places only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
@@ -35,15 +37,17 @@ async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId
/**
* Places still free on a line: the tighter of the option and variant limits.
* @param {{ sessionId?: string|null, excludeEntryId?: string, db?: object }} [opts] -
* sessionId: a "pick sessions" line (the limits are places per session)
* @returns {Promise<number|null>} null when unlimited
*/
async function remainingFor(option, variant, opts = {}) {
async function remainingFor(option, variant, { sessionId = null, ...opts } = {}) {
const limits = [];
if (option?.stockLimit > 0) {
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, opts));
}
if (variant?.stockLimit > 0) {
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id, sessionId }, opts));
}
if (limits.length === 0) return null;
return Math.max(0, Math.min(...limits));
+2 -2
View File
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
}
} catch (_) { /* form models unavailable — don't block */ }
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
const byOption = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
if (!byOption.has(key)) byOption.set(key, []);
byOption.get(key).push(ro);
}
+63 -14
View File
@@ -11,11 +11,16 @@
* offered what's free; otherwise they're skipped for the next person who fits.
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
* - "Pick sessions" options are waited for per session (entry.sessionId): free places are
* that session's, an offer never runs past the session's start, and nobody is offered a
* session that has started or was cancelled. Main tickets also need room in the
* session(s) they cover when sessions have a capacity (utils/sessions.js).
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { remainingFor } = require('./stock');
const { activeSessions, isPickOption, sessionBookings, sessionPlacesFree, sessionLabel, wholeCourseClosesAt } = require('./sessions');
const ACTIVE = ['waiting', 'offered'];
@@ -27,6 +32,42 @@ function waitlistError(message, statusCode = 400) {
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
/**
* Places free on a waitlist line: option/variant stock (per session for a picked line)
* and, for main tickets on events with session capacities, room in the session(s).
* @param {object} event - with sessions
* @returns {Promise<number|null>} null when unlimited
*/
async function placesFree(event, option, variant, sessionId = null, { db = prisma, excludeEntryId = null } = {}) {
const limits = [await remainingFor(option, variant, { sessionId, db, excludeEntryId })];
if (event.scheduleType === 'sessions' && option.isMainTicket && (event.sessions || []).some(s => s.capacity > 0)) {
limits.push(sessionPlacesFree(event.sessions, await sessionBookings(event.id, db, { excludeEntryId }), sessionId));
}
const known = limits.filter(n => n !== null);
return known.length ? Math.min(...known) : null;
}
/** Whether a waitlist line can still be offered at `now` (its session hasn't started, etc.). */
function lineOpen(event, sessionId, now = new Date()) {
if (!sessionId) return event.scheduleType !== 'sessions' || now < wholeCourseClosesAt(event, event.sessions);
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
return !!session && new Date(session.startsAt) > now;
}
/** When an offer made now lapses: the event's offer hours, but never after its session starts. */
function offerExpiry(hours, session = null, now = new Date()) {
const expires = new Date(now.getTime() + (hours || 24) * 60 * 60 * 1000);
return session && new Date(session.startsAt) < expires ? new Date(session.startsAt) : expires;
}
/** "Drop-in (Large)" or "Drop-in — Class 2, Wed 4 Nov" for messages. */
function lineName(entry, sessions = entry.event?.sessions) {
const base = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
if (!entry.session) return base;
const when = new Date(entry.session.startsAt).toLocaleDateString('en-GB', { timeZone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', weekday: 'short', day: 'numeric', month: 'short' });
return `${base} — ${sessionLabel(entry.session, sessions || [entry.session])}, ${when}`;
}
function claimLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/waitlist/claim/${token}`;
@@ -63,7 +104,7 @@ async function notifyOffer(entry, token) {
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const user = entry.user;
const link = claimLink(token);
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
const item = lineName(entry);
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const partialNote = entry.offeredQuantity < entry.quantity
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
@@ -86,10 +127,10 @@ async function notifyOffer(entry, token) {
await Promise.allSettled(sends);
}
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
const OFFER_INCLUDE = { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, hours) {
/** Puts an entry on offer until `expiresAt` (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, expiresAt) {
const token = crypto.randomBytes(24).toString('hex');
await db.waitlistEntry.update({
where: { id: entry.id },
@@ -98,7 +139,7 @@ async function writeOffer(db, entry, quantity, hours) {
offeredQuantity: quantity,
offerTokenHash: hashToken(token),
offeredAt: new Date(),
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
offerExpiresAt: expiresAt,
},
});
return token;
@@ -113,8 +154,8 @@ async function notifyOffers(offers) {
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
async function makeOffer(entryId, quantity) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true, session: true } });
const token = await writeOffer(prisma, entry, quantity, offerExpiry(entry.event.waitlistOfferHours, entry.session));
await notifyOffers([{ entryId, token }]);
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
}
@@ -127,7 +168,7 @@ async function makeOffer(entryId, quantity) {
async function processWaitlist(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { variants: true } } },
include: { eventOptions: { include: { variants: true } }, sessions: true },
});
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
if (new Date(event.endDate) < new Date()) return 0;
@@ -136,16 +177,20 @@ async function processWaitlist(eventId) {
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
const lineKey = (w) => `${w.eventOptionId}::${w.variantId || ''}::${w.sessionId || ''}`;
const lines = [...new Map(waiting.map(w => [lineKey(w), w])).values()];
for (const sample of lines) {
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
if (!option) continue;
const free = await remainingFor(option, variant, { db: tx });
if (!option || !lineOpen(event, sample.sessionId)) continue;
// An option switched between per-session and whole-course after people joined.
if (!!sample.sessionId !== isPickOption(event, option)) continue;
const free = await placesFree(event, option, variant, sample.sessionId, { db: tx });
if (free === null || free <= 0) continue;
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
const session = sample.sessionId ? event.sessions.find(s => s.id === sample.sessionId) : null;
const queue = waiting.filter(w => lineKey(w) === lineKey(sample));
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, offerExpiry(event.waitlistOfferHours, session));
made.push({ entryId: offer.entryId, token });
}
}
@@ -183,7 +228,7 @@ async function findOfferByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.waitlistEntry.findUnique({
where: { offerTokenHash: hashToken(token) },
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
include: { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true } } },
});
}
@@ -192,6 +237,10 @@ module.exports = {
waitlistError,
hashToken,
pickOffers,
placesFree,
lineOpen,
offerExpiry,
lineName,
makeOffer,
processWaitlist,
expireOffers,
+6 -3
View File
@@ -80,15 +80,18 @@ function friendlyYocoError(e) {
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
/**
* Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it);
* `headers` adds request headers (e.g. Idempotency-Key).
*/
async function yocoRequest(method, path, body, key, headers = {}) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
headers: { 'Content-Type': 'application/json', Authorization: auth, ...headers },
timeout: 15000,
});
return res.data;
+54
View File
@@ -0,0 +1,54 @@
const { JOBS, isEnabled, cronFor, nextRun, isNoop } = require('../src/jobs');
const job = (name) => JOBS.find(j => j.name === name);
describe('job registry', () => {
const saved = { ...process.env };
afterEach(() => { process.env = { ...saved }; });
test('every job has a valid cron schedule and a unique name', () => {
const names = new Set();
for (const j of JOBS) {
expect(names.has(j.name)).toBe(false);
names.add(j.name);
expect(() => nextRun(j.cron)).not.toThrow();
}
});
test('a job is on unless its own flag or JOBS_ENABLED is false', () => {
delete process.env.WAITLIST_JOB_ENABLED;
delete process.env.JOBS_ENABLED;
expect(isEnabled(job('waitlist'))).toBe(true);
process.env.WAITLIST_JOB_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
process.env.WAITLIST_JOB_ENABLED = 'true';
process.env.JOBS_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
expect(isEnabled(job('temp-cleanup'))).toBe(false);
});
test('JOB_SCHEDULE_<NAME> overrides the schedule; an invalid one is ignored', () => {
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = '30 5 * * *';
expect(cronFor(job('analytics-rollup'))).toBe('30 5 * * *');
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = 'every tuesday';
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(cronFor(job('analytics-rollup'))).toBe(job('analytics-rollup').cron);
warn.mockRestore();
});
test('daily jobs fire at local (Johannesburg) time', () => {
// 07:00 SAST = 05:00 UTC
const next = nextRun('0 7 * * *', new Date('2026-11-04T06:00:00Z'));
expect(next.toISOString()).toBe('2026-11-05T05:00:00.000Z');
expect(nextRun('0 7 * * *', new Date('2026-11-04T04:00:00Z')).toISOString()).toBe('2026-11-04T05:00:00.000Z');
});
test('isNoop: quiet when nothing happened', () => {
expect(isNoop({ expired: 0, offers: 0 })).toBe(true);
expect(isNoop({ reminders: { sessions: 0, sent: 0 }, certificates: { issued: 0, sent: 0 } })).toBe(true);
expect(isNoop({ deleted: 0, errors: 0 })).toBe(true);
expect(isNoop(null)).toBe(true);
expect(isNoop({ sent: 3 })).toBe(false);
expect(isNoop({ file: 'backup.sql.gz' })).toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const crypto = require('crypto');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payfast = require('../src/payments/providers/payfast');
const status = require('../src/payments/status');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
// As PayFast's sandbox returned it (CRLF lines, quoted amounts with thousands separators)
const CSV = [
'Date,Type,Sign,Party,Name,Description,Currency,"Funding Type",Gross,Fee,Net,Balance,"M Payment ID","PF Payment ID","custom str1"',
'"2026-10-23 14:19:24",FUNDS_RECEIVED,CREDIT,"Test User 01","Family Fun Day",Adult,ZAR,WALLET,80.00,-2.30,77.70,"22,574.02",chk_a,3471967,',
'"2026-10-23 15:00:00",FUNDS_RECEIVED,CREDIT,"Test User 01","Big ""Camp""",,ZAR,CC,"1,250.00",-20.00,"1,230.00",,chk_b,3471999,',
'"2026-10-23 16:00:00",REFUND,DEBIT,"Test User 01",x,,ZAR,CC,-80.00,0,-80.00,,chk_c,3472000,',
].join('\r\n');
beforeEach(() => {
jest.clearAllMocks();
settings(PF);
});
describe('PayFast API', () => {
test('requests are signed over the alphabetically sorted variables and passphrase', () => {
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
expect(payfast.apiSignature({ version: 'v1', 'merchant-id': '10000100', timestamp: '2026-10-23T10:00:00+00:00', passphrase: 'Salt pass', from: '' }))
.toBe(md5('merchant-id=10000100&passphrase=Salt+pass&timestamp=2026-10-23T10%3A00%3A00%2B00%3A00&version=v1'));
});
test('reads PayFast CSV, quoted commas and quotes included', () => {
const rows = payfast.parseCsv(CSV);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ Type: 'FUNDS_RECEIVED', Gross: '80.00', Balance: '22,574.02', 'M Payment ID': 'chk_a', 'PF Payment ID': '3471967' });
expect(rows[1]).toMatchObject({ Name: 'Big "Camp"', Gross: '1,250.00' });
});
test('findPayments matches history rows on our checkout id (money received only)', async () => {
const get = jest.fn().mockResolvedValue({ data: CSV });
const list = ['chk_a', 'chk_b', 'chk_c'].map(id => ({ id, createdAt: new Date() }));
const found = await payfast.findPayments(list, { get });
expect([...found.keys()]).toEqual(['chk_a', 'chk_b']);
expect(found.get('chk_a')).toMatchObject({ externalId: '3471967', amountCents: 8000, checkoutId: 'chk_a', status: 'succeeded' });
expect(found.get('chk_a').createdDate.toISOString()).toBe('2026-10-23T12:19:24.000Z');
expect(found.get('chk_b').amountCents).toBe(125000);
const [url, opts] = get.mock.calls[0];
expect(url).toBe('https://api.payfast.co.za/transactions/history');
expect(opts.params).toMatchObject({ testing: 'true', limit: 1000, offset: 0 });
expect(opts.headers).toMatchObject({ 'merchant-id': '10000100', version: 'v1', signature: expect.any(String) });
});
test('live mode leaves out testing=true', async () => {
settings({ ...PF, payfast_mode: 'live' });
const get = jest.fn().mockResolvedValue({ data: CSV });
await payfast.findPayments([{ id: 'chk_a', createdAt: new Date() }], { get });
expect(get.mock.calls[0][1].params.testing).toBeUndefined();
});
test('a refused passphrase reads clearly', async () => {
const get = jest.fn().mockRejectedValue({ response: { status: 401, data: '{"data":{"response":"Merchant authorization failed."}}' } });
await expect(payfast.testConnection({ get })).rejects.toThrow(/refused the merchant ID or passphrase/);
});
});
describe('checkCheckouts', () => {
const checkout = (id, provider, cents) => ({ id, provider, externalId: null, amountCents: cents, metadata: { registrationId: 'r9', userId: 'u9' }, createdAt: new Date() });
let spy;
beforeEach(() => {
prisma.providerTransaction.findFirst.mockResolvedValue(null);
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', status: 'pending', registrationOptions: [], payments: [{ amount: 80 }] });
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Fun Day' }, user: { id: 'u9', name: 'T' } });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', ...data }));
computeRegistrationTotalDue.mockReturnValue(80);
prisma.payment.findFirst.mockResolvedValue(null);
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.ticket.findMany.mockResolvedValue([]);
spy = jest.spyOn(payfast, 'findPayments');
});
afterEach(() => spy.mockRestore());
test('a payment PayFast received is recorded as its ITN would have been', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '3471967', amountCents: 8000, currency: 'ZAR', createdDate: new Date(), checkoutId: 'chk_a', methodType: null, metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000)]);
expect(out.get('chk_a')).toEqual({ status: 'recorded' });
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 80, provider: 'payfast', externalId: '3471967', registrationId: 'r9', status: 'completed' });
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_a'] } } }));
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '3471967', reconciled: true }) });
});
test('a different amount is held for review; nothing found is not paid', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '1', amountCents: 2000, checkoutId: 'chk_a', metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000), checkout('chk_n', 'payfast', 5000)]);
expect(out.get('chk_a')).toMatchObject({ status: 'needs_review', note: expect.stringMatching(/Paid R20.00/) });
expect(out.get('chk_n')).toEqual({ status: 'not_paid' });
expect(prisma.payment.create).not.toHaveBeenCalled();
});
test('already paid checkouts are skipped; Yoco can\'t be asked', async () => {
prisma.providerTransaction.findFirst.mockImplementation(({ where }) => Promise.resolve(where.checkoutId.in.includes('chk_paid') && where.paymentId ? { id: 'x' } : null));
spy.mockResolvedValue(new Map());
const out = await status.checkCheckouts([checkout('chk_paid', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
expect(out.get('chk_paid')).toEqual({ status: 'paid' });
expect(out.get('chk_y')).toEqual({ status: 'cant_check' });
expect(spy).not.toHaveBeenCalled();
});
test('the job checks checkouts between 3 days and 10 minutes old, with providers that can be asked', async () => {
const now = new Date('2026-10-23T12:00:00Z');
prisma.providerCheckout.findMany.mockResolvedValue([checkout('chk_a', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
spy.mockResolvedValue(new Map());
await expect(status.runPaymentStatusJob(now)).resolves.toEqual({ checked: 1, recorded: 0, needsReview: 0 });
expect(prisma.providerCheckout.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { provider: { not: null }, createdAt: { gte: new Date('2026-10-20T12:00:00Z'), lte: new Date('2026-10-23T11:50:00Z') } },
}));
expect(spy).toHaveBeenCalledWith([expect.objectContaining({ id: 'chk_a' })]);
});
});
+375
View File
@@ -0,0 +1,375 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); fn.get = jest.fn(); return fn; });
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const axios = require('axios');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payments = require('../src/payments');
const yoco = require('../src/payments/providers/yoco');
const payfast = require('../src/payments/providers/payfast');
const { handleProviderWebhook } = require('../src/payments/webhook');
const SECRET = 'whsec_' + Buffer.from('test-webhook-secret').toString('base64');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
const ENV = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ENV };
delete process.env.YOCO_SECRET_KEY;
delete process.env.YOCO_WEBHOOK_SECRET;
for (const k of ['PAYFAST_MERCHANT_ID', 'PAYFAST_MERCHANT_KEY', 'PAYFAST_PASSPHRASE', 'PAYFAST_MODE', 'PAYFAST_SKIP_IP_CHECK']) delete process.env[k];
process.env.FRONTEND_URL = 'https://events.example.org';
process.env.BACKEND_URL = 'https://api.example.org';
prisma.providerCheckout.create.mockImplementation(({ data }) => Promise.resolve({ id: 'chk_1', provider: null, externalId: null, redirectUrl: null, ...data }));
prisma.providerCheckout.update.mockResolvedValue({});
prisma.providerCheckout.delete.mockResolvedValue({});
});
describe('provider settings', () => {
test('Yoco is on by default (it was the only provider)', () => {
expect(payments.parseProviderSettings('', ['yoco', 'payfast'])).toEqual([
{ id: 'yoco', enabled: true }, { id: 'payfast', enabled: false },
]);
});
test('stored order and flags win; unknown ids are dropped, new ones come last', () => {
const raw = JSON.stringify({ order: ['payfast', 'gone', 'yoco'], enabled: { yoco: false, payfast: true } });
expect(payments.parseProviderSettings(raw, ['yoco', 'payfast', 'snapscan'])).toEqual([
{ id: 'payfast', enabled: true }, { id: 'yoco', enabled: false }, { id: 'snapscan', enabled: false },
]);
});
test('bad JSON falls back to the defaults', () => {
expect(payments.parseProviderSettings('{nope', ['yoco'])).toEqual([{ id: 'yoco', enabled: true }]);
});
test('serialising keeps the order and drops unknown providers', () => {
expect(JSON.parse(payments.serializeProviderSettings([{ id: 'yoco', enabled: false }, { id: 'zzz', enabled: true }])))
.toEqual({ order: ['yoco'], enabled: { yoco: false } });
});
});
describe('createCheckout', () => {
test('online payments must be at least R15', async () => {
await expect(payments.createCheckout(null, { amountCents: 1499 })).rejects.toThrow(/at least R15/);
expect(prisma.providerCheckout.create).not.toHaveBeenCalled();
});
test('nothing enabled: online payments are off', async () => {
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/turned off/) });
});
test('asking for a provider that is switched off is refused', async () => {
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
await expect(payments.createCheckout('yoco', { amountCents: 5000 })).rejects.toThrow(/Yoco is turned off/);
});
test('enabled but no key: the provider explains, and the checkout record is removed', async () => {
settings({});
axios.mockRejectedValue(new Error('should not be called'));
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toThrow(/Yoco secret key/);
expect(prisma.providerCheckout.delete).toHaveBeenCalledWith({ where: { id: 'chk_1' } });
});
test('one provider available: starts it straight away and records the checkout', async () => {
settings({ yoco_secret_key: 'sk_test_abc' });
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ externalId: 'ch_1', redirectUrl: 'https://pay/1' });
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 }, description: 'Camp' });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'yoco', redirectUrl: 'https://pay/1' });
expect(prisma.providerCheckout.create).toHaveBeenCalledWith({ data: { amountCents: 5000, currency: 'ZAR', metadata: { a: 1 }, request: { description: 'Camp' } } });
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ checkoutId: 'chk_1', amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'yoco', externalId: 'ch_1', redirectUrl: 'https://pay/1' } });
spy.mockRestore();
});
test('several available: the payer chooses on /pay/<id>', async () => {
settings({ yoco_secret_key: 'sk_test_abc', ...PF, payment_providers: JSON.stringify({ order: ['payfast', 'yoco'], enabled: { yoco: true, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: null, redirectUrl: 'https://events.example.org/pay/chk_1' });
});
test('a form provider (PayFast) sends the payer to /pay/<id>, which posts the form', async () => {
settings({ ...PF, payment_providers: JSON.stringify({ order: ['payfast'], enabled: { yoco: false, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'payfast', redirectUrl: 'https://events.example.org/pay/chk_1' });
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'payfast' } });
});
});
describe('Yoco provider', () => {
const now = 1_800_000_000_000;
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_1', amount: 12000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_1', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r1' } } });
const signed = (overrides = {}) => {
const ts = String(Math.floor(now / 1000));
return { headers: { 'webhook-id': 'msg_1', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_1', ts, body)}`, ...overrides }, rawBody: body };
};
test('line items map to Yoco cents, deductions stay negative', () => {
expect(yoco.toYocoLineItems([{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }])).toEqual([
{ displayName: 'Ticket', quantity: 2, pricingDetails: { price: 5000 } },
{ displayName: 'Coupon', quantity: 1, pricingDetails: { price: -1000 } },
]);
});
test('a correctly signed webhook verifies', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: true, webhookId: 'msg_1' });
});
test('any of several signatures may match (key rotation)', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
const req = signed();
req.headers['webhook-signature'] = `v1,bm9wZQ== ${req.headers['webhook-signature']}`;
await expect(yoco.verifyWebhook(req, now)).resolves.toMatchObject({ ok: true });
});
test('bad signature, old timestamp, missing headers, no secret', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
await expect(yoco.verifyWebhook(signed({ 'webhook-signature': 'v1,bm9wZQ==' }), now)).resolves.toMatchObject({ ok: false, status: 403 });
await expect(yoco.verifyWebhook(signed(), now + 4 * 60 * 1000)).resolves.toMatchObject({ ok: false, status: 400, message: expect.stringMatching(/timestamp/) });
await expect(yoco.verifyWebhook({ headers: {}, rawBody: body }, now)).resolves.toMatchObject({ ok: false, status: 400, details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'] } });
delete process.env.YOCO_WEBHOOK_SECRET;
settings({});
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: false, status: 500 });
});
test('parses a payment.succeeded body', () => {
expect(yoco.parseWebhook(JSON.parse(body))).toEqual({
type: 'payment.succeeded',
transaction: {
externalId: 'p_1', amountCents: 12000, currency: 'ZAR', createdDate: null, checkoutId: 'ch_1',
methodType: 'card', metadata: { registrationId: 'r1' }, status: 'succeeded',
},
});
});
});
describe('PayFast provider', () => {
test('encodes like PHP urlencode: upper-case escapes, + for spaces, and \' ( ) ! * ~ escaped', () => {
expect(payfast.pfEncode("Men's Camp (Youth)! *~")).toBe('Men%27s+Camp+%28Youth%29%21+%2A%7E');
expect(payfast.pfEncode('https://x.org/a?b=1&c=2')).toBe('https%3A%2F%2Fx.org%2Fa%3Fb%3D1%26c%3D2');
expect(payfast.pfEncode(' padded ')).toBe('padded');
});
test('a checkout signs the non-blank fields in order; an ITN signs every field', () => {
const pairs = [['a', '1'], ['b', ''], ['c', 'x y']];
expect(payfast.paramString(pairs)).toBe('a=1&c=x+y');
expect(payfast.paramString(pairs, { allFields: true })).toBe('a=1&b=&c=x+y');
const md5 = (s) => require('crypto').createHash('md5').update(s).digest('hex');
expect(payfast.signature(pairs, 'my pass')).toBe(md5('a=1&c=x+y&passphrase=my+pass'));
expect(payfast.signature(pairs)).toBe(md5('a=1&c=x+y'));
});
test('builds the signed form in PayFast\'s field order', async () => {
settings(PF);
const { form } = await payfast.createCheckout({
checkoutId: 'chk_9', amountCents: 12550, successUrl: 'https://e.org/ok', cancelUrl: 'https://e.org/no',
payer: { name: "Thandi O'Neil Mokoena", email: 'thandi@example.org' }, description: "Men's Camp (Youth)",
lineItems: [{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }],
});
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
expect(signature).toBe(payfast.signature(Object.entries(rest), 'Salt pass'));
});
test('live mode posts to www.payfast.co.za; test emails are left out', async () => {
settings({ ...PF, payfast_mode: 'live' });
const { form } = await payfast.createCheckout({ checkoutId: 'c', amountCents: 2000, payer: { name: 'A', email: 'walkin@guest.local' } });
expect(form.action).toBe('https://www.payfast.co.za/eng/process');
expect(form.fields.email_address).toBeUndefined();
});
test('not set up: says what is missing', async () => {
settings({});
await expect(payfast.createCheckout({ checkoutId: 'c', amountCents: 2000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/merchant ID and key/) });
});
describe('ITN checks', () => {
// An ITN as PayFast posts it: every field, blanks included, signature last
const itn = (overrides = {}, passphrase = 'Salt pass') => {
const pairs = Object.entries({
m_payment_id: 'chk_9', pf_payment_id: '1089250', payment_status: 'COMPLETE', item_name: "Men's Camp (Youth)", item_description: '',
amount_gross: '125.50', amount_fee: '-4.60', amount_net: '120.90', custom_str1: '', name_first: 'Thandi', name_last: '', email_address: 'thandi@example.org',
merchant_id: '10000100', ...overrides,
});
const sig = payfast.signature(pairs, passphrase, { allFields: true });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${sig}`;
};
const req = (rawBody, ip = '197.97.145.144') => ({ rawBody, ip, headers: {} });
const deps = (reply = 'VALID', ips = ['197.97.145.144']) => ({ lookupIps: async () => new Set(ips), post: jest.fn().mockResolvedValue({ data: reply }) });
beforeEach(() => settings(PF));
test('a genuine ITN passes, and is confirmed with PayFast over every field', async () => {
const d = deps();
await expect(payfast.verifyWebhook(req(itn()), d)).resolves.toMatchObject({ ok: true, webhookId: '1089250' });
const [url, sent] = d.post.mock.calls[0];
expect(url).toBe('https://sandbox.payfast.co.za/eng/query/validate');
expect(sent).toContain('item_description=&');
expect(sent).not.toContain('signature=');
});
test('a forged or wrongly signed ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({}, 'wrong passphrase')), deps())).resolves.toMatchObject({ ok: false, status: 403, message: 'Invalid signature' });
const tampered = itn().replace('amount_gross=125.50', 'amount_gross=1.00');
await expect(payfast.verifyWebhook(req(tampered), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('not from a PayFast address: refused (unless the check is switched off)', async () => {
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: false, status: 403, message: expect.stringMatching(/did not come from PayFast/) });
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: true });
});
test('PayFast doesn\'t confirm it, or is unreachable', async () => {
await expect(payfast.verifyWebhook(req(itn()), deps('INVALID'))).resolves.toMatchObject({ ok: false, status: 403 });
const down = { lookupIps: async () => new Set(['197.97.145.144']), post: jest.fn().mockRejectedValue(new Error('timeout')) };
await expect(payfast.verifyWebhook(req(itn()), down)).resolves.toMatchObject({ ok: false, status: 502 });
});
test('another merchant\'s ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({ merchant_id: '99999999' })), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('parses COMPLETE as a successful payment', () => {
expect(payfast.parseWebhook(payfast.readWebhookBody(itn()))).toEqual({
type: 'payment.succeeded',
transaction: { externalId: '1089250', amountCents: 12550, currency: 'ZAR', createdDate: null, checkoutId: 'chk_9', methodType: null, metadata: {}, status: 'succeeded' },
});
expect(payfast.parseWebhook({ payment_status: 'CANCELLED' }).type).toBe('payment.cancelled');
});
});
});
describe('webhook flow', () => {
const response = () => {
const res = { statusCode: 200, body: null };
res.status = (c) => { res.statusCode = c; return res; };
res.json = (b) => { res.body = b; return res; };
return res;
};
beforeEach(() => {
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Camp' }, user: { id: 'u9', name: 'T' } });
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', registrationOptions: [], payments: [{ amount: 100 }] });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', userId: 'u9', eventId: 'e9', ...data }));
computeRegistrationTotalDue.mockReturnValue(100);
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.providerCheckout.findUnique.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue(null);
prisma.ticket.findMany.mockResolvedValue([]);
});
describe('Yoco', () => {
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_9', amount: 15000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_9', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r9', userId: 'u9', donationAmount: '50.00' } } });
const request = () => {
const ts = String(Math.floor(Date.now() / 1000));
return { headers: { 'webhook-id': 'msg_9', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_9', ts, body)}` }, rawBody: body };
};
beforeEach(() => { process.env.YOCO_WEBHOOK_SECRET = SECRET; settings({}); });
test('records the payment (donation split off), tagged with the provider, and a reconciled transaction', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.statusCode).toBe(200);
expect(res.body.data.processedData.registration).toMatchObject({ id: 'r9', status: 'paid' });
const [main, donation] = prisma.payment.create.mock.calls.map(c => c[0].data);
expect(main).toMatchObject({ amount: 100, method: 'card', provider: 'yoco', externalId: 'p_9', registrationId: 'r9', isDonation: false, status: 'completed' });
expect(donation).toMatchObject({ amount: 50, provider: 'yoco', isDonation: true, originalPaymentId: main.id });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'yoco', externalId: 'p_9', amount: 15000, reconciled: true, paymentId: main.id }) });
});
test('found through our checkout record: the registration is looked up by our id too', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue({ id: 'chk_ours', provider: 'yoco', externalId: 'ch_9', amountCents: 15000, metadata: { registrationId: 'r9' } });
await handleProviderWebhook('yoco')(request(), response());
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_ours', 'ch_9'] } } }));
expect(prisma.payment.create).toHaveBeenCalled();
});
test('a repeated webhook records nothing new', async () => {
prisma.payment.findFirst.mockResolvedValue({ id: 'existing' });
prisma.providerTransaction.findUnique.mockResolvedValue({ id: 'ptx' });
prisma.providerTransaction.update.mockResolvedValue({ id: 'ptx' });
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.body.data.processedData).toMatchObject({ status: 'already_processed', paymentId: 'existing' });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.update).toHaveBeenCalledWith({ where: { id: 'ptx' }, data: { reconciled: true, paymentId: 'existing' } });
});
test('a bad signature is refused before anything is stored', async () => {
const req = request();
req.headers['webhook-signature'] = 'v1,bm9wZQ==';
const res = response();
await handleProviderWebhook('yoco')(req, res);
expect(res.statusCode).toBe(403);
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).not.toHaveBeenCalled();
});
});
describe('PayFast', () => {
const itn = (amount = '100.00') => {
const pairs = Object.entries({ m_payment_id: 'chk_pf', pf_payment_id: '555', payment_status: 'COMPLETE', item_name: 'Camp', amount_gross: amount, merchant_id: '10000100' });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${payfast.signature(pairs, 'Salt pass', { allFields: true })}`;
};
beforeEach(() => {
settings(PF);
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
axios.post.mockResolvedValue({ data: 'VALID' });
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findUnique.mockResolvedValue({ id: 'chk_pf', provider: 'payfast', amountCents: 10000, metadata: { registrationId: 'r9', userId: 'u9', eventId: 'e9', type: 'registration_payment' } });
});
test('a confirmed ITN records the payment against the checkout\'s registration', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn(), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 100, provider: 'payfast', method: 'card', externalId: '555', registrationId: 'r9', status: 'completed' });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '555', checkoutId: 'chk_pf', reconciled: true }) });
});
test('paid a different amount from the checkout: held for review, not recorded', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn('20.00'), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(res.body.data).toEqual({ needsReview: true });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', reconciled: false, note: expect.stringMatching(/Paid R20.00, but the checkout was for R100.00/) }) });
});
});
});
+369
View File
@@ -0,0 +1,369 @@
const {
dayKey,
activeSessions,
scheduleBounds,
normalizeSessionsInput,
sessionAcceptsCheckIn,
currentSession,
registrationClosesAt,
lateInfo,
latePrice,
completionMet,
normalizeReminders,
normalizeCertificateConfig,
scanSession,
redeemedForSession,
sessionLabel,
isPickOption,
expandPickLines,
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
choosesSessions,
lineSessions,
mergeSessionIds,
ticketDateText,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
const wed = (day, h = 16, m = 30) => new Date(Date.UTC(2026, 10, day, h, m)); // November 2026
const session = (id, day, extra = {}) => ({ id, startsAt: wed(day), endsAt: wed(day, 18, 30), cancelledAt: null, ...extra });
// Five Wednesdays: 4, 11, 18, 25 Nov and 2 Dec (as day 32 → Date.UTC rolls over).
const COURSE = [session('s1', 4), session('s2', 11), session('s3', 18), session('s4', 25), session('s5', 32)];
describe('dayKey', () => {
test('uses the church timezone, not UTC', () => {
expect(dayKey(new Date('2026-11-04T23:30:00Z'))).toBe('2026-11-05'); // 01:30 local next day
expect(dayKey(new Date('2026-11-04T21:59:00Z'))).toBe('2026-11-04');
});
});
describe('activeSessions / scheduleBounds', () => {
test('drops cancelled sessions and sorts by start', () => {
const list = [session('b', 11), session('a', 4), session('c', 18, { cancelledAt: new Date() })];
expect(activeSessions(list).map(s => s.id)).toEqual(['a', 'b']);
});
test('bounds run from the first start to the last end', () => {
const b = scheduleBounds(COURSE);
expect(b.startDate).toEqual(wed(4));
expect(b.endDate).toEqual(wed(32, 18, 30));
});
test('a cancelled last session moves the end date back', () => {
const list = [...COURSE.slice(0, 4), { ...COURSE[4], cancelledAt: new Date() }];
expect(scheduleBounds(list).endDate).toEqual(wed(25, 18, 30));
});
test('no active sessions → null', () => {
expect(scheduleBounds([session('a', 4, { cancelledAt: new Date() })])).toBeNull();
});
});
describe('normalizeSessionsInput', () => {
test('sorts sessions and trims labels', () => {
const out = normalizeSessionsInput([
{ startsAt: wed(11), endsAt: wed(11, 18), label: ' Week 2 ' },
{ startsAt: wed(4), endsAt: wed(4, 18) },
]);
expect(out.map(s => s.label)).toEqual([null, 'Week 2']);
});
test('rejects a session that ends before it starts', () => {
expect(() => normalizeSessionsInput([{ startsAt: wed(4, 18), endsAt: wed(4, 16) }])).toThrow(/must end after/);
});
test('rejects missing dates', () => {
expect(() => normalizeSessionsInput([{ startsAt: 'nope', endsAt: wed(4) }])).toThrow(/start and end/);
});
test('needs at least one session that is not cancelled', () => {
expect(() => normalizeSessionsInput([{ startsAt: wed(4), endsAt: wed(4, 18), cancelled: true }])).toThrow(/at least one/);
expect(() => normalizeSessionsInput([])).toThrow(/at least one/);
});
});
describe('check-in window', () => {
const s = session('s1', 4); // 18:30–20:30 local on 4 Nov
test('whole day accepts any time on the session date (local)', () => {
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T05:00:00Z'), 'whole_day')).toBe(true); // 07:00 local
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T21:30:00Z'), 'whole_day')).toBe(true); // 23:30 local
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T22:30:00Z'), 'whole_day')).toBe(false); // 00:30 next day
});
test('session only accepts from 2 h before start until the end', () => {
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:29:00Z'), 'session_only')).toBe(false);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:30:00Z'), 'session_only')).toBe(true);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:30:00Z'), 'session_only')).toBe(true);
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:31:00Z'), 'session_only')).toBe(false);
});
test('cancelled sessions never accept check-ins', () => {
expect(sessionAcceptsCheckIn({ ...s, cancelledAt: new Date() }, wed(4), 'whole_day')).toBe(false);
});
});
describe('currentSession', () => {
test('finds today\'s session', () => {
expect(currentSession(COURSE, new Date('2026-11-11T08:00:00Z')).id).toBe('s2');
});
test('null on a day without a session', () => {
expect(currentSession(COURSE, new Date('2026-11-12T08:00:00Z'))).toBeNull();
});
test('with two sessions in a day, picks the running one, else the next, else the last', () => {
const day = [
{ id: 'am', startsAt: new Date('2026-11-07T07:00:00Z'), endsAt: new Date('2026-11-07T10:00:00Z'), cancelledAt: null },
{ id: 'pm', startsAt: new Date('2026-11-07T14:00:00Z'), endsAt: new Date('2026-11-07T17:00:00Z'), cancelledAt: null },
];
expect(currentSession(day, new Date('2026-11-07T08:00:00Z')).id).toBe('am');
expect(currentSession(day, new Date('2026-11-07T11:00:00Z')).id).toBe('pm'); // between: next one
expect(currentSession(day, new Date('2026-11-07T13:00:00Z')).id).toBe('pm'); // within 2 h of pm
expect(currentSession(day, new Date('2026-11-07T19:00:00Z')).id).toBe('pm'); // after both: last
expect(currentSession(day, new Date('2026-11-07T04:00:00Z')).id).toBe('am'); // before both: next
});
});
describe('registrationClosesAt', () => {
const base = { scheduleType: 'sessions', endDate: wed(32, 18, 30), registrationDeadline: null, lateJoinEnabled: false, lateJoinUntilSessionId: null };
test('single-date events close at the end date', () => {
expect(registrationClosesAt({ ...base, scheduleType: 'single' }, [])).toEqual(wed(32, 18, 30));
});
test('without late joining, closes when the first session starts', () => {
expect(registrationClosesAt(base, COURSE)).toEqual(wed(4));
});
test('with late joining, closes when the chosen session starts', () => {
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, COURSE)).toEqual(wed(18));
});
test('an earlier registration deadline wins', () => {
const deadline = new Date('2026-11-01T00:00:00Z');
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3', registrationDeadline: deadline }, COURSE)).toEqual(deadline);
});
test('a cancelled "until" session falls back to the first session', () => {
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, list)).toEqual(wed(4));
});
});
describe('late pricing', () => {
const event = { scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', lateFixedPrice: null };
test('not active before the first session starts', () => {
expect(lateInfo(event, COURSE, wed(3)).active).toBe(false);
});
test('pro-rata on sessions not yet started, rounded to the nearest R10', () => {
const info = lateInfo(event, COURSE, wed(5)); // session 1 done → 4 of 5 left
expect(info).toMatchObject({ active: true, sessionsLeft: 4, totalSessions: 5 });
expect(latePrice(300, info)).toBe(240);
expect(latePrice(229, info)).toBe(180); // 183.2 → 180
expect(latePrice(232, info)).toBe(190); // 185.6 → 190
});
test('pro-rata never goes below R10 or above the normal price', () => {
const info = lateInfo(event, COURSE, wed(26)); // 1 of 5 left
expect(latePrice(15, info)).toBe(10); // 3 → 0 → at least R10
expect(latePrice(8, info)).toBe(8); // under R10 already: unchanged
expect(latePrice(0, info)).toBe(0);
});
test('a session in progress counts as started', () => {
const info = lateInfo(event, COURSE, new Date(wed(11).getTime() + 60_000));
expect(info.sessionsLeft).toBe(3);
});
test('fixed late price only ever lowers the price', () => {
const info = lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: 150 }, COURSE, wed(12));
expect(latePrice(300, info)).toBe(150);
expect(latePrice(100, info)).toBe(100);
});
test('full price, late joining off, or a fixed price without an amount → no change', () => {
expect(lateInfo({ ...event, latePricing: 'full' }, COURSE, wed(12)).active).toBe(false);
expect(lateInfo({ ...event, lateJoinEnabled: false }, COURSE, wed(12)).active).toBe(false);
expect(lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: null }, COURSE, wed(12)).active).toBe(false);
expect(latePrice(300, { active: false })).toBe(300);
});
});
describe('completionMet', () => {
test('all sessions (default)', () => {
expect(completionMet(undefined, 5, 5)).toBe(true);
expect(completionMet({ type: 'all' }, 4, 5)).toBe(false);
});
test('at least N sessions, capped at the total', () => {
expect(completionMet({ type: 'count', value: 4 }, 4, 5)).toBe(true);
expect(completionMet({ type: 'count', value: 4 }, 3, 5)).toBe(false);
expect(completionMet({ type: 'count', value: 10 }, 3, 3)).toBe(true);
});
test('at least N percent', () => {
expect(completionMet({ type: 'percent', value: 80 }, 4, 5)).toBe(true);
expect(completionMet({ type: 'percent', value: 80 }, 3, 5)).toBe(false);
expect(completionMet({ type: 'percent', value: 66 }, 2, 3)).toBe(true);
});
test('nobody completes without attending', () => {
expect(completionMet({ type: 'count', value: 1 }, 0, 5)).toBe(false);
expect(completionMet(undefined, 0, 0)).toBe(false);
});
});
describe('settings cleaning', () => {
test('reminders: off unless an offset and a channel are chosen', () => {
expect(normalizeReminders(null)).toBeNull();
expect(normalizeReminders({ offsets: [180], channels: [] })).toBeNull();
expect(normalizeReminders({ offsets: [180, 999, 1440], channels: ['email', 'sms'] })).toEqual({ offsets: [1440, 180], channels: ['email'] });
});
test('certificates: unknown template falls back to classic, rule is tidied', () => {
const c = normalizeCertificateConfig({ template: 'fancy', rule: { type: 'count', value: '3' } });
expect(c.template).toBe('classic');
expect(c.rule).toEqual({ type: 'count', value: 3 });
expect(c.title).toBe('Certificate of Completion');
expect(normalizeCertificateConfig({ rule: { type: 'all', value: 4 } }).rule).toEqual({ type: 'all', value: null });
});
});
describe('scanSession', () => {
const event = { checkInWindow: 'whole_day' };
test("defaults to today's session", () => {
expect(scanSession(event, COURSE, null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
});
test('no session today: says when the next one is', () => {
const r = scanSession(event, COURSE, null, new Date('2026-11-12T09:00:00Z'));
expect(r.session).toBeNull();
expect(r.error).toMatch(/next one is Session 3 on 2026-11-18/);
});
test('after the last session: all over', () => {
expect(scanSession(event, COURSE, null, new Date('2026-12-10T09:00:00Z')).error).toMatch(/over/);
});
test('a picked session must be open now', () => {
expect(scanSession(event, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(scanSession(event, COURSE, 's4', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only open on the day/);
expect(scanSession({ checkInWindow: 'session_only' }, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/2 hours before/);
});
test('a cancelled or unknown session is refused', () => {
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
expect(scanSession(event, list, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/cancelled/);
expect(scanSession(event, COURSE, 'nope').error).toMatch(/isn't part/);
});
});
describe('redeemedForSession / sessionLabel', () => {
test("counts only that session's check-ins", () => {
const usages = [{ sessionId: 's1', quantityRedeemed: 2 }, { sessionId: 's2', quantityRedeemed: 1 }, { sessionId: 's1' }];
expect(redeemedForSession(usages, 's1')).toBe(3);
expect(redeemedForSession(usages, 's3')).toBe(0);
});
test('uses the label, else the position among active sessions', () => {
expect(sessionLabel({ id: 'x', label: 'Opening night' }, COURSE)).toBe('Opening night');
expect(sessionLabel(COURSE[2], COURSE)).toBe('Session 3');
const list = [{ ...COURSE[0], cancelledAt: new Date() }, ...COURSE.slice(1)];
expect(sessionLabel(list[2], list)).toBe('Session 2');
});
});
describe('pick sessions', () => {
const pickEvent = {
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
lateJoinEnabled: false, sessions: COURSE,
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('choosesSessions: pick-mode multi-day events only', () => {
expect(choosesSessions(pickEvent)).toBe(true);
expect(choosesSessions({ ...pickEvent, attendanceMode: 'whole' })).toBe(false);
expect(choosesSessions({ ...pickEvent, scheduleType: 'single' })).toBe(false);
});
test('lineSessions / mergeSessionIds', () => {
expect(lineSessions({ sessionId: 's2', sessionIds: [] })).toEqual(['s2']);
expect(lineSessions({ sessionId: null, sessionIds: ['s1', 's3'] })).toEqual(['s1', 's3']);
expect(lineSessions({ sessionId: null, sessionIds: [] })).toBeNull();
expect(mergeSessionIds(['s1'], ['s3', 's1'])).toEqual(['s1', 's3']);
expect(mergeSessionIds(['s1'], [])).toEqual([]);
});
test('an all-sessions ticket with sessions ticked only scans on those', () => {
const ev = { checkInWindow: 'whole_day' };
const onDay2 = new Date(wed(11).getTime() + 30 * 60000);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s2', 's4']).session.id).toBe('s2');
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s1', 's4']).error).toMatch(/isn't booked for Session 2/);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, []).session.id).toBe('s2');
});
test('ticket dates list the ticked sessions', () => {
const ev = { ...pickEvent, sessions: COURSE };
expect(ticketDateText(ev, 'short', null, null, ['s2', 's4'])).toBe('2 sessions · 11 Nov, 25 Nov');
expect(ticketDateText(ev, 'long', null, null, ['s2'])).toBe('Wednesday, 11 November 2026 (Session 2)');
});
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
});
test('a pick line becomes one line per session; an all-sessions line keeps its ticked sessions', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
{ eventOptionId: 'full', quantity: 1, sessionIds: ['s2', 's5'] },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: [] },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: ['s2', 's5'] },
]);
});
test('sessions must be chosen, upcoming, and only on events where people choose them', () => {
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines({ ...pickEvent, attendanceMode: 'whole' }, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
});
test('staff can still book a session that is in progress', () => {
const during = new Date(wed(4).getTime() + 30 * 60000);
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
});
test('registration stays open until the last session; whole-course tickets close at the first', () => {
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
});
test('a picked ticket only checks in on its own session', () => {
const event = { checkInWindow: 'whole_day' };
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
});
test('groupTickets puts one booking\'s picked tickets together', () => {
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
expect(groups[1].sessionIds).toBeNull();
});
});
+60
View File
@@ -29,3 +29,63 @@ describe('pickOffers', () => {
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
});
});
const { lineOpen, offerExpiry, lineName } = require('../src/utils/waitlist');
const { sessionPlacesFree } = require('../src/utils/sessions');
const at = (h) => new Date(Date.UTC(2026, 10, 4, h));
const sessions = [
{ id: 's1', startsAt: at(8), endsAt: at(9), capacity: 10, cancelledAt: null },
{ id: 's2', startsAt: at(10), endsAt: at(11), capacity: 4, cancelledAt: null },
{ id: 's3', startsAt: at(12), endsAt: at(13), capacity: null, cancelledAt: null },
{ id: 's4', startsAt: at(14), endsAt: at(15), capacity: 1, cancelledAt: at(1) },
];
describe('sessionPlacesFree', () => {
const booked = new Map([['s1', 7], ['s2', 4], ['s3', 50]]);
test('a picked session: its own capacity', () => {
expect(sessionPlacesFree(sessions, booked, 's1')).toBe(3);
expect(sessionPlacesFree(sessions, booked, 's2')).toBe(0);
});
test('a session without a capacity is unlimited', () => {
expect(sessionPlacesFree(sessions, booked, 's3')).toBeNull();
});
test('whole-course: the fullest limited session, cancelled ones ignored', () => {
expect(sessionPlacesFree(sessions, booked, null)).toBe(0);
expect(sessionPlacesFree(sessions, new Map([['s1', 2], ['s2', 1]]), null)).toBe(3);
});
});
describe('lineOpen', () => {
const event = { scheduleType: 'sessions', attendanceMode: 'pick', endDate: at(13), sessions };
test('a session can be offered until it starts', () => {
expect(lineOpen(event, 's2', at(9))).toBe(true);
expect(lineOpen(event, 's2', at(10))).toBe(false);
});
test('cancelled or unknown sessions are not offered', () => {
expect(lineOpen(event, 's4', at(1))).toBe(false);
expect(lineOpen(event, 'nope', at(1))).toBe(false);
});
test('whole-course lines close when the course does', () => {
expect(lineOpen(event, null, at(7))).toBe(true);
expect(lineOpen(event, null, at(8))).toBe(false);
expect(lineOpen({ scheduleType: 'single' }, null, at(20))).toBe(true);
});
});
describe('offerExpiry', () => {
test('the offer hours, but never after the session starts', () => {
expect(offerExpiry(24, null, at(0))).toEqual(new Date(at(0).getTime() + 24 * 3600 * 1000));
expect(offerExpiry(24, sessions[1], at(0))).toEqual(at(10));
expect(offerExpiry(2, sessions[1], at(0))).toEqual(at(2));
});
});
describe('lineName', () => {
test('names the session for picked lines', () => {
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: null, session: null })).toBe('Drop-in');
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: { name: 'Adult' }, session: { ...sessions[1], label: 'Class 2' } }, sessions))
.toBe('Drop-in (Adult) — Class 2, Wed 4 Nov');
expect(lineName({ eventOption: { name: 'Drop-in' }, session: sessions[1] }, sessions)).toBe('Drop-in — Session 2, Wed 4 Nov');
});
});
+506
View File
@@ -0,0 +1,506 @@
# Hope Events — New Features
Planned features for the next round of development. Each section covers what
the feature does, who uses it, how it fits the existing platform, and the edge
cases to handle. See [FEATURES.md](./FEATURES.md) for what already exists.
---
## Contents
1. [Coupon codes](#1-coupon-codes)
2. [Waitlist](#2-waitlist)
3. [Group tickets](#3-group-tickets)
4. [Offline scanning](#4-offline-scanning)
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
6. [Sponsor a spot](#6-sponsor-a-spot)
7. [Ticket transfer](#7-ticket-transfer)
8. [Post-event feedback form](#8-post-event-feedback-form)
9. [Group / room allocation](#9-group--room-allocation)
10. [Registration funnel stats](#10-registration-funnel-stats)
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
12. [Admin "impersonate user"](#12-admin-impersonate-user)
13. [Suggested build order](#suggested-build-order)
---
## 1. Coupon codes
Discount codes applied at registration. The main use case is **staff
registering for events** at a reduced or free rate, but codes also work for
promos, leaders, and volunteers.
**Who:** Supervisors create and manage codes. Users enter them at checkout.
Supervisors can also apply them in Manual registration, At the door and the
kiosk.
### Code settings
| Setting | Options |
|---------|---------|
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
| Discount type | Percentage · Fixed amount · Comp (100% free) |
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
| Events | One event, several events, or all events |
| Valid from / until | Optional date window |
| Max total uses | Optional |
| Max uses per user | Optional (default 1) |
| Min quantity | Optional (e.g. 4+ tickets) |
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
| Active | On / off toggle |
### How it works
- The user enters a code on the registration page. It is validated server-side
and the discount shows as its own line before payment.
- The discount is stored as a **separate discount line on the registration**,
with its value **locked at apply time**, the same way early-bird tranches
are locked. Pricing refresh never re-prices an applied coupon, even if the
code later expires or is disabled.
- **Restricted codes** (by user, role or domain) need the user to be logged
in. Guest registration can't use them. This stops staff codes leaking in
group chats.
- **Role-based staff pricing (optional):** a ticket option can also carry a
"Staff price" that applies automatically for Staff+ users without a code.
### Edge cases
- **Edit options:** percentage codes recalculate over the new item total.
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
- **Cancellation / refund:** a cancelled registration releases its use count.
- **Discount can't go below R0.** A comp code on a fully covered registration
marks it Paid and generates tickets.
- **Concurrency:** check use limits inside a transaction so the last use
can't be claimed twice.
### Plumbing
- Invoices and receipts show the code and discount line.
- **New report: Discounts.** Uses per code, total value given away, per event.
- Finance and profit reports subtract discounts from expected revenue.
- **Audit log:** code created, edited, disabled, and manually applied by staff.
### Data model (sketch)
```prisma
model Coupon {
id String @id @default(cuid())
code String @unique
type CouponType // PERCENT | FIXED | COMP
value Decimal?
stackEarlyBird Boolean @default(false)
validFrom DateTime?
validUntil DateTime?
maxUses Int?
maxUsesPerUser Int? @default(1)
minQuantity Int?
restrictRoles Role[]
restrictDomain String?
active Boolean @default(true)
events CouponEvent[]
options CouponOption[]
allowedUsers CouponUser[]
redemptions CouponRedemption[]
createdById String
createdAt DateTime @default(now())
}
model CouponRedemption {
id String @id @default(cuid())
couponId String
registrationId String
userId String
discountAmount Decimal // locked at apply time
appliedById String? // staff member if applied manually
createdAt DateTime @default(now())
releasedAt DateTime? // set on cancellation
}
```
---
## 2. Waitlist
When an event or ticket option is sold out, users can join a waitlist instead
of hitting a dead end.
**Who:** Users join. Supervisors view and manage the queue.
### How it works
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
- The user picks an option, variant and quantity. Login is required if the
event requires login. Otherwise a guest account is created as usual.
- When stock frees up (a cancellation, a refund, or a supervisor raising the
stock limit), the **next person in line** gets an email/WhatsApp with a
**claim link**.
- The claim link **reserves the stock for a set time** (default 24 h, set per
event). If it isn't used in time, the offer moves to the next person.
- A claimed offer goes through normal registration and payment, with
early-bird and coupon rules applied as usual.
### Supervisor tools
- A waitlist tab per event shows position, person, option, quantity, joined
date and offer status.
- Supervisors can manually offer a spot to someone, reorder the queue, or
remove an entry.
- Supervisors can toggle the waitlist on or off per event.
### Edge cases
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
public site doesn't oversell.
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
or skip to the next person who fits. This is configurable per event.
- Users can leave the waitlist from their dashboard.
- Add the waitlist to the Daily summary (count per event).
---
## 3. Group tickets
One payer registers **several named attendees**, each with their own ticket
and their own form response. For example, a parent registers three kids for
camp.
**This is optional.** The payer can still choose to receive all tickets
themselves.
### How it works
- On the registration page, a toggle reads **"Registering for other people?"**
- For each attendee: name, plus optional email/phone, plus the event form if
there is one.
- **Ticket delivery choice per registration:**
- **Send all tickets to me** (default), so the payer gets every ticket.
- **Send each person their own ticket**, using their email/WhatsApp. If an
attendee has no contact details, their ticket falls back to the payer.
- Tickets are **individual, named tickets** rather than a quantity ticket, so
each one can be checked in, transferred or allocated to a room/group
separately.
- The payer stays the account owner for billing, invoices and payments.
### Edge cases
- Works alongside the existing quantity tickets, since an event can allow
either.
- If an attendee's email/phone matches an existing account, link the ticket
to that account (with the same duplicate-account protection as today).
- Attendee forms: each attendee has their own response. The payer can fill
them in, or each attendee can get a link to fill in their own.
- Feeds into parental consent (one consent request per minor attendee) and
group/room allocation.
---
## 4. Offline scanning
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
> section offline at a time.**
> While offline, devices can't see each other's scans. If two devices scanned
> the same section offline, the same ticket could be redeemed twice. Locking
> each section to a single device removes that risk without needing conflict
> resolution.
### How it works
1. **Before going offline (while online):** a staff member picks an event and
section and taps **"Enable offline mode"**.
2. The server **locks that section to this device** and records the device ID
and the staff member. Other devices see "Offline-locked by [name] on [device]"
and can't scan that section, online or offline, until it's released.
3. The device **downloads a snapshot** of every ticket valid for that section
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
4. **While offline:** scans validate against the local snapshot and update
remaining quantities locally. Every scan is queued with a timestamp.
5. **When back online:** queued scans sync to the server automatically. Any
conflicts (for example, a ticket transferred or cancelled while offline)
show in a **sync report** for a supervisor to review.
6. Staff tap **"Release section"** to end offline mode and unlock the section.
### Rules and edge cases
- One section can only be locked to one device at a time. Other sections of
the same event can be locked to other devices.
- A supervisor can **force-release** a lock if a phone dies or goes missing.
Unsynced scans on that device are lost unless the device reconnects later,
in which case they sync and are flagged.
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
after that time won't be on the device, and the scanner shows "Not found in
offline list. Check at the door desk."
- The snapshot is stored only for the lock's duration and cleared on release.
- Check-in confirmation messages to holders are sent after sync, not
instantly.
- **Audit log:** lock, release, force-release, and sync with scan count.
- Built as a **PWA** (service worker caches the scanner page and assets).
---
## 5. Parental consent (youth events)
For youth events, a parent or guardian must give consent before an attendee
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
to review and give consent.
### Event setup
- New event setting: **"Youth event: requires parental consent"**.
- Supervisors write the **consent text** (indemnity, activities, photo
permission, transport, medical treatment authorisation) in the event wizard.
- Optional extra sections on the consent page: **medical info** (allergies,
medication, conditions, medical aid), **emergency contact**, and **photo
consent** (Yes/No, recorded separately).
### How it works
1. During registration, each attendee gives their **date of birth**. If they
are under 18, **parent/guardian name, email and phone** are required.
2. On registration, the parent gets an **email and a WhatsApp** (always both,
regardless of anyone's notification preference) with a unique, secure
consent link.
3. The consent page shows the event details, attendee name and consent text.
The parent fills in any medical/emergency fields, **types their full name**
as a signature, ticks "I am the parent/legal guardian", and taps
**Give consent** (or **Decline**).
4. The system records the timestamp, IP address, device, typed name and the
**version of the consent text** they agreed to.
5. The registrant and the event's notify recipients get a confirmation.
### Rules
- **Tickets are generated but marked "Consent pending"** until consent is
given. Scanners show a clear warning and block check-in for that ticket
(supervisors can override, with an audit log entry).
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
3 days until the event, or until consent is given or declined.
- **Declined consent:** notifies the registrant and supervisors. The
registration stays but can't be checked in. A supervisor handles the refund.
- **Consent links expire** at the event's end date, and can be resent from
the registration detail screen.
- If the consent text is edited after some parents have consented, they keep
their record of the old version. Supervisors can choose to re-request
consent.
### Supervisor tools
- **Consent status column** on registrations and At the door (Pending /
Given / Declined).
- **New reports:** Consent status per event, and **Medical & emergency list**
(printable, for camp leaders). Access to the medical list is restricted to
Supervisor+ and logged.
- Bulk "Resend consent request" to everyone still pending.
### Privacy
Under POPIA, processing a child's personal information needs the consent of a
competent person (parent/guardian). Medical data is special personal
information. Store it encrypted, restrict access, include it in the existing
anonymise/delete flows, and delete it a set period after the event.
---
## 6. Sponsor a spot
A public donation flow linked to a specific event, so people can fund a spot
for someone who can't afford it (bursaries).
### How it works
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
spot"** button on the event page.
- Sponsors choose: **one full spot** (the main ticket price), **multiple
spots**, or **a custom amount**. Payment goes through Yoco as usual.
- A sponsor can be anonymous, or add a name and message.
- The money is recorded as a **donation tagged to the event**, and lands in a
**sponsorship pool** for that event.
- Supervisors assign pool funds to registrations using the existing donation
assign/unassign flow, fully or partly.
- An optional public progress bar shows "12 of 20 spots sponsored".
### Plumbing
- Sponsors get a receipt. They can optionally get a thank-you after the event
through Automations.
- The Donations breakdown report gains a sponsorship pool column (received,
assigned, remaining) per event.
- Recipients' identities are never shown to sponsors.
---
## 7. Ticket transfer
Users can hand their ticket to someone else before the event.
### How it works
- From My Events, a user taps **Transfer** on a ticket, and enters the new
holder's name and email/phone.
- The new holder gets the ticket by email/WhatsApp. If they don't have an
account, an inactive one is created with an activation link (same as guest
registration).
- The **old QR code is invalidated** and a new one is issued, so screenshots
of the old ticket won't scan.
- The original payer stays responsible for the registration (billing doesn't
move).
### Rules
- Allowed only before the event starts, and only for unredeemed tickets
(partially redeemed quantity tickets can't be transferred).
- Can be disabled per event. Supervisors can always transfer.
- If the event needs a form or parental consent, the new holder must complete
their own.
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
flags any scan of a transferred ticket's old code.
---
## 8. Post-event feedback form
An automatic survey sent after the event.
### How it works
- In the event wizard, a new **Feedback** step reuses the form builder, plus
new field types: **Rating (1–5 stars)** and **Multiple choice**.
- Supervisors pick a **send time** (e.g. the morning after the event ends).
It's sent by the existing scheduled job to everyone who **checked in**, or
to all registrants, as chosen.
- Responses can be anonymous or named (per event setting).
- One automatic reminder after 3 days for anyone who hasn't responded.
### Reporting
- **New report: Feedback summary.** Response rate, average rating per
question, multiple-choice breakdown (bar charts), and a list of text
answers. Exports to PDF/Excel like other reports.
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
---
## 9. Group / room allocation
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
camps and lockdowns.
### How it works
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
groups", "Buses". An event can have several sets.
- Each set has **groups** with an optional **capacity**, an optional
**gender restriction** and an optional **leader** (a staff user).
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
right, with capacity counters.
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
optionally keeping registrations together (siblings) or splitting them
(friends).
- Works per attendee, so it relies on group tickets (named attendees).
### Outputs
- Printable lists per group (with leader), and a master list.
- Optionally show the group/room on the attendee's ticket and in My Events.
- Group leaders (staff) can see their group's list, including medical
info if they're allowed to.
- Bulk message by group ("Bus 2 leaves at 07:30").
---
## 10. Registration funnel stats
Shows where people drop off between seeing an event and paying.
### Funnel stages (per event)
1. **Event page views** (unique visitors)
2. **Registration started** (opened the registration page)
3. **Registration submitted**
4. **Paid** (fully paid or free)
### How it works
- Lightweight first-party tracking (no third-party analytics). Log page views
and stage events with an anonymous session ID. No personal data until the
user logs in or registers.
- **New report: Registration funnel.** Count and conversion % at each stage,
over a date range, with a daily trend chart.
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
- A dashboard tile shows conversion % for active events.
### Privacy
Respect cookie consent. Aggregate raw page-view data after 90 days.
---
## 11. 2FA for Admins and Supervisors
Two-factor authentication, **required** for Admin and Supervisor accounts.
### How it works
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
Authenticator). WhatsApp code can be offered as a backup method since WAWP
is already set up.
- **Enrolment:** on the next login after being given the Supervisor/Admin
role, the user must set up 2FA by scanning a QR code and confirming a code.
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
- **Remember this device** for 30 days (optional, per login).
- Optional for Staff and User roles, from Profile & Security.
### Admin controls
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
their sessions and forces re-enrolment.
- Security event log entries: 2FA enabled, disabled, reset, recovery code
used, failed code attempts.
- Failed 2FA attempts count toward the existing login lockout.
- The setup wizard enrols the first admin in 2FA.
---
## 12. Admin "impersonate user"
Lets an Admin see the site exactly as a specific user sees it, to debug
problems ("my ticket isn't showing").
### How it works
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
**reason** (required).
- A short-lived session is created (max 30 minutes) that acts as the user.
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
### Safety rules
- **Read-only by default.** Payments, refunds, cancellations, password/email
changes, deleting data, giving consent and sending messages are blocked
while impersonating.
- Can't impersonate another Admin.
- Requires 2FA to be active on the admin's account.
- **Audit log:** start, end, reason, and every page/action during the
session.
- Optional: notify the user that an admin viewed their account (an
organisation-level setting).
---
## Suggested build order
| Phase | Features | Why |
|-------|----------|-----|
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
SETTINGS.md.
+115
View File
@@ -0,0 +1,115 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.37.3**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
## Done so far (road to 2.0)
| Version | What |
|---------|------|
| 1.32.0 | Early-bird ribbon on event cards |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
| 1.37.1 | "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap |
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
## Next up
1. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap).
2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`).
## Working on this repo
- **Release routine:** branch `feat/<name>` → commit → bump with
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
change — delete those two blocks from the generated SQL. It also rewrites the comment
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
need a migration.
- **Server Node version:** the deployment notes install Node 20, so dependencies must
support Node 20 (that's why pg-boss is pinned to 10.x).
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
saved credential); pushing from your own terminal works.
## Tests
- Backend unit tests: `cd backend && npm test` (Jest).
- PayFast e2e (`e2e/payfast.spec.ts`) pays into a real PayFast sandbox account and is skipped
unless `E2E_PAYFAST_MERCHANT_ID`, `E2E_PAYFAST_MERCHANT_KEY` and `E2E_PAYFAST_PASSPHRASE` are
set (from the Sandbox dashboard). PayFast's documented public test merchant (10000100) no
longer accepts payments. ITNs can't reach localhost; they're covered by unit tests
(`backend/tests/payments.test.js`) — to try one for real, use a tunnel, a public
`BACKEND_URL` and `PAYFAST_SKIP_IP_CHECK=true`.
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
`npx playwright install chromium`). They start their own backend (5100) and
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
re-seeded every run (`e2e/support/prepare-db.js`). The DB user must be able to
create databases. ~5 minutes in dev mode.
Locally, Playwright reuses servers already running on 5100/3100. If the run times out
waiting for the frontend (the home page returns 500 until the e2e backend is up), start
them yourself — `node e2e/support/prepare-db.js`, then the backend from `backend/`
with `PORT=5100`, the e2e `DATABASE_URL`, `JOBS_ENABLED=false`,
`TWO_FACTOR_REQUIRED_ROLES=false`, and `npx next dev -p 3100` from `frontend/` with
`NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100` — then run
`npx playwright test`. Don't edit frontend files while it runs.
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
people only.
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
## Where the multi-day code lives
- `backend/src/utils/sessions.js` — all session rules (dates, check-in window, late
pricing, pick sessions, capacity, grouping); unit tests in `backend/tests/sessions.test.js`.
- `backend/src/utils/certificates.js`, `sessionReminders.js`,
`controllers/attendanceController.js` (register + certificates API).
- `backend/src/jobs/index.js` — the background job registry.
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
## Where the payment code lives
- `backend/src/payments/index.js` — the provider registry and the `PaymentProvider` interface
(documented at the top), provider on/off and order (AppSetting `payment_providers`), the R15
minimum, and `createCheckout`. Tests: `backend/tests/payments.test.js`.
- `backend/src/payments/providers/yoco.js` — Yoco: checkouts, webhook signature check, webhook
parsing. Keys/webhook admin stays in `utils/yoco.js` + `controllers/yocoController.js`.
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
donation split, registration status, tickets, emails, `ProviderTransaction`).
- `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation).
- `backend/src/payments/status.js` — status checks for notifications that never arrived
(`payment-status` job, Payments → Reconcile → "Waiting for payment"). PayFast looks payments
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
(`routes/webhookRoutes.js`), and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`.
## Known gaps / follow-ups
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
+777
View File
@@ -0,0 +1,777 @@
# Hope Events — Road to 2.0.0
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
Each section covers what the feature does, who uses it, how it fits the
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.md).
**Theme for 2.0:** turn a feature-rich church app into a product that can be
sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), and maintainable (tests, CI, typed backend, versioned API).
**Status (2026-10-23):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23).
**Next: 1.38.0 POPIA toolkit.**
**Repositories:**
| Repo | What |
|------|------|
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
---
## Contents
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
6. [Event series and cloning](#6-event-series-and-cloning)
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work)
15. [Suggested build order](#suggested-build-order)
16. [Decisions](#decisions-2026-10-22)
---
## 1. Multi-day events (sessions)
One event that runs over several dates, e.g. **"Alpha Course — every Wednesday
18:30–20:30 for 5 weeks"**, a 3-day conference, or a camp with an evening
session each day. People register and pay **once**, get **one ticket**, and are
checked in **per session**.
> Not the same as [Event series](#6-event-series-and-cloning). A series groups
> *separate* events (Camp 2026, Camp 2027) that each have their own
> registrations. Sessions are dates *inside one* event.
**Who:** Supervisors set up the schedule. Users see it on the event page, in
their tickets and in their calendar. Staff check in per session.
### Event setup
New **Schedule** section on the event form:
| Setting | Options |
|---------|---------|
| Schedule type | Single date (today's behaviour) · Multiple sessions |
| Pattern builder | Repeat **daily / weekly / every N weeks** on chosen weekday(s), start date, time from–to, **number of sessions** or **until date** |
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
| Session location | Optional; falls back to the event location |
| Attendance mode | **Whole course** (one registration covers all sessions) · **Pick sessions** (see phase B below) |
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
| Late-joiner price | Per event, only when late joining is on: Full price · **Pro-rata** (price × remaining sessions ÷ total sessions, rounded to the **nearest R10**, e.g. R183 → R180, R185 → R190) · Fixed late price |
| Check-in window | Per event: **Whole day** (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
| Session reminders | Per event, **off by default**: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
| Certificates | Per event, **off by default**: when on, choose a template and the completion rule (see [Certificates](#certificates)) |
The pattern builder is only a helper: it generates rows, then the session list
is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping
a public holiday is just deleting one row.
### How it works
- `Event.startDate` / `endDate` **stay** and are kept in sync automatically
(first session start, last session end). Everything that already reads them —
consent age checks, transfer cut-off, early-bird, feedback timing, ICS,
listings, sitemap — keeps working unchanged. Only places that need
per-session behaviour are updated.
- **Public event page:** shows the full schedule, with past sessions greyed out
and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov –
Wed 2 Dec".
- **Event listings:** a multi-session event stays "upcoming / running" until
its **last** session ends, and sorts by its **next** session.
- **Tickets (PDF, email, WhatsApp):** list all session dates. One QR code.
- **Calendar (ICS):** one VEVENT per session (not an RRULE, so edited or
skipped sessions are exact).
- **Check-in:** the scanner auto-selects today's session (on a day with more
than one session, the one happening now or next), with a dropdown to
override. Whether a scan is accepted depends on the event's **check-in
window** (whole day or session time only). A ticket can be checked in
**once per session** (per person for group tickets). Scanning again in the
same session shows "Already checked in for Session 3 at 18:42".
- **Manual check-in:** supervisors can tick someone as present for a session
from the attendance register afterwards (e.g. let in without a scan), logged
in the audit log with who marked it.
- **Reminders:** off by default; when switched on for the event, a reminder
goes out before **each** session (email/WhatsApp, via the scheduled-message
worker), e.g. "See you tonight at 18:30".
- **Late joiners:** when late joining is on, the registration page shows which
sessions are left and the late price ("3 of 5 sessions left — R180").
- **Changes:** moving or cancelling a session after people registered prompts
the supervisor to notify registrants (pre-filled broadcast), and updates the
ICS/tickets.
### Phase B: pick sessions (drop-in)
Second step, released separately. Lets people register for **some** sessions,
e.g. a conference where each day is sold separately or a weekly drop-in class.
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per
session, "Full conference pass" covering all.
- Optional **capacity per session** on top of the option stock limit.
- The ticket stores which sessions it covers; scanning on a session it doesn't
cover shows "Not registered for this session" (with "Add & charge" at the
door for supervisors).
- Waitlist works per session when sessions have their own capacity.
### Reporting
- **Attendance register:** grid of attendees × sessions with ticks, totals per
session, attendance % per person. Export to Excel and PDF.
- **Completion filter:** "attended at least 4 of 5" — useful for certificates
and for follow-up ("missed the last 2 weeks").
- Daily summary email includes "Sessions today".
- Funnel stats unchanged (they are per event).
### Certificates
Optional completion certificates (PDF), chosen per event.
- **3–4 built-in templates**, e.g. *Classic* (border, serif), *Modern* (clean,
accent colour bar), *Church* (logo-led, scripture line), *Kids* (playful,
for children's programmes). All use the site logo and brand colour from Site
settings.
- **Event settings:** Certificates on/off · Template · Title ("Certificate of
Completion") · Body text with placeholders (`{name}`, `{event}`,
`{sessionsAttended}`, `{totalSessions}`, `{endDate}`) · Signature name and
role (optional uploaded signature image) · **Completion rule**: attended at
least N sessions or N % (default: all sessions).
- **Preview** button in the event form renders a sample PDF.
- **Who gets one:** attendees meeting the completion rule after the last
session. Supervisors can issue/withdraw one manually (audit logged).
- **Delivery:** download from My Events, bulk download (ZIP) from the
attendance register, and optional email/WhatsApp send.
- Each certificate has a short verification code + QR linking to a public
"certificate valid" page (name, event, date only).
- Generated with `pdfkit`, like tickets and receipts (`utils/pdfDocs.js`).
- Also usable on single-date events (completion = checked in).
### Edge cases
| Case | Behaviour |
|------|-----------|
| Delete a session that has check-ins | Blocked; offer **Cancel session** instead (kept for history, hidden from schedule, excluded from attendance %) |
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
| Ticket transfer | Allowed until the **first** session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
| Parental consent | Age is measured at the first session (current `startDate` rule) |
| Medical details deletion | Runs after the **last** session (end date), as today |
| Feedback form | Sent after the last session |
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
| Timezones | Sessions stored as UTC instants, same as `Event.startDate` today (see `icsUtils.js`) |
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
| Kiosk / At the door | Shows the current session; check-in counts against it |
### Data model (sketch)
```prisma
enum ScheduleType { single sessions }
enum AttendanceMode { whole pick }
enum LatePricing { full prorata fixed }
enum CheckInWindow { whole_day session_only }
model Event {
// ...existing fields
scheduleType ScheduleType @default(single)
attendanceMode AttendanceMode @default(whole)
lateJoinEnabled Boolean @default(false)
lateJoinUntilSessionId String?
latePricing LatePricing @default(full)
lateFixedPrice Float?
checkInWindow CheckInWindow @default(whole_day)
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
certificatesEnabled Boolean @default(false)
certificateConfig Json? // template, title, body, signature, completion rule
sessions EventSession[]
certificates Certificate[]
}
model Certificate {
id String @id @default(uuid())
eventId String
attendeeId String?
userId String?
name String
code String @unique // verification code
manual Boolean @default(false)
issuedAt DateTime @default(now())
revokedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
@@index([eventId])
}
model EventSession {
id String @id @default(uuid())
eventId String
label String?
startsAt DateTime
endsAt DateTime
location String?
capacity Int? // phase B
cancelledAt DateTime?
order Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
ticketSessions TicketSession[]
@@index([eventId, startsAt])
}
model TicketUsage {
// ...existing fields
sessionId String? // null on single-date events
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([sessionId])
}
// Phase B only: which sessions a "pick sessions" ticket covers.
model TicketSession {
ticketId String
sessionId String
@@id([ticketId, sessionId])
}
```
`Ticket.isUsed` keeps its meaning for single-date events. For session events
it's not used for check-in decisions; "checked in" is per session
(`TicketUsage.sessionId`).
### Plumbing
- Session CRUD + pattern generator in `backend/src/utils/sessions.js` with
Jest tests (DST-free but still test month boundaries, skipped dates, "until"
vs "count").
- Scan endpoint takes an optional `sessionId`; defaults to the current session.
- Help content, audit log entries (session created/moved/cancelled),
CHANGELOG, FEATURES.md, SETTINGS.md.
---
## 2. Payment providers: PayFast
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
settings. At checkout the user picks from the enabled ones. (Shipped in
1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
two providers are enough.)
**Who:** Admins configure providers. Users choose at payment. Supervisors
reconcile.
### Site settings → Payments
| Setting | Options |
|---------|---------|
| Providers | Yoco · PayFast — each with an **Enabled** toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast |
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
**Fees are not handled automatically.** Payments are recorded at the full
amount paid; provider fees are not deducted, estimated or added to the payer's
total. Fees are dealt with by people during **cashup**, as today. There is no
"pass fee to payer" option.
"Test connection" button per provider, like the WhatsApp setup.
### How it works
- A single **PaymentProvider interface** in the backend:
`createCheckout`, `handleWebhook` (verify signature → mark paid),
`getTransaction`, `refund` (if the provider supports it via API),
`listTransactions` (for reconciliation).
- Yoco is moved behind the same interface first (no behaviour change), then
PayFast is added.
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) so the existing reconciliation page
works for all providers.
- Refunds: if the provider has no refund API, the supervisor refunds in the
provider's portal and records it here (existing manual refund flow).
### Edge cases
- Provider disabled while a checkout is in progress → webhook still accepted
and processed.
- Duplicate / late webhooks → idempotent on the provider's transaction id.
- Webhook never arrives → "Check payment status" button and a periodic
reconcile job.
- Amount mismatch (user edits amount in app) → payment held as "needs review".
- Only enabled providers appear in kiosk / self-service.
- Amount below R15 → online payment options hidden, with a message to pay at
the door / contact the organiser.
- **Before building each one:** get the documentation links from Joshua and
confirm current API docs for webhooks, sandbox, refunds and QR deep-link
support.
---
## 3. POPIA toolkit
Tools that make the platform **POPIA-ready** out of the box. This is the main
selling point for other churches and organisations: "your attendee data is
handled properly, and you can prove it."
> This is tooling to support compliance, not legal advice. Wording and
> retention periods should be checked by whoever is the customer's
> Information Officer.
**Who:** Admins (and the appointed Information Officer) manage it. Users get
self-service privacy tools.
### For users (data subjects)
- **Privacy centre** in My account:
- **Download my data** — JSON + readable PDF of profile, registrations,
payments, tickets, form answers, consents, messages sent.
- **Correct my data** — edit profile; request correction of locked records.
- **Delete my account** — creates a deletion request (see below).
- **Communication preferences** — email / WhatsApp / marketing opt-in with
timestamps (extends the existing notification preferences).
- **Unsubscribe link** in every bulk email and an opt-out keyword for bulk
WhatsApp. Opted-out users are skipped automatically in broadcasts.
- **Privacy notice acceptance** — versioned; users accept the current version
at registration, and are asked again when it changes.
### For admins
| Tool | What it does |
|------|--------------|
| Information Officer | Name, contact details, shown on privacy notice and contact page |
| Privacy notice editor | Versioned text, published version history |
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
| Deletion workflow | Admin approves → personal info is **anonymised**; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
### Edge cases
- Deletion of a user with an open registration / upcoming event → request
waits until after the event, or the admin cancels and refunds first.
- Anonymised users still count in historic reports ("Deleted user").
- Backups contain deleted data until they age out — stated in the notice;
backup retention is one of the retention policies.
- Impersonation and exports must be logged in the access log.
---
## 4. Kids check-in with security labels
A secure check-in/check-out flow for children's ministry and youth events.
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
**Who:** Parents check in children (self-service kiosk or staff desk).
Kids-ministry staff run rooms. Supervisors set up rooms.
### How it works
- Parent finds their family (phone number / QR from My account) and ticks the
children being dropped off. New children can be added on the spot.
- The system prints **two labels**:
- **Child label:** name, room, allergy/medical icons, short pickup code.
- **Parent label:** the same pickup code.
- **Check-out** requires scanning or typing the matching code. A mismatch
alerts the room leader.
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
1 leader per 5 toddlers) and live room counts.
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
parent ("Please come to the Toddlers room").
- Attendance per child per session feeds the attendance register.
### Plumbing / edge cases
- Label printing via browser print with label-size CSS (Dymo / Brother label
printers, 62 mm and 89×36 mm presets); PDF fallback.
- Allergies and medical info come from the consent/medical fields; respect
the medical-info deletion rules.
- Custody notes ("Do not release to …") visible to room leaders only.
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
---
## 5. Volunteer / serving rosters
Plan who serves where, for an event or each session.
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
with the number of people needed.
- Volunteers **sign up** for open slots, or supervisors **assign** people.
- Reminders via email/WhatsApp before their slot; "can't make it" → swap
request to others in the role.
- **Volunteer check-in** on the day (separate from attendee check-in).
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
- Reports: roster sheet per session, fill rate, hours served per person.
---
## 6. Event series and cloning
- **Clone event:** copy an event with its options, variants, early-bird tiers,
forms, consent text, feedback form, cost template, allocation groups and
sessions (dates shifted to the new start). No registrations.
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
Breakfast) on one series page, with "Next in this series" and a series
follow/notify option.
- **Year-on-year reports:** registrations, income and attendance compared
across events in a series.
- **Season pass:** a bundle across events in a series (extends event bundles).
---
## 7. Apple and Google Wallet tickets
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
ticket, in email and in My Events.
- Pass shows event, date (next session for multi-day), location, holder name,
QR code.
- Passes **update** when the event/session changes (time, venue, cancelled).
- Requires an Apple Developer account (pass signing certificate) and a Google
Wallet issuer account; both configured in Site settings and **optional** —
hidden when not configured.
---
## 8. Outbound webhooks
Let other systems react to what happens in Hope Events.
- Admin adds endpoints and picks events: `registration.created`,
`registration.paid`, `registration.cancelled`, `payment.refunded`,
`ticket.checked_in`, `user.created`, `event.published`…
- Payloads signed with HMAC (secret per endpoint), retries with back-off,
delivery log with "Resend".
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via
Zapier/Make) without building each one. Native integrations can follow later.
---
## 9. Admin dashboard, command palette, bulk actions
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
outstanding, waitlist offers, data requests), alerts.
- **Command palette (Ctrl+K):** jump to any event, user, registration,
ticket or setting; quick actions ("New event", "Find payment R450").
- **Bulk actions** on registration/attendee lists: message, export, mark
paid, move option, cancel, allocate.
- **Saved filters** per user on list pages.
---
## 10. Languages (Afrikaans first)
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
- User language preference; emails, WhatsApp messages and PDFs sent in the
user's language, with per-language templates.
- Event content stays as typed by the supervisor (optional translated title /
description fields later).
---
## 11. Accessibility pass (WCAG 2.2 AA)
- Audit public pages, registration, payment, My Events and the kiosk.
- Keyboard navigation, focus states, labels, colour contrast (light and
dark), screen-reader names for icon buttons, error messages linked to
fields.
- Automated `axe` checks in the Playwright suite so it doesn't regress.
---
## 12. Multi-campus (one install per church)
**Decision: one install per church.** Each customer gets their own install
and database (the `/setup` wizard already makes this possible). This gives
full data separation — which also helps the POPIA story — and lets each
church run on a different server. **True multi-tenant is out of scope.**
**Multi-campus** is in scope: one church with several campuses inside one
install.
### Setup
- Site settings → **Campuses**: off by default. When on, admins add campuses
(name, address, contact, optional logo/colour accent, default location for
events).
- Single-campus churches never see campus fields anywhere.
### How it works
- **Events** belong to one campus, or to **All campuses** (church-wide
conferences, camps).
- **Public site:** campus filter on the events list, remembered per visitor;
campus shown on event cards and tickets. Optional campus landing pages
(`/campus/<slug>`).
- **Users** can set a home campus (used as the default filter and for
broadcasts). Accounts stay shared across campuses — one login for the
church.
- **Campus-scoped roles:** a supervisor or staff member can be limited to
one or more campuses (sees and manages only those events, registrations,
scanning, cashups). Admins and unscoped supervisors see everything.
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
church-wide total.
- **Broadcasts:** target "everyone at campus X".
- **Kids check-in rooms** and **volunteer roles** are per campus.
- **Notification recipients** per campus (e.g. the campus pastor gets that
campus's daily summary).
### Edge cases
- Turning campuses off with campus data present → hidden, not deleted.
- Deleting a campus with events → blocked; archive instead.
- Moving an event to another campus → scoped supervisors of the old campus
lose access; logged in the audit log.
### Data model (sketch)
```prisma
model Campus {
id String @id @default(uuid())
name String
slug String @unique
address String?
isActive Boolean @default(true)
order Int @default(0)
events Event[]
}
// Event.campusId String? (null = all campuses)
// User.homeCampusId String?
// UserCampusScope { userId, campusId } — empty = unscoped
```
### Running many installs
Because every church is a separate install, a few tools make that manageable:
- **Install script:** fresh server → Node, Postgres, PM2, reverse proxy,
`.env`, migrations, then `/setup` in the browser.
- **Update script:** pull a tagged version, back up the database, run
migrations, build, restart; roll back on failure.
- **Version and health:** current version shown in admin and on a `/health`
endpoint (version, DB, jobs, last backup), reported to the
[Hub](#13-central-management-platform-hope-events-hub).
---
## 13. Central management platform (Hope Events Hub)
A separate app, run by Joshua, to see and manage **every Hope Events
install** from one place: who the customers are, which version each runs,
whether they're healthy, basic usage stats, and their **licence** status.
**Who:** Joshua (and any future support staff). Customers never log in to it.
### Separate app, separate repo, separate server
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
deploys. It is never shipped to churches, so none of its code (customers,
licence signing key, other churches' data) ends up on a customer's server.
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
- **What lives where:**
- Hub repo: dashboard, customers, licences, licence **signing** (private
key — only ever on the Hub server), `/api/installs/register` and
`/api/installs/heartbeat` endpoints, alerts.
- Hope Events repo: the Hub **client** — licence step in `/setup`,
"Connect to Hub" in Site settings, heartbeat job, licence verification
with the Hub's **public** key, read-only mode, banners.
- **The contract between them** (register / heartbeat payloads, licence token
format) is versioned with a `protocolVersion` field and documented in both
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
protocol version, so churches on older versions keep reporting while they
wait to update.
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
down, **no church install is affected**.
- Hope Family Church's own site is the first registered install.
### Registration (setup → Hub)
1. The `/setup` wizard gets a **Licence** step: the customer enters the
**licence key** Joshua issued (created in the Hub first).
2. When setup completes, the install sends `POST /api/installs/register`
with: licence key, site name, URL, admin contact, version, server info
(Node/Postgres version, OS), timezone, enabled features.
3. The Hub checks the key, links the install to the customer, and returns an
**install id + install secret** (stored encrypted in AppSettings) and a
**signed licence token**.
4. Existing installs (already past setup) get a **"Connect to Hub"** button
in Site settings → Licence.
All later calls are signed with the install secret (HMAC) and go over HTTPS.
### Heartbeat (install → Hub)
- Every 15 minutes (job queue) the install POSTs a heartbeat:
- **Health:** version, uptime, DB OK, job queue OK, last successful
backup, disk free, last error count (from Sentry or local logs), pending
migrations.
- **Basic stats (aggregate counts only):** users, active/upcoming events,
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
sent, enabled payment providers, campuses on/off.
- **Optional (off by default, customer chooses):** payment totals.
- **Install → Hub only.** The install pushes; the Hub never needs to reach
into the install, so it works behind firewalls and the Hub has no access
to church data.
- **No personal information is ever sent** (no names, emails, phones,
attendee data). Listed in the install's POPIA operator register and
privacy notice as "aggregate usage statistics to the software provider".
### Hub dashboard
- **Installs list:** customer, URL, version (flag if behind the latest tag),
licence status, last heartbeat (red if > 1 h ago), health status.
- **Install detail:** health history, stats charts, version history,
licence history, notes, contacts.
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
failed or older than 48 h, disk low, errors spiking, licence expiring.
- **Releases:** latest version and changelog; installs see "Update
available" in their admin.
- **Customers:** contact details, plan, licence(s), billing notes.
### Licences
| State | What the install does |
|-------|----------------------|
| **Active** | Everything works |
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
| **Revoked** | Same as suspended, with a permanent banner |
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the
install picks up the change on its next heartbeat.
- The licence is a **signed token** (Ed25519: the Hub signs with its private
key, installs only hold the public key). Installs verify it **offline**, so
a Hub outage never switches a church off, and a token can't be faked by
editing the database.
- **Never a hard kill:** a church must always be able to scan tickets for
events people already paid for, and get its own data out (POPIA access
rights). Suspension blocks *new* activity only.
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
the token.
### Things to be aware of
- Code runs on the customer's server, so a determined developer could patch
out the licence check. Real protection is the **contract** plus updates and
support only being available to valid licences — the licence check keeps
honest customers honest.
- The Hub should not offer remote commands (run updates, impersonate,
database access) in v1: it's the most valuable target an attacker could
hit, so keeping it read-only toward installs keeps every church safe if it
is ever compromised. Remote "trigger update" can be revisited later.
- The Hub itself needs 2FA, backups and the same audit log approach.
### Data model (Hub, sketch)
```prisma
model Customer { id, name, contactName, email, phone, notes, licences, installs }
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
model Install { id, customerId, licenceId, name, url, secretHash, version,
lastHeartbeatAt, health Json, registeredAt }
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
// rolled up to daily after 30 days
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
```
---
## 14. Foundations (platform work)
Not visible to users, but this is what makes 2.0 a safe product to sell and
keep adding to.
| Item | What |
|------|------|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
---
## Suggested build order
| Phase | Version | Features | Why |
|-------|---------|----------|-----|
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 – 1.39.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.40.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
feature.
---
## Decisions (2026-10-22)
| # | Question | Decision |
|---|----------|----------|
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
| 2 | Late joining and late price | Set **per event**: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
| 3 | Check-in window | Per event, **whole day** by default (any time on the session's date); "session time only" as the alternative |
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
| 5 | Session reminders | Off by default, configurable per event |
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
## Still open
Hub (needed before phase 6, not before 1.33.0):
1. Hub domain and server, and where licence keys come from (created by hand
in the Hub per customer?).
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
still scan, data export still works) — agreed?
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
counts only?
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
now?
+48
View File
@@ -0,0 +1,48 @@
import { test as base, expect, type Page } from "@playwright/test";
import fs from "fs";
import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
export const API = "http://localhost:5100";
// Written by e2e/support/prepare-db.js when the e2e backend starts, so read lazily.
let cached: Seed | null = null;
export function seed(): Seed {
if (!cached) cached = JSON.parse(fs.readFileSync(path.join(__dirname, ".data", "seed.json"), "utf8")) as Seed;
return cached;
}
/** Signs the page in as a seeded user (the token goes where the app keeps it). */
export async function loginAs(page: Page, role: Role) {
await page.addInitScript((token) => {
localStorage.setItem("hope_events_token", token);
}, seed().users[role].token);
}
export const test = base.extend({
page: async ({ page }, use) => {
// Skip the cookie notice so it never covers what the tests click.
await page.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
await use(page);
},
});
/** Calls the e2e API as a seeded user. */
export async function api<T = unknown>(page: Page, role: Role, method: string, url: string, body?: unknown): Promise<T> {
const res = await page.request.fetch(`${API}${url}`, {
method,
headers: { Authorization: `Bearer ${seed().users[role].token}`, "Content-Type": "application/json" },
data: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok()) throw new Error(`${method} ${url} → ${res.status()}: ${text}`);
return (text ? JSON.parse(text) : null) as T;
}
export { expect };
+60
View File
@@ -0,0 +1,60 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
// Pays into a real PayFast sandbox account, so it needs one:
// E2E_PAYFAST_MERCHANT_ID, E2E_PAYFAST_MERCHANT_KEY, E2E_PAYFAST_PASSPHRASE
// (from the Sandbox dashboard). Skipped when they aren't set, e.g. in CI.
const PF = {
merchantId: process.env.E2E_PAYFAST_MERCHANT_ID || "",
merchantKey: process.env.E2E_PAYFAST_MERCHANT_KEY || "",
passphrase: process.env.E2E_PAYFAST_PASSPHRASE || "",
};
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
type Registration = { id: string };
type Checkout = { checkoutId: string; redirectUrl: string; provider: string | null };
test.describe("PayFast", () => {
test.skip(!PF.merchantId, "Set E2E_PAYFAST_* to test against a PayFast sandbox account");
test("choose PayFast on the pay page and land on PayFast's payment page", async ({ page }) => {
await api(page, "admin", "PUT", "/api/payfast/config", { ...PF, mode: "sandbox" });
try {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: true }] });
// A booking for the paid conference, then an online checkout for it
const event = await api<EventWithOptions>(page, "staff", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<Registration>(page, "staff", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
const origin = new URL(page.url() === "about:blank" ? "http://localhost:3100" : page.url()).origin;
const checkout = await api<Checkout>(page, "staff", "POST", "/api/payments/checkout", {
registrationId: reg.id, successUrl: `${origin}/payment/success`, cancelUrl: `${origin}/payment/cancel`, failureUrl: `${origin}/payment/failure`,
});
// Yoco and PayFast are both on, so the payer chooses
expect(checkout.provider).toBeNull();
expect(checkout.redirectUrl).toContain(`/pay/${checkout.checkoutId}`);
await loginAs(page, "staff");
await page.goto(new URL(checkout.redirectUrl).pathname);
await expect(page.getByText("R150.00")).toBeVisible();
await expect(page.getByRole("button", { name: "Pay with Yoco" })).toBeVisible();
await page.getByRole("button", { name: "Pay with PayFast" }).click();
// The browser posts the signed form to the sandbox, which shows its payment page
await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 });
await expect(page.locator("body")).not.toContainText(/signature/i);
await expect(page.locator("body")).toContainText(/150[.,]00/);
// Not paid at PayFast: the status check asks PayFast's API (signed) and says so
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await page.getByText("Reconcile", { exact: true }).click();
const row = page.getByRole("row").filter({ hasText: "PayFast" }).filter({ hasText: "R 150.00" }).first();
await row.getByRole("button", { name: "Check status" }).click();
await expect(row.getByText("Not paid (yet)")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No new payments found.")).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" });
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
test.describe("payment providers", () => {
test("admin: Payments tab turns a provider on and off", async ({ page }) => {
await loginAs(page, "admin");
// The tab's older name still opens it.
await page.goto("/dashboard/admin/settings?tab=yoco");
await expect(page.getByRole("heading", { name: "Payments", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeVisible();
const providers = page.getByRole("list", { name: "Payment providers" });
const yoco = providers.getByRole("checkbox", { name: "Enable Yoco" });
await expect(yoco).toBeChecked();
try {
await yoco.uncheck();
await expect(page.getByText("Payment providers saved")).toBeVisible();
await expect(page.getByText(/Online payments are off/)).toBeVisible();
// A switched-off provider's settings are hidden
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeHidden();
const off = await api<{ providers: { id: string }[]; minimumAmount: number }>(page, "user", "GET", "/api/payments/providers");
expect(off.providers).toEqual([]);
expect(off.minimumAmount).toBe(15);
await yoco.check();
await expect(yoco).toBeChecked();
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeVisible();
const settings = await api<{ providers: ProviderSetting[] }>(page, "admin", "GET", "/api/payments/providers/settings");
expect(settings.providers.find((p) => p.id === "yoco")?.enabled).toBe(true);
} finally {
// Leave Yoco on for the other tests.
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] });
}
});
test("with no provider on, nothing offers to pay online", async ({ page }) => {
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
const event = await api<EventWithOptions>(page, "admin", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<{ id: string }>(page, "admin", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: false }, { id: "payfast", enabled: false }] });
try {
await loginAs(page, "admin");
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByText(/Online payment isn.t available/)).toBeVisible();
await expect(page.getByRole("button", { name: "Pay online" })).toHaveCount(0);
await page.goto("/dashboard/user");
await expect(page.getByRole("button", { name: "Make donation" })).toHaveCount(0);
await page.goto("/dashboard/user/donate");
await expect(page.getByText(/Online donations aren.t available/)).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
}
// Back on (when Yoco is set up in this environment), the button returns
const on = await api<{ providers: unknown[] }>(page, "admin", "GET", "/api/payments/providers");
if (on.providers.length > 0) {
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByRole("button", { name: "Pay online" })).toBeVisible();
}
});
});
+94
View File
@@ -0,0 +1,94 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
test.describe("pick sessions", () => {
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.dropIn}`);
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await expect(picker.getByText("Full")).toBeVisible();
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
await picker.getByRole("checkbox").nth(1).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R100.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
});
test("an all-sessions pass: tick the days, pay one price", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.conference}`);
await expect(page.getByText("R200.00", { exact: true })).toBeVisible();
await expect(page.getByText("per session")).toHaveCount(0);
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(0).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R200.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const line = regs.find((r) => r.eventId === seed().events.conference)?.registrationOptions[0];
expect(line?.sessionId ?? null).toBeNull();
expect(line?.sessionIds).toHaveLength(2);
expect(line?.priceSnapshot).toBe(200);
expect(line?.quantity).toBe(1);
});
test("join the waitlist for a full class", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/register/${seed().events.dropIn}`);
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/Drop-in — Class 1/)).toBeVisible();
await page.locator("form").filter({ hasText: "Join the waitlist" }).getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/You're on the waitlist/)).toBeVisible();
const mine = await api<WaitlistEntry[]>(page, "admin", "GET", "/api/waitlist/mine");
const entry = mine.find((e) => e.eventId === seed().events.dropIn);
expect(entry?.sessionId).toBeTruthy();
expect(entry?.quantity).toBe(1);
});
test("at the door: book a class for a walk-in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
// The page picks a default event once its list loads; choose ours after that.
await page.waitForLoadState("networkidle");
await page.locator("select").first().selectOption(value!);
await page.waitForLoadState("networkidle");
await expect(page.locator("select").first()).toHaveValue(value!);
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
await page.getByText('New attendee "Door Walk In"…').click();
await page.getByPlaceholder("Email address").fill("door-walkin@e2e.test");
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await expect(page.getByText("R 50.00 per session")).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(1).check();
await expect(page.getByText("Total: R 50.00")).toBeVisible();
await page.getByRole("button", { name: "Confirm Registration" }).click();
await expect(page.getByText("Select Items")).toBeHidden();
const regs = await api<Registration[]>(page, "supervisor", "GET", `/api/registrations/event/${seed().events.dropIn}`);
const reg = regs.find((r) => r.user?.name === "Door Walk In");
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(1);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect, seed } from "./fixtures";
test.describe("public pages", () => {
test("events list shows events, with the multi-day summary", async ({ page }) => {
await page.goto("/events");
await expect(page.getByRole("heading", { name: "All Events" })).toBeVisible();
await expect(page.getByText("E2E Free Evening")).toBeVisible();
const course = page.locator("div.border.rounded-xl", { hasText: "E2E Alpha Course" });
await expect(course.getByText(/5 sessions · /)).toBeVisible();
});
test("a multi-day event page shows the schedule and the late-joiner price", async ({ page }) => {
await page.goto(`/events/${seed().events.course}`);
await expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible();
const schedule = page.locator("div.rounded-xl", { has: page.getByRole("heading", { name: "Schedule" }) });
await expect(schedule.getByText("Week 1")).toBeVisible();
await expect(schedule.getByText("Week 5")).toBeVisible();
await expect(schedule.getByText("Next")).toBeVisible();
// Week 2 is in progress, so 3 of 5 are still to come: R300 × 3/5 = R180.
await expect(page.getByText(/3 of 5 sessions are still to come/).filter({ visible: true }).first()).toBeVisible();
await expect(page.getByText("R180.00").filter({ visible: true }).first()).toBeVisible();
});
test("an unknown certificate code is reported as not found", async ({ page }) => {
await page.goto("/certificates/verify/ABCDE-12345");
await expect(page.getByRole("heading", { name: "Certificate not found" })).toBeVisible();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; status: string; eventId: string };
type Ticket = { id: string; qrCode: string; eventId: string };
type CashByUser = { rows: { userId: string | null; cash: { total: number; count: number } }[] };
test.describe("registration → payment → ticket → check-in → cashup", () => {
test("a free event: register and get a ticket straight away", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.free}`);
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const tickets = await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets");
expect(tickets.some((t) => t.eventId === seed().events.free)).toBe(true);
});
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
await page.getByRole("button", { name: "+" }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const reg = regs.find((r) => r.eventId === seed().events.paid);
expect(reg?.status).not.toBe("paid");
expect((await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).some((t) => t.eventId === seed().events.paid)).toBe(false);
// A supervisor records the cash payment at the desk.
await api(page, "supervisor", "POST", "/api/payments", { amount: 150, method: "cash", registrationId: reg!.id, eventId: seed().events.paid, userId: seed().users.user.id });
const ticket = (await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).find((t) => t.eventId === seed().events.paid);
expect(ticket).toBeTruthy();
// My Events shows it.
await page.goto("/dashboard/user");
await expect(page.getByText("E2E Paid Conference").first()).toBeVisible();
// Staff check it in; a second scan is refused.
await api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 });
await expect(api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 })).rejects.toThrow(/403.*fully used/);
// Cashup: the R150 cash is on the supervisor who took it.
const cash = await api<CashByUser>(page, "supervisor", "GET", `/api/cashups/event/${seed().events.paid}/cash-by-user`);
const mine = cash.rows.find((r) => r.userId === seed().users.supervisor.id);
expect(mine?.cash.total).toBe(150);
expect(mine?.cash.count).toBe(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
test.describe("multi-day events", () => {
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
const { course, events } = seed();
// Scanning today: Week 2.
const scan = await api<{ session: { label: string } }>(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 });
expect(scan.session.label).toBe("Week 2");
await expect(api(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 })).rejects.toThrow(/Already checked in for Week 2/);
// The register shows weeks 1 and 2 ticked.
await loginAs(page, "supervisor");
await page.goto(`/dashboard/supervisor/attendance?eventId=${events.course}`);
const row = page.locator("tr", { hasText: "Thandi Test" });
await expect(row.getByText("2/5")).toBeVisible();
// Tick week 3 by hand (e.g. let in without a scan), then untick it.
const week3 = row.locator("td").nth(3).getByRole("button");
await week3.click();
await expect(row.getByText("3/5")).toBeVisible();
await week3.click();
await expect(row.getByText("2/5")).toBeVisible();
// Two sessions meets the rule (at least 2), so a certificate can be issued now.
await page.getByRole("button", { name: "Issue now" }).click();
await expect(page.getByText("1 certificate issued.")).toBeVisible();
const code = (await row.locator("span.font-mono").innerText()).trim();
expect(code).toMatch(/^[A-Z0-9]{5}-[A-Z0-9]{5}$/);
// Anyone can check it.
await page.goto(`/certificates/verify/${code}`);
await expect(page.getByRole("heading", { name: "Valid certificate" })).toBeVisible();
await expect(page.getByText("Thandi Test")).toBeVisible();
});
test("the attendee sees their certificate on My Events", async ({ page }) => {
await loginAs(page, "user");
await page.goto("/dashboard/user");
await expect(page.getByRole("heading", { name: "My Certificates" })).toBeVisible();
await expect(page.getByText("E2E Alpha Course").first()).toBeVisible();
});
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/ticket-scanning");
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
test.describe("supervisor tools", () => {
test("create a weekly multi-session event in the wizard", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/events");
await page.getByRole("button", { name: "+ Add Event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
await page.getByPlaceholder("Event title").fill("E2E Wizard Course");
await page.getByRole("button", { name: "Multiple sessions" }).click();
// The repeat pattern defaults to weekly, 18:30–20:30, 5 sessions from today.
await page.getByRole("button", { name: "Create sessions" }).click();
await expect(page.getByText(/^5 sessions — edit any date/)).toBeVisible();
await page.locator('input[type="number"][placeholder="0"]').first().fill("250");
// Through the remaining steps to Create Event.
const create = page.getByRole("button", { name: "Create Event" });
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
await page.getByRole("button", { name: "Next →" }).click();
}
await create.click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeHidden();
const row = page.locator("li", { hasText: "E2E Wizard Course" });
await expect(row.getByText(/5 sessions · /)).toBeVisible();
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all");
const created = all.find((e) => e.title === "E2E Wizard Course");
expect(created?.scheduleType).toBe("sessions");
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
});
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
test("admin: background jobs page", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/settings?tab=jobs");
await expect(page.getByRole("heading", { name: "Background jobs" })).toBeVisible();
// The e2e backend runs with JOBS_ENABLED=false.
await expect(page.getByText(/All background jobs are switched off/)).toBeVisible();
await expect(page.getByText("Session reminders and certificates")).toBeVisible();
});
});
+16
View File
@@ -0,0 +1,16 @@
// The e2e database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added
// to the database name — always separate from the database you develop against.
const path = require('path');
function e2eDatabaseUrl() {
if (process.env.E2E_DATABASE_URL) return process.env.E2E_DATABASE_URL;
const backend = path.join(__dirname, '..', '..', 'backend');
require(require.resolve('dotenv', { paths: [backend] })).config({ path: path.join(backend, '.env'), quiet: true });
if (!process.env.DATABASE_URL) throw new Error('Set E2E_DATABASE_URL (or DATABASE_URL in backend/.env) for the e2e tests.');
const u = new URL(process.env.DATABASE_URL);
const name = u.pathname.replace(/^\//, '');
u.pathname = `/${name.endsWith('_e2e') ? name : `${name}_e2e`}`;
return u.toString();
}
module.exports = { e2eDatabaseUrl };
+175
View File
@@ -0,0 +1,175 @@
/**
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
* login tokens the tests use.
*
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
* database name. It is always a separate database — never the one you develop against.
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const root = path.join(__dirname, '..', '..');
const backend = path.join(root, 'backend');
const req = (m) => require(require.resolve(m, { paths: [backend] }));
req('dotenv').config({ path: path.join(backend, '.env') });
const { e2eDatabaseUrl } = require('./db-url');
async function recreateDatabase(url) {
const { Client } = req('pg');
const target = new URL(url);
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
const admin = new URL(url);
admin.pathname = '/postgres';
admin.search = '';
const client = new Client({ connectionString: admin.toString() });
await client.connect();
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await client.query(`CREATE DATABASE "${dbName}"`);
await client.end();
}
async function seed(url) {
process.env.DATABASE_URL = url;
const prisma = require(path.join(backend, 'src/config/db'));
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
await prisma.appSetting.createMany({
data: [
{ key: 'setup_complete', value: 'true' },
{ key: 'org_name', value: 'E2E Church' },
{ key: 'org_address', value: '1 Test Street, Pretoria' },
],
});
const password = await hashPassword('E2e-password-123');
const mk = (role, name, email, phoneNumber) => prisma.user.create({
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() },
});
const users = {
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
};
const D = 86400000;
const now = Date.now();
const at = (days, hourUtc = 16, minute = 30) => {
const d = new Date(now + days * D);
d.setUTCHours(hourUtc, minute, 0, 0);
return d;
};
const free = await prisma.event.create({
data: {
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
const paid = await prisma.event.create({
data: {
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
},
});
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
const todayStart = new Date(now - 30 * 60 * 1000);
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
const startsAt = new Date(todayStart.getTime() + days * D);
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
});
const course = await prisma.event.create({
data: {
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true,
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
sessions: { create: sessions },
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
// A drop-in class: three upcoming classes, booked per class (the first is full).
const classes = [1, 8, 15].map((days, i) => {
const startsAt = at(days, 16, 0);
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
});
const dropIn = await prisma.event.create({
data: {
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick', waitlistEnabled: true,
sessions: { create: classes },
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
// A conference sold per pass: people tick the days they'll come, one price for the pass.
const days = [2, 3, 4].map((d, i) => {
const startsAt = at(d, 7, 0);
return { label: `Day ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 8 * 60 * 60 * 1000), order: i };
});
const conference = await prisma.event.create({
data: {
title: 'E2E Conference', description: 'Three days; come to the days you can.', startDate: days[0].startsAt, endDate: days[2].endsAt,
price: 200, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
sessions: { create: days },
eventOptions: { create: [{ name: 'Conference pass', price: 200, isMainTicket: true, sessionMode: 'all' }] },
},
});
// Someone else already has the only place in class 1.
await prisma.registration.create({
data: {
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
},
});
// Thandi is booked on the course and was checked in for week 1.
const reg = await prisma.registration.create({
data: {
userId: users.user.id, eventId: course.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
},
});
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
await generateTicketsForRegistration(reg.id);
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
};
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
await prisma.$disconnect?.();
}
(async () => {
const url = e2eDatabaseUrl();
await recreateDatabase(url);
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
await seed(url);
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
process.exit(0);
})().catch((e) => {
console.error('[e2e] Preparing the database failed:', e);
process.exit(1);
});
+3
View File
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
// The end-to-end tests run their own dev server; a separate build folder keeps it
// from clashing with one already running.
distDir: process.env.NEXT_DIST_DIR || ".next",
images: {
domains: ['images.unsplash.com'],
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.29.0",
"version": "1.37.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.29.0",
"version": "1.37.3",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.29.0",
"version": "1.37.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+17 -9
View File
@@ -1,14 +1,18 @@
/*
* Service worker for offline ticket scanning.
*
* Only the scanner page and the static files it needs are cached:
* - /_next/static/* (hashed, never change): cache-first.
* - The scanner page itself: network-first, falling back to the cached copy offline.
* Only the scanner page and the static files it needs are cached, both network-first
* with the cached copy as the offline fallback:
* - /_next/static/*. Not cache-first: under `next dev` these files keep their names when
* their content changes, so cache-first served stale code on every page, for good.
* (Production files are hashed and come from the browser's HTTP cache anyway.)
* - The scanner page itself.
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
* API calls are never cached — offline scans go through IndexedDB instead.
*/
const CACHE = 'hope-scanner-v1';
// v2: drops v1 caches, which could hold stale development scripts (see above).
const CACHE = 'hope-scanner-v2';
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
self.addEventListener('install', () => self.skipWaiting());
@@ -41,11 +45,15 @@ self.addEventListener('fetch', (event) => {
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const hit = await cache.match(req);
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
return;
}
@@ -0,0 +1,61 @@
import { Metadata } from "next";
import { BadgeCheck, ShieldX } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Verify a certificate",
robots: { index: false },
};
type Verification = { valid: boolean; revoked?: boolean; name?: string; event?: string; completedOn?: string; issuedAt?: string };
// Public check for the QR code / code printed on a completion certificate. Shows only the
// name, event and dates — nothing else about the person.
export default async function VerifyCertificatePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
let result: Verification = { valid: false };
try {
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
} catch {
result = { valid: false };
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-12 px-4 max-w-lg mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm text-center">
{result.valid ? (
<>
<BadgeCheck className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">Valid certificate</h1>
<p className="text-gray-700 mt-3"><span className="font-semibold">{result.name}</span> completed</p>
<p className="text-lg font-medium text-brand-700 mt-1">{result.event}</p>
<p className="text-sm text-gray-500 mt-3">
{result.completedOn && <>Completed {formatDate(new Date(result.completedOn))} · </>}
{result.issuedAt && <>Issued {formatDate(new Date(result.issuedAt))}</>}
</p>
</>
) : (
<>
<ShieldX className="w-12 h-12 text-red-500 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">{result.revoked ? "This certificate was withdrawn" : "Certificate not found"}</h1>
<p className="text-sm text-gray-500 mt-3">
{result.revoked
? "The organiser has withdrawn this certificate, so it's no longer valid."
: "Check the code on the certificate and try again. Codes look like K7M2Q-9XRTB."}
</p>
</>
)}
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
</div>
</main>
<Footer />
</div>
);
}
+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, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { PaymentsTab } from "@/components/admin/PaymentsTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -25,13 +26,20 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "payments", label: "Payments", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
];
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
/** The code from Search Console's verification meta tag; accepts the whole tag or just the code. */
function googleVerificationCode(input: string): string {
const m = input.match(/content\s*=\s*["']([^"']*)["']/i);
return (m ? m[1] : input).trim();
}
function Field({
label, hint, required, children,
}: {
@@ -89,7 +97,9 @@ function SiteSettingsPageInner() {
const searchParams = useSearchParams();
const { reload: reloadSettings } = useSiteSettings();
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
// ?tab=yoco is the Payments tab's older name.
const tabParam = searchParams.get("tab") === "yoco" ? "payments" : searchParams.get("tab");
const initialTab = TABS.some(t => t.id === tabParam) ? (tabParam as TabId) : "organisation";
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
const [loadingInitial, setLoadingInitial] = useState(true);
@@ -104,6 +114,7 @@ function SiteSettingsPageInner() {
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
const [googleVerification, setGoogleVerification] = useState("");
// ── Branding ──
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
@@ -153,6 +164,7 @@ function SiteSettingsPageInner() {
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setGoogleVerification(s.google_site_verification || "");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
@@ -209,6 +221,7 @@ function SiteSettingsPageInner() {
org_phone: orgPhone.trim(),
org_address: orgAddress.trim(),
app_base_url: appBaseUrl.trim(),
google_site_verification: googleVerificationCode(googleVerification),
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
@@ -432,6 +445,10 @@ function SiteSettingsPageInner() {
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<Field label="Google Search Console verification" hint="In Search Console, add this site as a URL-prefix property and choose the HTML tag method. Paste the tag (or just its content code) here and save, then click Verify in Search Console. Leave it set to stay verified.">
<input className={inputCls} placeholder='<meta name="google-site-verification" content="..." />'
value={googleVerification} onChange={e => setGoogleVerification(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
@@ -688,10 +705,11 @@ function SiteSettingsPageInner() {
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
{activeTab === "payments" && <PaymentsTab active={activeTab === "payments"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
</div>
{activeTab === "branding" && (
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { printTicketCards } from "@/lib/ticketPrint";
import { QrImage } from "@/components/shared/QrImage";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -142,96 +143,7 @@ function EventTicketsContent() {
return list;
}, [tickets, eventId, selectedUserId, selectedVariantId]);
// Printing helpers
const buildTicketHtmlCard = (t: TicketRecord) => {
const eventTitle = t.event?.title || t.eventId || "Event";
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
const qty = t.quantity || 1;
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
)}`;
const dateStr = eventDate ? formatDate(eventDate) : "";
return `
<div class="ticket">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">ID: ${t.id}</div>
${holder ? `<div class="meta">${holder}</div>` : ""}
</div>
`;
};
const openPrintWindow = (pagesHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>Tickets</title>
<style>
@page { size: A4; margin: 10mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.page {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
height: 277mm;
gap: 6mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
.evt { font-weight: bold; font-size: 14px; }
.type { font-size: 12px; margin-top: 2mm; }
.qty { font-size: 11px; margin-top: 1mm; }
.date { font-size: 10px; color: #555; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
.qrwrap img { width: 30mm; height: 30mm; }
.meta { font-size: 10px; text-align: center; }
</style>
</head><body>
${pagesHtml}
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const printTickets = (ticketList: TicketRecord[]) => {
if (!ticketList || ticketList.length === 0) return;
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
const PAGE_SIZE = 8;
const pages: string[] = [];
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
const chunk = ticketList.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, (t) => t.user?.name);
return (
<div className="max-w-6xl mx-auto w-full p-6">
+2 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
@@ -15,6 +15,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
] as const;
export default function StaffDashboardPage() {
@@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan";
import { errorMessage } from "@/lib/utils";
import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
@@ -30,6 +31,10 @@ type ScanTicket = {
attendee?: { name: string } | null;
user?: { name?: string; email?: string } | null;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
// Multi-day events: the session this scan counts for, and what's left for it.
remaining?: number;
session?: { id: string; label: string } | null;
sessionError?: string | null;
};
export default function TicketScanningPage() {
@@ -73,6 +78,8 @@ export default function TicketScanningPage() {
const [sections, setSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false);
// Multi-day events: "auto" = today's session (the server picks it).
const [selectedSessionId, setSelectedSessionId] = useState<string>("auto");
// All events from API (including past); frontend filters to 12h window or all past
useEffect(() => {
@@ -100,6 +107,11 @@ export default function TicketScanningPage() {
});
}, [allEvents, includePastEvents]);
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
useEffect(() => { setSelectedSessionId("auto"); }, [selectedEventId]);
const sessionParam = selectedEventId !== "all" && selectedSessionId !== "auto" ? selectedSessionId : null;
useEffect(() => {
(async () => {
if (!token || !selectedEventId || selectedEventId === "all") {
@@ -136,7 +148,7 @@ export default function TicketScanningPage() {
useEffect(() => {
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
scannerRef.current?.stop();
}, [selectedEventId, selectedSectionId]);
}, [selectedEventId, selectedSectionId, selectedSessionId]);
async function handleResult(text: string) {
setError(null);
@@ -177,7 +189,7 @@ export default function TicketScanningPage() {
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: ScanTicket;
try {
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
} catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return;
@@ -208,13 +220,19 @@ export default function TicketScanningPage() {
}
}
// Compute remaining qty
// Multi-day events: no session open for check-in now (wrong day, cancelled…).
if (preview?.sessionError) {
setErrorModal(preview.sessionError);
return;
}
// Compute remaining qty (the server counts it per session for multi-day events)
const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed;
const remaining = typeof preview?.remaining === "number" ? preview.remaining : ticketQty - totalRedeemed;
if (remaining <= 0) {
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
setAlreadyUsedModal({ message: preview?.session ? `Already checked in for ${preview.session.label}` : 'Ticket has already been fully used', ticket: preview });
return;
}
@@ -236,7 +254,7 @@ export default function TicketScanningPage() {
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name;
const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
const eventTitle = ticket?.event?.title || 'Event';
const eventTitle = `${ticket?.event?.title || 'Event'}${ticket?.session ? ` — ${ticket.session.label}` : ''}`;
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
const newRemaining = remaining - qty;
@@ -276,6 +294,7 @@ export default function TicketScanningPage() {
qty,
deviceId: offline.deviceId(),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
@@ -378,6 +397,23 @@ export default function TicketScanningPage() {
</div>
)}
{eventSessions.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Session</label>
<select
className="border rounded px-3 py-2 w-full sm:w-72 max-w-full"
value={selectedSessionId}
onChange={(e) => setSelectedSessionId(e.target.value)}
disabled={!!offline.lock}
>
<option value="auto">Today&apos;s session{currentSessionLabel(selectedEvent) ? ` (${currentSessionLabel(selectedEvent)})` : ""}</option>
{eventSessions.map(s => (
<option key={s.id} value={s.id}>{sessionLabel(s, selectedEvent?.sessions)} — {formatSessionWhen(s)}</option>
))}
</select>
</div>
)}
<OfflineScanPanel
offline={offline}
token={token}
@@ -467,6 +503,7 @@ 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>
{confirmModal.ticket?.session && <div><span className="font-medium">Session:</span> {confirmModal.ticket.session.label}</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>
@@ -14,6 +14,9 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -87,12 +90,6 @@ function initQuantities(eventOptions: EventOption[]): Record<string, number> {
return map;
}
function ticketLabel(t: TicketRecord): string {
const opt = t.registrationOption?.eventOption?.name || "Ticket";
const variant = t.registrationOption?.variant?.name;
return variant ? `${opt} — ${variant}` : opt;
}
export default function AtTheDoorPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -100,6 +97,11 @@ export default function AtTheDoorPage() {
const [eventId, setEventId] = useState("");
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
// "Pick sessions" events: the event (for its sessions) and the sessions chosen per
// quantity key. Picked sessions are booked on new registrations only.
const [doorEvent, setDoorEvent] = useState<Event | null>(null);
const [picks, setPicks] = useState<Record<string, string[]>>({});
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
@@ -162,12 +164,16 @@ export default function AtTheDoorPage() {
authToken: token
});
setEventOptions(ev.options || ev.eventOptions || []);
const all = ev.options || ev.eventOptions || [];
setDoorEvent(ev);
setEventOptions(all);
setDoorHasPickOptions(choosesSessions(ev));
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
setDoorBundles(ev.bundles || []);
} catch {
setDoorEvent(null);
setEventOptions([]);
setDoorBundles([]);
}
@@ -211,6 +217,10 @@ export default function AtTheDoorPage() {
}, [activeRegistration]);
// Charged per session / has sessions to tick (every ticket on a "pick sessions" event).
const isPick = (optionId: string) => isPickOption(doorEvent, eventOptions.find(o => o.id === optionId));
const choosing = choosesSessions(doorEvent);
const confirmRegistration = async () => {
if (confirming) return;
@@ -218,13 +228,18 @@ export default function AtTheDoorPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choosing && !pendingEditReg ? { sessionIds: picks[key] || [] } : {}) };
});
if (!opts.length) {
setError("Select at least one item");
return;
}
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
if (noSessions) {
setError(`Choose the sessions for "${eventOptions.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`);
return;
}
for (const [key, minQty] of Object.entries(minQuantities)) {
if (minQty > 0 && (quantities[key] || 0) < minQty) {
@@ -242,6 +257,8 @@ export default function AtTheDoorPage() {
if (missing) { setError(missing); return; }
}
// Picked-session lines take no names.
const nameOptions = eventOptions.filter(o => !isPick(o.id));
setConfirming(true);
try {
if (pendingEditReg) {
@@ -284,8 +301,8 @@ export default function AtTheDoorPage() {
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
...(doorAllowsNames && (doorNaming || doorYouthStart) && 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 }
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
: {}),
}
});
@@ -309,16 +326,20 @@ export default function AtTheDoorPage() {
// Called when NewAttendeeModal is confirmed — proceed to options selection
const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => {
const qtyMap = initQuantities(eventOptions);
// Default main ticket to 1 (first variant if variants exist)
const pickMap: Record<string, string[]> = {};
// Default main ticket to 1 (first variant if variants exist). On a "pick sessions"
// event with a session today that still has room, that's one place in today's session.
const today = todaysSession(doorEvent);
const todayPick = today && doorEvent && choosing ? eventOptions.find(o => o.isMainTicket
&& bookableSessions(doorEvent, o, true).some(s => s.id === today.id && (s.left === null || s.left > 0))) : undefined;
eventOptions.forEach(o => {
if (!o.isMainTicket) return;
if ((o.variants || []).length > 0) {
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
} else {
qtyMap[o.id] = 1;
}
if (!o.isMainTicket || (todayPick ? o !== todayPick : choosing)) return;
const key = (o.variants || []).length > 0 ? `${o.id}::${(o.variants || [])[0].id}` : o.id;
qtyMap[key] = 1;
if (o === todayPick && today) pickMap[key] = [today.id];
});
setQuantities(qtyMap);
setPicks(pickMap);
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
@@ -343,8 +364,8 @@ export default function AtTheDoorPage() {
return;
}
const qtyMap = initQuantities(eventOptions);
const regOptions = freshReg.registrationOptions || [];
const qtyMap = initQuantities(eventOptions.filter(o => !isPick(o.id)));
const regOptions = (freshReg.registrationOptions || []).filter(ro => !ro.sessionId);
regOptions.forEach((ro) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
@@ -359,6 +380,7 @@ export default function AtTheDoorPage() {
});
setQuantities(qtyMap);
setPicks({});
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
@@ -500,7 +522,8 @@ export default function AtTheDoorPage() {
<OptionsModal
open={showOptionsModal}
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
options={eventOptions}
options={pendingEditReg ? eventOptions.filter(o => !isPick(o.id)) : eventOptions}
pick={doorEvent && doorHasPickOptions ? { event: doorEvent, picks, setPicks } : null}
quantities={quantities}
setQuantities={setQuantities}
minQuantities={minQuantities}
@@ -915,61 +938,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s
}
};
const print = (ticket: TicketRecord) => {
const w = window.open("", "_blank");
if (!w) return;
const eventTitle = ticket.event?.title || ticket.eventId || "Event";
const eventDate = ticket.event?.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = eventDate ? eventDate.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric" }) : "";
const type = ticketLabel(ticket);
const qty = ticket.quantity || 1;
const holder = ticket.user?.name || ticket.userId || "";
const qrData = encodeURIComponent(ticket.qrCode || ticket.id);
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${qrData}`;
w.document.write(`<!doctype html><html><head><title>Ticket</title>
<style>
@page { size: A6; margin: 6mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.ticket { border: 1.5px solid #222; border-radius: 3mm; padding: 5mm; height: calc(148mm - 12mm); display: flex; flex-direction: column; justify-content: space-between; }
.evt { font-weight: bold; font-size: 15pt; line-height: 1.2; }
.date { font-size: 10pt; color: #555; margin-top: 2mm; }
.type { font-size: 12pt; margin-top: 2mm; color: #222; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1; }
.qrwrap img { width: 55mm; height: 55mm; display: block; }
.meta { font-size: 9pt; text-align: center; color: #666; word-break: break-all; }
</style>
</head><body>
<div class="ticket">
<div>
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${ticket.id}</div>
</div>
<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const print = (ticket: TicketRecord) => printTicketCards([ticket], (t) => t.user?.name);
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
@@ -1104,10 +1073,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
);
}
// Multi-day events: check-in is per session, so only today's session's check-ins count.
const session = todaysSession(ticketsForEvent[0]?.event);
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = (t.usages || [])
.filter(u => !session || u.sessionId === session.id)
.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
@@ -1139,7 +1112,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
<div className="text-xl font-semibold mb-4">Check-In{session ? <span className="ml-2 text-sm font-normal text-gray-500">{session.label || "Today's session"}</span> : null}</div>
{/* ✅ User */}
<div className="mb-4">
@@ -1237,15 +1210,30 @@ type OptionsModalProps = {
bundles: PublicBundle[];
// Named-attendee editor settings, or null when the event doesn't take names.
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
// A "pick sessions" event: its sessions and the sessions chosen per quantity key.
pick: { event: Event; picks: Record<string, string[]>; setPicks: React.Dispatch<React.SetStateAction<Record<string, string[]>>> } | null;
};
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names, pick }: OptionsModalProps) {
const pickOpt = (optionId: string) => !!pick && isPickOption(pick.event, (options || []).find(o => o.id === optionId));
// Sessions are ticked on new bookings only (edits leave booked sessions as they are).
const choiceOpt = (optionId: string) => !!pick && !isEdit && (options || []).some(o => o.id === optionId);
// A picked option is priced per session.
const times = (key: string) => (pick && pickOpt(key.split("::")[0]) ? (pick.picks[key] || []).length : 1);
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
return { eventOptionId, quantity, variantId: variantId || null, ...(pick && choiceOpt(eventOptionId) ? { sessionIds: pick.picks[key] || [] } : {}) };
});
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const picker = (key: string, option: EventOption) => {
if (!pick || !choiceOpt(option.id)) return null;
const qty = quantities[key] || 0;
return <SessionPicker event={pick.event} option={option} staff quantity={Math.max(1, qty)} selected={qty > 0 ? pick.picks[key] || [] : []}
onChange={ids => { pick.setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const perSession = (o: EventOption) => (pickOpt(o.id) ? " per session" : "");
// Bundle deals apply to the whole registration, so this holds for edits too.
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
@@ -1256,9 +1244,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const total = safeOptions.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
}, 0);
const afterBundles = bundleQuote ? bundleQuote.total : total;
@@ -1279,6 +1267,11 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="text-xl font-semibold">
{isEdit ? "Edit Registration" : "Select Items"}
</div>
{isEdit && pick && (
<div className="text-sm text-gray-500 mt-1">
Sessions already booked stay as they are. To add sessions, book them as a new registration.
</div>
)}
{isEdit && totalPaid > 0 && (
<div className="text-sm text-gray-500 mt-1">
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> — new total must be at least this amount.
@@ -1310,11 +1303,12 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const key = `${opt.id}::${v.id}`;
const minQty = minQuantities[key] || 0;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
<div key={v.id} className="px-4 py-2.5 border-b last:border-b-0">
<div className="flex items-center justify-between">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
@@ -1325,6 +1319,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
{picker(key, opt)}
</div>
);
})}
</div>
@@ -1336,7 +1332,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div key={opt.id} className="border rounded-xl p-3">
<div className="font-medium">{opt.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
@@ -1346,6 +1342,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
{picker(opt.id, opt)}
</div>
);
})}
@@ -1366,7 +1363,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{names && (
<div className="mb-3 max-h-64 overflow-y-auto">
<AttendeeNamesEditor
options={safeOptions}
options={safeOptions.filter(o => !pickOpt(o.id))}
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
{...names}
payerLabel="the person registering"
@@ -0,0 +1,296 @@
"use client";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
const colDate = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
function AttendancePageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = ["admin", "supervisor", "staff"].includes(user?.role || "");
const canManage = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("eventId") || "");
const [reg, setReg] = useState<AttendanceRegister | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [showCancelled, setShowCancelled] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
// Multi-day events first (that's what the register is mostly for), newest first.
const list = (evs || []).filter(e => e.requiresRegistration !== false)
.sort((a, b) => Number(b.scheduleType === "sessions") - Number(a.scheduleType === "sessions") || new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setEvents(list);
if (!eventId && list.length) setEventId(list[0].id);
})
.catch(e => setError(errorMessage(e) || "Failed to load events"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, canView]);
const load = useCallback(async () => {
if (!token || !eventId) return;
setBusy(true);
try {
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Failed to load the register");
} finally {
setBusy(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const columns = useMemo(() => (reg?.columns || []).filter(c => showCancelled || !c.cancelled), [reg, showCancelled]);
const multi = reg?.event.scheduleType === "sessions";
// Sessions already held (for "missed the last two").
const heldIds = useMemo(() => (reg?.columns || []).filter(c => !c.cancelled && new Date(c.startsAt).getTime() < Date.now()).map(c => c.id), [reg]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return (reg?.rows || []).filter(r => {
if (q && ![r.name, r.bookedBy, r.email, r.phone].some(v => v?.toLowerCase().includes(q))) return false;
if (filter === "completed") return r.completed;
if (filter === "not_completed") return !r.completed;
if (filter === "missed_recent") {
const lastTwo = heldIds.slice(-2);
return lastTwo.length > 0 && lastTwo.every(id => !r.checkIns[id]);
}
return true;
});
}, [reg, query, filter, heldIds]);
const toggle = async (row: AttendanceRow, sessionId: string) => {
if (!canManage || !token) return;
const present = !row.checkIns[sessionId];
const key = `${row.ticketId}:${sessionId}`;
setPending(key);
try {
// Picked sessions have a ticket per session.
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
const col = reg?.columns.find(c => c.id === sessionId);
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
await load();
} catch (e) {
setError(errorMessage(e) || "Couldn't update attendance");
} finally {
setPending(null);
}
};
const post = async (path: string, body: object, done: (r: Record<string, number>) => string) => {
if (!token) return;
setBusy(true);
try {
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
setInfo(done(r));
await load();
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
};
const download = async (path: string, filename: string) => {
if (!token) return;
try {
saveBlob(await fetchApiFile(API_BASE, token, path), filename);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
};
const safeTitle = (reg?.event.title || "event").replace(/[^a-z0-9 -]/gi, "").slice(0, 60);
const certsOn = !!reg?.event.certificatesEnabled;
const issued = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt).length;
const unsent = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt && !r.certificate.emailedAt).length;
const completed = (reg?.rows || []).filter(r => r.completed).length;
return (
<div className="max-w-7xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Attendance</h1>
<p className="text-sm text-gray-500">Who came to each session, and completion certificates</p>
</div>
</div>
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view attendance.</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 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input className="border rounded-lg pl-8 pr-3 py-2 text-sm w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as Filter)}>
<option value="all">Everyone</option>
<option value="completed">Completed</option>
<option value="not_completed">Not completed</option>
{multi && <option value="missed_recent">Missed the last two sessions</option>}
</select>
{multi && (reg?.columns || []).some(c => c.cancelled) && (
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showCancelled} onChange={e => setShowCancelled(e.target.checked)} /> Show cancelled sessions
</label>
)}
<div className="ml-auto flex flex-wrap gap-2">
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
</div>
</div>
{reg && (
<div className="flex flex-wrap gap-2 text-xs">
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700">{reg.rows.length} attendee{reg.rows.length === 1 ? "" : "s"}</span>
<span className="px-2 py-1 rounded bg-green-50 text-green-800">{completed} completed</span>
{certsOn && <span className="px-2 py-1 rounded bg-brand-50 text-brand-800">{issued} certificate{issued === 1 ? "" : "s"} issued</span>}
{!multi && <span className="px-2 py-1 rounded bg-gray-50 text-gray-500">Single-date event — one check-in column</span>}
</div>
)}
{reg && certsOn && canManage && (
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
<Award className="w-4 h-4 text-brand-600" />
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
</div>
)}
{busy && !reg && <p className="text-sm text-gray-500">Loading…</p>}
{reg && rows.length === 0 && <p className="text-sm text-gray-500 py-6 text-center">{reg.rows.length === 0 ? "No tickets for this event yet." : "Nobody matches."}</p>}
{reg && rows.length > 0 && (
<div className="overflow-x-auto border rounded-lg">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-600">
<tr>
<th className="sticky left-0 z-10 bg-gray-50 text-left font-medium px-3 py-2 min-w-[12rem]">Name</th>
{columns.map(c => (
<th key={c.id} className={`font-medium px-2 py-2 text-center whitespace-nowrap ${c.cancelled ? "text-gray-400 line-through" : ""}`}>
<div>{c.label}</div>
<div className="text-[10px] font-normal text-gray-400">{multi ? colDate.format(new Date(c.startsAt)) : ""}</div>
</th>
))}
<th className="font-medium px-2 py-2 text-center">Total</th>
{certsOn && <th className="font-medium px-3 py-2 text-left">Certificate</th>}
</tr>
</thead>
<tbody className="divide-y">
{rows.map(r => (
<tr key={r.ticketId} className="hover:bg-gray-50/60">
<td className="sticky left-0 z-10 bg-white px-3 py-2">
<div className="font-medium text-gray-900">{r.name || "—"}{r.quantity > 1 && <span className="ml-1 text-xs text-gray-500">×{r.quantity}</span>}</div>
<div className="text-[11px] text-gray-500">{r.variant ? `${r.option} — ${r.variant}` : r.option}{r.bookedBy ? ` · booked by ${r.bookedBy}` : ""}</div>
</td>
{columns.map(c => {
const qty = r.checkIns[c.id] || 0;
const key = `${r.ticketId}:${c.id}`;
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
}
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
return (
<td key={c.id} className="px-2 py-2 text-center">
<button type="button" disabled={disabled} onClick={() => toggle(r, c.id)}
title={canManage ? (qty ? "Present — click to mark absent" : "Absent — click to mark present") : undefined}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border text-xs ${qty ? "bg-green-50 border-green-300 text-green-700" : "bg-white border-gray-200 text-gray-300"} ${disabled ? "cursor-default" : "hover:border-brand-400"} ${pending === key ? "opacity-50" : ""}`}>
{qty ? (r.quantity > 1 ? qty : <Check className="w-4 h-4" />) : "·"}
</button>
</td>
);
})}
<td className="px-2 py-2 text-center">
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${r.completed ? "bg-green-50 text-green-800" : "text-gray-600"}`}>{r.attended}/{r.total}</span>
</td>
{certsOn && (
<td className="px-3 py-2 whitespace-nowrap">
{r.certificate && !r.certificate.revokedAt ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700">{r.certificate.code}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
</div>
) : canManage ? (
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
</button>
) : <span className="text-xs text-gray-400">—</span>}
</td>
)}
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 text-xs text-gray-600">
<tr>
<td className="sticky left-0 bg-gray-50 px-3 py-2 font-medium">Present</td>
{columns.map(c => <td key={c.id} className="px-2 py-2 text-center font-medium">{c.cancelled ? "—" : reg.totals[c.id] || 0}</td>)}
<td />
{certsOn && <td />}
</tr>
</tfoot>
</table>
</div>
)}
{reg && canManage && (
<p className="text-[11px] text-gray-400">Click a box to mark someone present or absent (e.g. they were let in without a scan). Changes are recorded in the audit log.</p>
)}
</div>
)}
</div>
);
}
export default function AttendancePage() {
return (
<Suspense fallback={null}>
<AttendancePageInner />
</Suspense>
);
}
@@ -5,8 +5,7 @@ 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";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
@@ -17,6 +16,7 @@ type EventLite = {
id: string;
title: string;
startDate: string;
endDate?: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
@@ -122,17 +122,19 @@ function bodyFromDraft(d: Draft) {
};
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
function couponStatus(c: Coupon): { label: string; cls: string } {
type Status = { key: "active" | "scheduled" | "off" | "expired" | "used"; label: string; cls: string };
function couponStatus(c: Coupon): Status {
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" };
if (!c.active) return { key: "off", label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { key: "expired", label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.maxUses && c.uses >= c.maxUses) return { key: "used", label: "Used up", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { key: "scheduled", label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
return { key: "active", label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
@@ -151,7 +153,7 @@ export default function CouponsPage() {
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
@@ -175,10 +177,17 @@ export default function CouponsPage() {
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
// Codes that can still be used first; off, expired and used-up ones only when asked for.
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 order: Record<Status["key"], number> = { active: 0, scheduled: 1, used: 2, expired: 3, off: 4 };
return coupons
.map(c => ({ c, status: couponStatus(c) }))
.filter(({ c, status }) => (showInactive || status.key === "active" || status.key === "scheduled")
&& (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q) || c.events.some(e => e.event.title.toLowerCase().includes(q))))
.sort((a, b) => order[a.status.key] - order[b.status.key]);
}, [coupons, query, showInactive]);
const hiddenCount = coupons.filter(c => !["active", "scheduled"].includes(couponStatus(c).key)).length;
const toggleActive = async (c: Coupon) => {
try {
@@ -199,9 +208,11 @@ export default function CouponsPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft() });
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="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<TicketPercent className="w-5 h-5 text-brand-600" />
@@ -214,8 +225,8 @@ export default function CouponsPage() {
</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"
onClick={startNew}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New code
</button>
@@ -230,72 +241,46 @@ export default function CouponsPage() {
{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">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:w-72">
<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)} />
<input className={`${inputCls} pl-8`} placeholder="Search codes or events" 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
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
Show codes that are off, expired or used up{hiddenCount > 0 ? ` (${hiddenCount})` : ""}
</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>
{!fetching && visible.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center mx-auto mb-3">
<TicketPercent className="w-6 h-6 text-brand-600" />
</div>
<p className="text-sm font-medium text-gray-900">{coupons.length === 0 ? "No coupon codes yet" : "No codes match"}</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;STAFF50&quot;: 50% off for staff, or &quot;WELCOME20&quot;: R20 off for first-time visitors.</p>
{coupons.length === 0 && (
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a code
</button>
)}
</div>
)}
{visible.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{visible.map(({ c, status }) => (
<CouponCard
key={c.id}
coupon={c}
status={status}
onUses={() => openUses(c)}
onEdit={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}
onToggle={() => toggleActive(c)}
/>
))}
</div>
)}
</div>
)}
@@ -311,47 +296,119 @@ export default function CouponsPage() {
)}
{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>
<Sheet title={<>Uses of <span className="font-mono">{viewing.coupon.code}</span></>} onClose={() => setViewing(null)}>
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<ul className="divide-y">
{viewing.redemptions.map(r => (
<li key={r.id} className={`py-2.5 flex items-start justify-between gap-3 ${r.releasedAt ? "opacity-60" : ""}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{r.user.name}</div>
<div className="text-xs text-gray-500 truncate">{r.user.email}</div>
<div className="text-xs text-gray-500">{r.registration.event.title} · {fmtDate(r.createdAt)}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold">{fmtRand(r.discountAmount)}</div>
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</div>
</li>
))}
</ul>
)}
</Sheet>
)}
</div>
);
}
/** A dialog that slides up full-width on phones and sits centred on bigger screens. */
function Sheet({ title, subtitle, onClose, children, footer, onSubmit }: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
onSubmit?: (e: React.FormEvent) => void;
}) {
const Box = onSubmit ? "form" : "div";
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<Box onSubmit={onSubmit} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto overflow-x-hidden overscroll-contain">{children}</div>
{footer && <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">{footer}</div>}
</Box>
</div>
);
}
function CouponCard({ coupon: c, status, onUses, onEdit, onToggle }: { coupon: Coupon; status: Status; onUses: () => void; onEdit: () => void; onToggle: () => void }) {
const restricted = c.allowedUsers.length
? `${c.allowedUsers.length} ${c.allowedUsers.length === 1 ? "person" : "people"}`
: c.restrictRoles.length ? c.restrictRoles.map(r => (r === "user" ? "members" : r)).join(", ") : c.restrictDomain ? `@${c.restrictDomain}` : null;
const dates = c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end date"}` : null;
const limits = [
c.maxUses ? `${c.maxUses} uses in total` : null,
c.maxUsesPerUser ? `${c.maxUsesPerUser} per person` : null,
c.minQuantity ? `min ${c.minQuantity} tickets` : null,
].filter(Boolean).join(" · ");
const live = status.key === "active" || status.key === "scheduled";
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${live ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-mono font-semibold text-gray-900 break-all">{c.code}</div>
{c.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{c.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span className="text-xl font-semibold text-gray-900">{c.summary}</span>
{c.options.length > 0 && (
<span className="text-xs text-gray-500">on {c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{c.events.length === 0
? <span className="rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs">All events</span>
: c.events.map(e => (
<span key={e.eventId} className="rounded-full bg-brand-50 text-brand-800 border border-brand-100 px-2.5 py-1 text-xs break-words">{e.event.title}</span>
))}
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>Used <strong>{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}×</strong> · {fmtRand(c.totalDiscount)} given</div>
{restricted && <div className="flex items-center gap-1 text-amber-700"><Users className="w-3.5 h-3.5 shrink-0" /> Only for {restricted}</div>}
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5 shrink-0" /> {dates}</div>}
{limits && <div>{limits}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onUses} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<ListChecks className="w-3.5 h-3.5" /> Uses
</button>
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 sm:ml-auto ${c.active ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {c.active ? "Turn off" : "Turn on"}
</button>
</div>
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
@@ -365,8 +422,22 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const [eventQuery, setEventQuery] = useState("");
const [showPast, setShowPast] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Upcoming events soonest first. Past events only when asked for, but ones already
// picked always stay in the list so they can be unticked.
const eventChoices = useMemo(() => {
const q = eventQuery.trim().toLowerCase();
const upcoming = events.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = events.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return [...upcoming, ...past]
.filter(e => showPast || !isPast(e) || d.eventIds.includes(e.id))
.filter(e => !q || e.title.toLowerCase().includes(q));
}, [events, eventQuery, showPast, d.eventIds]);
const pastCount = events.filter(isPast).length;
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
@@ -415,184 +486,200 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
}
};
const label = "block text-sm font-medium text-gray-700 mb-1";
const optional = <span className="font-normal text-gray-400">(optional)</span>;
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">
<Sheet
title={couponId ? "Edit code" : "New coupon code"}
onClose={onClose}
onSubmit={save}
footer={(
<>
<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 className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>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={label}>Description {optional}</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
<p className="text-xs text-gray-500 mt-1">Only staff see this.</p>
</div>
</div>
</form>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Discount</label>
<div className="grid grid-cols-3 gap-2">
{([["percent", "Percentage", "e.g. 20% off"], ["fixed", "Amount off", "e.g. R50 off"], ["comp", "Free", "100% off"]] as [CouponType, string, string][]).map(([t, name, hint]) => (
<button type="button" key={t} onClick={() => set("type", t)} aria-pressed={d.type === t}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.type === t ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{name}</div>
<div className="text-xs text-gray-500">{hint}</div>
</button>
))}
</div>
{d.type !== "comp" && (
<div className="mt-3 sm:max-w-[12rem]">
<label className={label}>{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" inputMode="decimal" 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>
)}
<label className="mt-3 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>
<label className={label}>Events</label>
<p className="text-xs text-gray-500 mb-2">
{d.eventIds.length === 0 ? "None picked: the code works for every event." : `${d.eventIds.length} picked. The code only works for ${d.eventIds.length === 1 ? "this event" : "these events"}.`}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Find an event" value={eventQuery} onChange={e => setEventQuery(e.target.value)} />
</div>
{pastCount > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 shrink-0">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{eventChoices.map(ev => (
<label key={ev.id} className={`flex items-center gap-2 px-3 py-2 text-sm ${d.eventIds.includes(ev.id) ? "bg-brand-50/60" : ""}`}>
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span className="min-w-0 flex-1 truncate">{ev.title}</span>
<span className="text-xs text-gray-500 shrink-0">{fmtDate(ev.startDate)}{isPast(ev) ? " · past" : ""}</span>
</label>
))}
{eventChoices.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">{eventQuery ? "No events match." : "No upcoming events."}</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className={label}>Only these tickets / items {optional}</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 bg-gray-50">
<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 py-0.5">
<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 py-0.5">
<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={label}>Valid from {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className={label}>Valid until {optional}</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={label}>Max total uses</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Max uses per person</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Minimum tickets</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className={label}>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-x-4 gap-y-2 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-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.active} onChange={e => set("active", e.target.checked)} />
<span>Active <span className="block text-xs text-gray-500">Discounts already given keep their amount if you turn it off later.</span></span>
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
</Sheet>
);
}
@@ -587,7 +587,7 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> — the event title.</li>
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
<li><code>{'{{payment.link}}'}</code> — a direct online payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, API_BASE } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -13,6 +13,12 @@ import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import {
SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate,
type KeyedSession, type AttendanceDraft,
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +95,7 @@ type OptionDraft = {
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
sessionMode?: "all" | "pick"; // "pick sessions" events only
};
type SectionDraft = {
@@ -203,7 +210,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
// ─── Items & Pricing substep editors ────────────────────────────────────────
/** Substep 0 — bare option rows */
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
function OptionsEditor({ options, onChange, required, pickMode }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean; pickMode?: boolean }) {
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
const add = () => {
@@ -239,6 +246,15 @@ 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" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
{pickMode && (
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Pick sessions: bought per session — the price and stock are per session">Sessions</label>
<select className="border rounded px-2 py-1.5 text-sm" value={opt.sessionMode || "all"} onChange={e => upd(i, { sessionMode: e.target.value as "all" | "pick" })}>
<option value="all">All sessions</option>
<option value="pick">Pick sessions (price per session)</option>
</select>
</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
@@ -803,8 +819,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
// ─── steps ───────────────────────────────────────────────────────────────────
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback", "Attendance"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
type PricingSubstep = 0 | 1 | 2;
@@ -819,6 +835,8 @@ interface EventDraft {
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
allowTicketTransfer: boolean; collectGender: boolean;
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
scheduleType: "single" | "sessions";
attendanceMode: "whole" | "pick";
}
const blankDraft = (location = ""): EventDraft => ({
@@ -832,8 +850,13 @@ const blankDraft = (location = ""): EventDraft => ({
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
allowTicketTransfer: true, collectGender: false,
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
scheduleType: "single",
attendanceMode: "whole",
});
// "YYYY-MM-DDTHH:MM" (local) → ISO
const isoOf = (local: string) => new Date(local).toISOString();
const blankOptions = (): OptionDraft[] => [
{ name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] }
];
@@ -874,8 +897,37 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
// ── multi-day schedule + attendance settings ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
lateJoinEnabled: !!ev?.lateJoinEnabled,
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
latePricing: ev?.latePricing || "full",
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
checkInWindow: ev?.checkInWindow || "whole_day",
remindersEnabled: !!ev?.sessionReminders,
reminderOffsets: ev?.sessionReminders?.offsets || [180],
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!ev?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
const pickMode = multi && draft.attendanceMode === "pick";
// A multi-session event's dates come from its sessions.
const scheduleBounds = useMemo(() => {
const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt);
if (!active.length) return null;
const end = active.reduce((m, s) => (s.endsAt > m ? s.endsAt : m), active[0].endsAt);
return { start: active[0].startsAt, end };
}, [sessions]);
// Shown after saving when sessions moved or were cancelled, to offer telling registrants.
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions();
@@ -888,6 +940,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
sessionMode: o.sessionMode || "all",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t) => !t.variantId)
@@ -951,8 +1004,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{ type: "text", label: "What could we do better?", isRequired: false, helpText: "" },
] });
}
if (on && !fbSettings.sendAt && draft.endDate) {
const next = new Date(draft.endDate);
const endForFeedback = multi ? scheduleBounds?.end : draft.endDate;
if (on && !fbSettings.sendAt && endForFeedback) {
const next = new Date(endForFeedback);
next.setDate(next.getDate() + 1);
next.setHours(9, 0, 0, 0);
setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) }));
@@ -1036,7 +1090,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update option + variants first to get/confirm variant IDs
const updated = await apiFetch<EventOption>(`/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"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, 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, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -1068,7 +1122,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created = await apiFetch<EventOption>(`/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"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
if (created?.id) {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
@@ -1115,13 +1169,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"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = existingMain.id;
} else {
const created = await apiFetch<EventOption>(`/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"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = created?.id;
}
@@ -1176,16 +1230,36 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (multi) {
const problem = sessionsProblem(sessions);
if (problem) { setError(problem); setStep(0); return; }
if (attendance.lateJoinEnabled && !attendance.lateJoinUntilKey) { setError("Choose until which session late joining is allowed"); setStep(8); return; }
if (attendance.lateJoinEnabled && attendance.latePricing === "fixed" && attendance.lateFixedPrice.trim() === "") { setError("Enter the fixed late price"); setStep(8); return; }
} else if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
}
setSaving(true); setError(null);
try {
const sortedSessions = sortSessions(sessions);
const body: Record<string, unknown> = {
title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
startDate: multi && scheduleBounds ? isoOf(scheduleBounds.start) : new Date(draft.startDate).toISOString(),
endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(),
scheduleType: draft.scheduleType,
...(multi ? {
attendanceMode: draft.attendanceMode,
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled, capacity: pickMode && s.capacity.trim() ? parseInt(s.capacity, 10) : null })),
lateJoinEnabled: attendance.lateJoinEnabled,
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
latePricing: attendance.latePricing,
lateFixedPrice: attendance.latePricing === "fixed" ? attendance.lateFixedPrice : null,
checkInWindow: attendance.checkInWindow,
sessionReminders: attendance.remindersEnabled ? { offsets: attendance.reminderOffsets, channels: attendance.reminderChannels } : null,
} : {}),
certificatesEnabled: attendance.certificatesEnabled,
certificateConfig: attendance.certificate,
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
@@ -1212,13 +1286,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
location: draft.location.trim(),
};
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
const saved = await apiFetch<Event & { sessionChanges?: { moved: number; cancelled: number } | null }>(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id);
const ch = saved?.sessionChanges;
if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled };
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
@@ -1247,7 +1324,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}
}
onSuccess(); onClose();
onSuccess();
if (notice) setChangedNotice(notice);
else onClose();
} catch (e) {
setError(errorMessage(e) || "Failed to save event");
} finally {
@@ -1282,7 +1361,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || (multi ? !!sessionsProblem(sessions) : (!draft.startDate || !draft.endDate)) || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
// Not applicable to contact-only events, which have no ticket options at all.
@@ -1339,6 +1418,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div>
)}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{changedNotice && (
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm space-y-2">
<p>
Saved. You {[changedNotice.moved ? `moved ${changedNotice.moved} session${changedNotice.moved === 1 ? "" : "s"}` : "", changedNotice.cancelled ? `cancelled ${changedNotice.cancelled}` : ""].filter(Boolean).join(" and ")}.
Tickets and calendar files show the new schedule, but people who registered aren&apos;t told automatically.
</p>
<div className="flex gap-2">
<a href={`/dashboard/supervisor/email-attendees?eventId=${changedNotice.eventId}`} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700">Message attendees</a>
<button type="button" onClick={onClose} className="px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50">Done</button>
</div>
</div>
)}
{/* ── Step 0: Basic Details ── */}
{step === 0 && (
@@ -1351,10 +1442,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<label className="block text-xs text-gray-600 mb-1">Description</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} />
</div>
<div>
<div className="block text-xs text-gray-600 mb-1">When <span className="text-red-500">*</span></div>
<div className="inline-flex rounded-lg border p-0.5 bg-gray-50">
{([["single", "One date"], ["sessions", "Multiple sessions"]] as const).map(([v, label]) => (
<button key={v} type="button" onClick={() => upd({ scheduleType: v })}
className={`px-3 py-1.5 text-xs rounded-md ${draft.scheduleType === v ? "bg-white shadow-sm text-brand-700 font-medium" : "text-gray-600 hover:text-gray-900"}`}>{label}</button>
))}
</div>
{multi && <p className="text-[10px] text-gray-400 mt-1">One registration and one ticket for every session, e.g. every Wednesday for 5 weeks. Check-in is per session.</p>}
</div>
{multi ? (
<>
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} showCapacity={pickMode} />
<div>
<div className="block text-xs text-gray-600 mb-1">How people book</div>
<div className="space-y-1">
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" checked={draft.attendanceMode === "whole"} onChange={() => upd({ attendanceMode: "whole" })} /><span><span className="font-medium">The whole course</span><span className="block text-xs text-gray-500">One ticket for every session.</span></span></label>
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" disabled={draft.requiresParentalConsent} checked={draft.attendanceMode === "pick"} onChange={() => upd({ attendanceMode: "pick" })} /><span><span className="font-medium">Pick sessions</span><span className="block text-xs text-gray-500">People choose which sessions to come to (e.g. day passes or a drop-in class). Set which tickets are per session in Items &amp; Pricing, and an optional capacity per session above.{draft.requiresParentalConsent ? " Not available on youth events." : ""}</span></span></label>
</div>
</div>
</>
) : (
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<div>
@@ -1415,7 +1531,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{step === 1 && pricingSubstep === 0 && (
<div>
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} pickMode={pickMode} />
</div>
)}
{step === 1 && pricingSubstep === 1 && (
@@ -1431,6 +1547,22 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div>
)}
{/* ── Step 8: Attendance (late joining, check-in, reminders, certificates) ── */}
{step === 8 && (
<AttendanceSettings
value={attendance}
onChange={updAttendance}
multi={multi}
sessions={sessions}
mainPrice={parseFloat(options.find(o => o.isMainTicket)?.price || draft.price || "0") || 0}
uploadButton={(onUploaded) => <UploadImageButton onUploaded={onUploaded} label="Upload" />}
onPreview={async () => {
if (!token) return;
openBlob(await fetchApiFile(API_BASE, token, "/api/attendance/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
}}
/>
)}
{/* ── Step 2: Sections ── */}
{step === 2 && (
<div>
@@ -1714,7 +1846,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
{scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
@@ -12,6 +12,8 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -268,29 +270,53 @@ export default function ManualRegistrationPage() {
}
}, [selectedEventId, events]);
// The events list doesn't carry bundle deals; the single-event response does.
// The events list doesn't carry bundle deals or places per session; the single-event
// response does.
const [fullEvent, setFullEvent] = useState<Event | null>(null);
useEffect(() => {
if (!selectedEventId) { setBundles([]); return; }
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setBundles([]); });
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
return () => { cancelled = true; };
}, [selectedEventId, token]);
// "Pick sessions" options: the sessions chosen per quantity key.
const [picks, setPicks] = useState<Record<string, string[]>>({});
useEffect(() => { setPicks({}); }, [selectedEventId]);
const pickOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
};
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
const choiceOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && choosesSessions(fullEvent) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = choiceOpt(optionId);
if (!o || !fullEvent) return null;
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const qty = quantities[key] || 0;
return <SessionPicker event={fullEvent} option={o} staff quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
}, 0);
}, [options, quantities]);
}, [options, quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
}), [quantities]);
return { eventOptionId, quantity, variantId: variantId || null, ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
@@ -304,9 +330,11 @@ export default function ManualRegistrationPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
if (noSessions) { setError(`Choose the sessions for "${options.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`); return; }
if (selectedIsYouth) {
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
if (missing) { setError(missing); return; }
@@ -564,7 +592,8 @@ export default function ManualRegistrationPage() {
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
<div key={v.id} className="border-b last:border-b-0">
<div className="flex items-center justify-between px-3 py-2">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
@@ -582,6 +611,8 @@ export default function ManualRegistrationPage() {
/>
</div>
</div>
{picker(key, opt.id) && <div className="px-3 pb-2">{picker(key, opt.id)}</div>}
</div>
);
})}
</div>
@@ -605,6 +636,7 @@ export default function ManualRegistrationPage() {
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
{picker(opt.id, opt.id)}
</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, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} 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/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -35,16 +37,20 @@ function refundMethodForOriginal(method: string | null | undefined): string {
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
type YocoTx = {
// An unreconciled payment provider transaction (GET /api/provider-transactions/unreconciled).
// `metadata` is the checkout metadata: who/what the payment was for.
type ProviderTx = {
id: string;
provider: string;
providerLabel: string;
amount: number;
checkoutId?: string | null;
externalId?: string | null;
methodType?: string | null;
createdDate?: string | null;
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
metadata?: { userId?: string; eventId?: string } | null;
// Why it wasn't recorded automatically (e.g. paid a different amount from the checkout)
note?: string | null;
};
function RegistrationOptions({ regs, regOutstanding }: {
@@ -141,6 +147,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
function PaymentsContent() {
const { user, loading, token } = useAuth();
const online = useOnlinePayments();
const router = useRouter();
const search = useSearchParams();
@@ -278,7 +285,7 @@ function PaymentsContent() {
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
setLinkGenerating(true);
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/yoco-checkout", {
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
method: "POST",
authToken: token,
body: {
@@ -319,7 +326,7 @@ function PaymentsContent() {
setError(null); setInfo(null);
try {
setLinkSending(channel);
await apiFetch("/api/payments/yoco-checkout/send", {
await apiFetch("/api/payments/checkout/send", {
method: "POST",
authToken: token,
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
@@ -421,34 +428,34 @@ function PaymentsContent() {
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
// Yoco unreconciled list and actions
const [yocoLoading, setYocoLoading] = useState(false);
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
const loadYocoUnreconciled = useCallback(async () => {
// Unreconciled online payments (from payment providers) and actions
const [txLoading, setTxLoading] = useState(false);
const [providerTxs, setProviderTxs] = useState<ProviderTx[]>([]);
const loadUnreconciled = useCallback(async () => {
if (!token) return;
try {
setYocoLoading(true);
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
setTxLoading(true);
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setYocoTxs(items);
setProviderTxs(items);
} catch {
// ignore silently, shown on demand via error state if needed
} finally {
setYocoLoading(false);
setTxLoading(false);
}
}, [token]);
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
useEffect(() => { loadUnreconciled(); }, [loadUnreconciled]);
// Inline reconcile form state per-row
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
const openReconcileToRegistration = (tx: YocoTx) => {
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
const openReconcileToRegistration = (tx: ProviderTx) => {
const metaUser = tx.metadata?.userId ? String(tx.metadata.userId) : '';
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
};
const openReconcileAsDonation = (tx: YocoTx) => {
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
const openReconcileAsDonation = (tx: ProviderTx) => {
const metaEvent = tx.metadata?.eventId ? String(tx.metadata.eventId) : '';
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
};
@@ -467,7 +474,7 @@ function PaymentsContent() {
setReconcileForm(prev => ({ ...prev, submitting: true }));
if (type === 'reg') {
if (!registrationId) { setError('Please select a registration'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { registrationId }
@@ -475,7 +482,7 @@ function PaymentsContent() {
setInfo('Reconciled to registration');
} else {
if (!eventId) { setError('Please select an event'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { eventId, userId: userId || undefined }
@@ -483,7 +490,7 @@ function PaymentsContent() {
setInfo('Reconciled as donation');
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
await Promise.all([loadUnreconciled(), loadPayments(), loadRegistrations()]);
} catch (e) {
setError(errorMessage(e) || 'Failed to reconcile');
} finally {
@@ -537,31 +544,39 @@ function PaymentsContent() {
<ScanLine className="w-4 h-4" />
Reconcile
</label>
{/* Payment links are paid online, so only while a payment provider is on */}
{online.available && (
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" />
Payment Link
</label>
)}
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Yoco reconciliation panel */}
{mode === 'reconcile' && (
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
)}
{/* Online payment reconciliation panel */}
{mode === 'reconcile' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">Unreconciled Yoco Payments</div>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadYocoUnreconciled} disabled={yocoLoading}>{yocoLoading ? 'Refreshing…' : 'Refresh'}</button>
<div className="text-lg font-semibold">Unreconciled Online Payments</div>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadUnreconciled} disabled={txLoading}>{txLoading ? 'Refreshing…' : 'Refresh'}</button>
</div>
{yocoTxs.length === 0 ? (
<div className="text-sm text-gray-500">No unreconciled Yoco transactions.</div>
{providerTxs.length === 0 ? (
<div className="text-sm text-gray-500">No unreconciled online payments.</div>
) : (
<div className="overflow-x-auto w-full">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Created</th>
<th className="py-2 pr-3">Provider</th>
<th className="py-2 pr-3">External ID</th>
<th className="py-2 pr-3">Amount</th>
<th className="py-2 pr-3">Checkout</th>
@@ -570,10 +585,14 @@ function PaymentsContent() {
</tr>
</thead>
<tbody>
{yocoTxs.map(tx => (
{providerTxs.map(tx => (
<React.Fragment key={tx.id}>
<tr className="border-b hover:bg-gray-50">
<td className="py-2 pr-3 whitespace-nowrap">{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'}</td>
<td className="py-2 pr-3">
{tx.providerLabel}
{tx.note && <div className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 mt-1 max-w-[16rem]">{tx.note}</div>}
</td>
<td className="py-2 pr-3 font-mono text-xs break-all max-w-[10rem]">{tx.externalId}</td>
<td className="py-2 pr-3">R {(Number(tx.amount || 0)/100).toFixed(2)}</td>
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
@@ -586,9 +605,9 @@ function PaymentsContent() {
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
if (!ok) return;
try {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
setInfo('Transaction ignored');
await loadYocoUnreconciled();
await loadUnreconciled();
} catch (e) {
setError(errorMessage(e) || 'Failed to ignore');
}
@@ -597,7 +616,7 @@ function PaymentsContent() {
</tr>
{reconcileForm.txId === tx.id && (
<tr className="bg-gray-50">
<td colSpan={6} className="p-3">
<td colSpan={7} className="p-3">
{reconcileForm.type === 'reg' ? (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div>
@@ -749,7 +768,7 @@ function PaymentsContent() {
</>
)}
{mode === 'link' && (
{mode === 'link' && online.available && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment link</div>
<div className="grid sm:grid-cols-2 gap-3">

Some files were not shown because too many files have changed in this diff Show More