Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 96adf20cf0 Deploy script: roll back once (not inside subshells); docs; bump to 1.37.4
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 16:20:22 +02:00
joshuaandClaude Opus 5.5 a9c7a1129b Deploy: latest main without a tag; per-install PM2 config out of git
- scripts/deploy.sh with no tag deploys the latest main (detached
  origin/main); with a tag, that release.
- ecosystem.config.js is no longer in git (now ecosystem.config.example.js;
  the deploy creates the install's copy from it once). The deploy keeps the
  install's copy safe across checkouts and rollbacks, including releases
  that still tracked it.
- The script runs from a temp copy, so checking out a new deploy.sh can't
  change it mid-run; set -E so failures in functions still roll back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:15:29 +02:00
joshua d8eb9f22cc Merge branch 'fix/help-entities'
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:57:46 +02:00
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
joshuaandClaude Opus 5.5 4a492a8b16 Bump to 1.29.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 294fff0b41 Merge branch 'feat/bundle-types'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 f33d04a57c Add bundle types: pick any, buy X get Y free, group discount
Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for
a set price), free (cheapest freeCount in each set free) and group (setSize or more
qualifying tickets at a per-ticket price or % off, applied once). The supervisor page
has a type picker, buyers see a one-line description of each deal, and the Complete
bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 6b186b3481 Bump to 1.28.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:22 +02:00
joshuaandClaude Opus 5.5 7ba40163db Fix the bundle edit form's ticket rows
The quantity input had both w-full and w-16; w-full won, filling the row and pushing
the ticket picker off the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:21 +02:00
joshuaandClaude Opus 5.5 428b438251 Bump to 1.28.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:04 +02:00
joshuaandClaude Opus 5.5 6ce59af0ea Redesign the Bundle deals page
Upcoming events only by default (with a Show past events option), bundles as cards
instead of a table, and a mobile-friendly edit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:03 +02:00
joshuaandClaude Opus 5.5 53ed72d032 Add bundle deals to manual registration
Lists the event's deals and shows the Complete bundle / + cards, as at the door and
on the kiosk. Part of 1.28.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:32:17 +02:00
joshuaandClaude Opus 5.5 35d02c243b Bump to 1.28.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:49 +02:00
joshuaandClaude Opus 5.5 e2e6ec0e97 Merge branch 'feat/bundles-door-kiosk'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 92ab8065ef Add bundle deals to at the door and the kiosk
Both list the event's deals and show the Complete bundle / + cards from the register
form. At the door gets the standard Back to dashboard link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 084e3c6679 Bump to 1.28.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 de196e34f2 Merge branch 'feat/bundle-deals'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 763b66ff56 Add bundle deals
Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 8f92a8c3e9 Bump to 1.27.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:48 +02:00
joshua 6fc6c35214 Merge branch 'chore/demo-history' 2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 be1d6f2e31 Add ten completed events to the demo data
set-demo now also stages ten past events with door sales, staff cash
counts, check-ins, refunds, donations and feedback. Their cashups run
through the real cashup code after the insert and cover every path:
quick close, full settlements (card fees, deposit fee, shortfall, cash
on hand, an unaccounted amount), close-reopen-close, and pending EFT
money received later or still outstanding. One recent event is left
open with the cash counted for a live demo. Funnel sessions older than
90 days are rolled up into daily totals, as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 dc02f6b9ff Bump to 1.27.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:29 +02:00
joshua 103c328a6a Merge branch 'feat/cashup-settlement' 2026-10-21 13:29:28 +02:00
joshuaandClaude Opus 5.5 7446388bfa Take cash deposit fees out of the counted cash
A bank charge on a cash deposit comes out of cash that was counted and
banked, so cash on hand is now the count less what reached the bank and
any bank-charge write-off. Before, a deposit fee showed as money "more
than expected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:17 +02:00
joshuaandClaude Opus 5.5 8a4c144f6d Add cashup settlement and back links to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:19 +02:00
joshuaandClaude Opus 5.5 3a68b24a0f Settle cashups: where the money ended up
A full cashup now records, per payment method, how much of the expected
takings is in the bank, still on hand (cash) or pending (on the card
report or with a POP), and writes off what was lost to card fees, bank
charges or a shortfall. Whatever is left is shown as unaccounted; closing
with an unaccounted amount is allowed after a warning.

Pending money can be marked received or written off after the event is
closed, without reopening it. Reopening starts the next cashup from the
last one. Write-offs come off net profit and show as "Bank & card
charges" in the cashup, finance and profit reports.

The reports page also moves to the shared back link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 9be78c3c34 Use one back link style across the dashboards
Every dashboard page now uses the shared BackLink, the small
"← Back to dashboard" link above the page title that the Waitlist page
already had, instead of a mix of grey Back buttons in different places.
Pages that had no way back (site settings, profile, donate, manual
registration) get one too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 d268236686 Bump to 1.26.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:51 +02:00
joshua 95a8bd0796 Merge branch 'chore/demo-data' 2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 101632e758 Add set-demo script for staging demo data
npm run set-demo wipes the database, keeping the site settings and the
admin account, and stages six upcoming events with registrations,
payments, tickets, a waitlist, camp cabins, coupons, costs and funnel
stats for showing the platform to other churches. Documented in
backend/DEMO_DATA.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 7cf5f79d01 Merge branch 'chore/frontend-lint'
Includes chore/deps-security-prisma7 (Prisma 7 upgrade and npm audit fixes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 00464c086d Bump to 1.26.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:16:38 +02:00
joshuaandClaude Opus 5.5 4e07034bbd Add frontend lint cleanup to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:11:49 +02:00
joshuaandClaude Opus 5.5 2d6c840614 Type the reports page and turn lint back on in builds
ReportsV2 gets typed API responses (funnel, feedback summary, coupon
redemptions, cashup audit, consent) and derived rows. Master-order
quantities move into a per-row map, so an option name can no longer
overwrite a fixed column. Consent row types move to src/types. Removes
unused export helpers and state.

With the frontend now at zero lint errors and warnings, next build runs
ESLint again (eslint.ignoreDuringBuilds is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 235da5bf61 Type the at-the-door page and share pricing helpers
Each door panel and modal gets typed props; unused setError/setInfo/
onCreated props are dropped. The current early-bird price helpers move
to src/lib/pricing.ts (shared with manual registration), dead fallbacks
for a legacy registration 'options' field are removed, and the refund
history now shows a refund's reason (stored in its status field) instead
of a 'reason' field that never exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 f6581eb441 Type payments and user dashboard pages
Moves the duplicated registration-option pricing helper into
src/lib/pricing.ts and drops an unused users list from payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 0a8788afff Type supervisor forms, manual registration and events pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b560d0d340 Type registrations, email and WhatsApp messaging pages
Adds typed payloads and results for the email/WhatsApp attendee and
broadcast endpoints. The WhatsApp page's messageDirty flag becomes a ref
(it only gated the template effect), and a no-op branch in the email
template effect is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 b2439a781e Type more frontend pages and fix their hook dependencies
Cashup, users, profile, consent, allocations, event options, forms,
event tickets, ticket scanning and manual registration now use real
types. Data loaders are wrapped in useCallback and listed as effect
dependencies. Also adds shared Attendee, Section and form response
types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 545f3d913e Add shared API types and type the smaller frontend pages
Expand src/types with User, Event, EventOption, Registration, Payment,
Ticket, paginated results and dashboard stats, modelled on the Prisma
schema. apiFetch now defaults to unknown instead of any. Dashboard
loaders are wrapped in useCallback so their effects can list them as
dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:01 +02:00
joshuaandClaude Opus 5.5 619b5b9a93 Type caught errors as unknown in the frontend
Replace `catch (e: any)` with `catch (e)` and read the message through a
new errorMessage() helper. ApiError.data gets a real type, and
useDismissingState's setter keeps one identity across renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 958a5a5950 Fix frontend lint: links, unused code, quotes, img elements
- Internal links use next/link instead of <a> (no full page reload).
- Remove dead code: RegisterForm's unreachable Yoco checkout branch, the
  unused AttendeeForms on the registration success page, unused Geist
  fonts, and assorted unused variables and catch bindings.
- Escape quotes in JSX text; replace @ts-ignore in apiFetch with a cast.
- Keep the intentional raw <img> elements (data URLs, runtime API host)
  with a documented lint exception; the event editor uses ApiImage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:10:00 +02:00
joshuaandClaude Opus 5.5 f2be3a8b45 Upgrade to Prisma 7 and clear npm audit advisories
- Prisma 5.22 -> 7.10 using the @prisma/adapter-pg driver adapter; the
  datasource URL moves to backend/prisma.config.js. All clients are built
  in src/config/db.js, and index.js reuses the shared client.
- Backend: patch follow-redirects, drop nodemon for `node --watch`, and use
  js-yaml 4 for the Jest coverage loader. npm audit reports 0.
- Frontend: override postcss-selector-parser to 7.1.7 (Tailwind 3 output
  is unchanged). braces has no patched release and stays, build/lint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:58:11 +02:00
joshua ce22f44f7c Merge branch 'fix/mobile-cookie-banner' 2026-10-20 12:47:41 +02:00
joshuaandClaude Opus 5.5 30b567f091 Bump to 1.25.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:36 +02:00
joshuaandClaude Opus 5.5 0b22eca0fb Lift cookie banner and help button above mobile bottom nav
The cookie consent banner was fixed at bottom-0 under the z-50 BottomNav, so
on mobile it was hidden. It now sits above the nav (h-16 + safe-area) and
publishes its height as --cookie-banner-h, which HelpFab adds to its offset
so the banner no longer covers the help button either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:47:13 +02:00
joshua 5529811119 Merge branch 'feat/yoco-settings' 2026-10-20 10:28:57 +02:00
joshuaandClaude Opus 5.5 1f0b2b1425 Bump to 1.25.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:53 +02:00
joshuaandClaude Opus 5.5 865a16abe9 Store Yoco secrets in the database; add Yoco settings tab
The Yoco secret key and webhook secret now live in AppSetting (AES-256-GCM
encrypted), with .env as a fallback. A new Site Settings → Yoco tab shows
them masked card-style, registers a webhook with Yoco (storing the secret it
returns) or accepts one entered by hand, and lists/deletes the account's
webhooks. Generic settings endpoints never read or write these keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:50 +02:00
joshua 921b94a66a Merge branch 'feat/privacy-transfer-accept' 2026-10-19 18:46:43 +02:00
joshuaandClaude Opus 5.5 b3bfbe486c Bump to 1.24.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:25 +02:00
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
joshuaandClaude Opus 5.5 ccb4db919a Bump to 1.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:25:08 +02:00
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00
joshua fb8ce5f86d Merge branch 'feat/funnel-stats' 2026-10-19 18:14:29 +02:00
joshuaandClaude Opus 5.5 4cd89b1a60 Bump to 1.22.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:24 +02:00
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00
joshua 238fcd6fd7 Merge branch 'feat/feedback-forms' 2026-10-19 15:28:48 +02:00
267 changed files with 24150 additions and 5070 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'"
+9 -1
View File
@@ -31,6 +31,9 @@ yarn-error.log*
.DS_Store
Thumbs.db
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
/ecosystem.config.js
# misc scratch / generated files
temp/
backend/public/uploads/
@@ -39,4 +42,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/
+369
View File
@@ -7,6 +7,375 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.37.4] - 2026-10-23
### Fixed
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
## [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.
### Added
- Bundle deals now come in four types, chosen when you create one:
- **Exact set**: these tickets in these numbers for one price, e.g. 2 adults + 3 kids for R220. This is how bundles worked before.
- **Pick any**: any mix of the qualifying tickets for a set price, e.g. any 4 of Adult or Child for R250, so 2 + 2 and 4 + 0 both count.
- **Buy X get Y free**: in every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free.
- **Group discount**: from a group size up, every qualifying ticket costs less (a price per ticket or a percentage off), e.g. 10+ tickets: R70 each. It applies once, to the whole group, and never makes a ticket dearer.
- The deals list and the "Complete bundle" cards on the register form, at the door, manual registration and the kiosk describe each type in one line, e.g. "Any 4 of Adult, Child for R250". For a group discount the button reads "Complete group" and adds the tickets still needed.
- `npm run set-demo`: Family Fun Day also has a pick-any deal, "Lunch for four" (any 4 lunch packs for R220).
## [1.28.3] - 2026-10-21
### Fixed
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
## [1.28.2] - 2026-10-21
### Changed
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
## [1.28.1] - 2026-10-21
### Added
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
### Fixed
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
- At the door's "less than already paid" warning shows the total after bundle deals.
## [1.28.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- **Bundle deals**: sell a set of tickets together for one price, e.g. 5 Adult + 3 Child for R500 instead of R550. Supervisors set them up per event under Dashboard → **Bundle deals**: the tickets and quantities (one variant or any variant), the price, optional dates, and on/off.
- Bundles apply automatically, with no code, as many times as a registration's tickets allow; leftover tickets are charged as usual. A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included. When several compete, the one that saves the most is used first. This works for online registration, manual registration, at the door and on the kiosk.
- The event page and the register form list the event's deals, and every basket shows the saving live. On the register form, once a ticket from a bundle is picked, the bundle appears with what's missing and a **Complete bundle** button that tops up the ticket numbers. Once it's complete, **+** adds another full set. Invoices, receipts, emails, WhatsApp messages and the Yoco payment page show it as its own line, e.g. "Bundle: Family deal ×2".
- The Discounts report lists bundle deals next to coupon codes. Each bundle shows how often it's been used and the total saving given.
- `npm run set-demo`: Family Fun Day sells a **Family deal** bundle (2 adults + 3 kids for R220) in place of the old Family pass ticket.
### Changed
- Coupon codes now work out after bundle deals, on what's left. Registration.discountTotal now holds bundle and coupon discounts together, with the bundle part in the new `bundleDiscount`.
- Removing a code from a registration keeps its bundle deal. When an early-bird tier expires before payment and prices change, bundle deals are worked out again and coupon amounts are kept.
- Registrations keep a bundle they already have, even after it's turned off or its dates pass. Deleting a bundle that has been used turns it off instead.
## [1.27.1] - 2026-10-21
### Added
- `npm run set-demo` also stages ten completed events from the past year, with door sales recorded by staff, staff cash counts, check-ins, refunds, donations and post-event feedback. Their cashups go through every path: a quick close; full closes with card fees, a cash deposit fee, a written-off shortfall, cash kept on hand and an unaccounted amount; a close, reopen and re-close; pending EFT money that was later received and some still outstanding; and one event left open with the cash counted, ready for a live cashup. Funnel stats older than 90 days are stored as daily totals, the way the app rolls them up. See `backend/DEMO_DATA.md`.
## [1.27.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- Cashups now record where each payment method's money ended up. For cash, card, EFT and other, the expected takings are split into what's **in the bank**, **cash on hand**, and **pending** (on the card machine or Yoco report, or with a proof of payment), with a reference for the pending amount. Card fees, bank charges, cash shortfalls and other losses are **written off** with a reason. Each method shows what's still **unaccounted**. Closing with an unaccounted amount is allowed after a warning, and the amount comes off profit, as variance did before.
- When staff have counted the cash, cash on hand is worked out for you: the count, less what reached the bank and any deposit fee written off as a bank charge.
- Pending money on a closed event can be marked **received** or **written off** later from the cashup screen, without reopening the event. Each one is logged with who did it and when.
- Reopening an event starts the next cashup from the last one, with any pending money settled since folded in.
- Write-offs show as **Bank & card charges** on the cashup screen and in the Cashup, Finance and Profit reports. The Cashup report has new In bank, On hand, Pending, Written off and Unaccounted columns.
### Changed
- Net profit now subtracts write-offs as their own line instead of showing card fees and bank charges as missing income. The net figure is the same.
- Every dashboard page uses the same back link: the small "← Back to dashboard" link above the page title, as on the Waitlist page. The grey Back buttons are gone. Site settings, profile, donate and manual registration, which had no way back, now have one too.
## [1.26.1] - 2026-10-21
### Added
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
## [1.26.0] - 2026-10-21
### Changed
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
### Fixed
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
### Security
- Backend `npm audit` now reports 0 vulnerabilities:
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
- Removed `nodemon`, the only backend package that pulled in `braces`.
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
## [1.25.1] - 2026-10-20
### Fixed
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
## [1.25.0] - 2026-10-20
### Added
- A **Yoco** tab in Site Settings for online payments:
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
### Changed
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
- Selecting several events compares them.
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
- The privacy policy's cookies section now describes these statistics.
- New `/api/analytics` endpoints.
- Requires the `add_funnel_analytics` database migration.
## [1.21.0] - 2026-10-19
### Added
+124 -21
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
@@ -164,13 +199,46 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
confirmed by a signed webhook. Keys and the webhook are set up from
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.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
from the registration itself.
- Each record holds only the stage, the traffic source (utm_source, or the short-link
alias, which short links now pass as `?src=`) and the device type.
- The Registration funnel report shows counts and conversion per stage over a date
range, a daily trend, and breakdowns by source, device and ticket option.
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
events.
- Raw rows are rolled up into daily totals after 90 days.
- **Post-event feedback** — a Feedback step in the event wizard.
- Questions use the form builder, with new **star rating (1–5)** and **multiple
choice** types (also available on attendee forms).
@@ -193,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
@@ -217,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
@@ -284,9 +361,12 @@ logout controls. Disconnected sessions are recovered automatically.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
email/phone). They get a new ticket and QR code, and an inactive account with an
activation link if they're new. The old code stops working, and scanning it says it
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
@@ -318,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
@@ -404,7 +484,7 @@ a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
@@ -431,6 +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 |
| 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.
@@ -472,15 +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) |
| 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 |
---
@@ -492,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).
---
@@ -506,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 |
+30 -3
View File
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
- DATABASE_URL
- JWT_SECRET
- PORT (optional; defaults to 3000 if not set)
- YOCO_SECRET_KEY (for payments)
- YOCO_WEBHOOK_SECRET (to verify webhooks)
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
- FRONTEND_URL or APP_BASE_URL (used in email links)
@@ -69,6 +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 → 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
@@ -82,8 +81,36 @@ 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, on the server in the repo folder:
- `bash scripts/deploy.sh` — the latest `main`
- `bash scripts/deploy.sh v1.37.4` — a tagged release
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
If anything fails before the reload, it goes back to the previous version (the running app
isn't touched).
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
set names that match your PM2 apps (`pm2 ls`) and the port you use.
### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL.
@@ -139,7 +166,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.
+15
View File
@@ -57,6 +57,20 @@ 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
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. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
@@ -66,6 +80,7 @@ or Branding tab has been saved even once — the wizard always writes
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
+18 -3
View File
@@ -33,9 +33,21 @@ FRONTEND_URL=http://localhost:3000
# EMAIL_PASS=your_email_password
# EMAIL_FROM=no-reply@example.com
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
YOCO_SECRET_KEY=your_yoco_secret_key
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
# 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.)
@@ -57,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
@@ -72,6 +86,7 @@ BACKUP_ENABLED=true
# - Registration notification emails
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
# - Yoco secret key and webhook secret (encrypted the same way)
# - Legal page content (operator name, Information Officer, website URL, etc.)
# The first-time setup wizard handles initial configuration on fresh deployments.
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
+164 -16
View File
@@ -21,11 +21,15 @@ 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)
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -37,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)
@@ -283,6 +287,53 @@ 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 |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
How the stages are recorded:
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
- `paid` is read from that registration's status.
- Counts are sessions that reached a stage or a later one.
Data handling:
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
## Post-event feedback
| Method | Path | Auth | Description |
@@ -297,7 +348,7 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
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
@@ -340,9 +391,18 @@ Related changes:
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
@@ -380,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" }` |
@@ -398,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
@@ -452,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 |
@@ -472,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-...",
@@ -621,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 |
---
@@ -657,14 +788,15 @@ 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 |
---
## Settings
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
@@ -676,6 +808,22 @@ For what each individual setting key defaults to when it's never been saved, see
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
## Yoco
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 |
|--------|------|------|-------------|
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
### POST /api/settings/test-smtp
```json
{
+191
View File
@@ -0,0 +1,191 @@
# Demo data
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
for showing the platform to other churches. It stages six upcoming events with
registrations, payments, tickets and people, and ten completed events from the past year
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
check-in and cashup screen has something to show.
> **This deletes real data.** Run it only against a database you're happy to wipe, such
> as a dedicated demo deployment. It is not something to run on a live church site.
---
## Running it
```bash
cd backend
npm run set-demo # shows the database it will wipe and asks you to type "demo"
npm run set-demo -- --yes # no prompt (for scripts)
```
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
for one run, set it in the environment first. A value already in the environment wins
over `.env`:
```bash
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
```
It takes about five seconds and is safe to re-run as often as you like. Each run wipes the
previous demo and creates it again.
### Before you run it
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
To keep a different account, set `DEMO_ADMIN_EMAIL`:
```bash
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
```
If the account isn't found, the script stops before changing anything.
- Migrations must be up to date (`npm run prisma:deploy`).
---
## What it keeps and what it clears
| Kept | Cleared |
|---|---|
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
| The admin account, including its login history, trusted devices and 2FA | Every other user |
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
The wipe and the new data are written in one database transaction. If that fails,
nothing changes. The past events' cashups run straight afterwards, through the same
code as the cashup screen. If one of those fails, re-run the script.
---
## What it creates
### Upcoming events
All dates are worked out from the day you run it, so these events are always upcoming.
Registrations are spread over the past month, as if sign-ups have been coming in.
The data is generated from a fixed seed, so every run looks the same.
| When (from today) | Event | Shows off |
|---|---|---|
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations) and a **pick-any** deal (Lunch for four: any 4 lunch packs for R220), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
Each event also gets:
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
(WhatsApp, Facebook, Instagram, email, direct) and device.
Paid registrations have tickets with QR codes, the same way the app issues them once
payment is complete. Part-paid and pending ones don't have tickets yet.
### Past events
Ten completed events spread over the past year, for showing cashups and the historical
reports. Each one has:
- Online sales in the five weeks before the event, with a few unpaid no-shows.
- **Door sales** on the day, recorded by the staff team (cash and card machine).
- **Check-ins**: about 90% of tickets scanned, with the odd group arriving short.
- **Staff cash counts** with note and coin denominations.
Some events also have refunds, donations and post-event feedback.
The cashups cover every path through the cashup screen:
| When | Event | Cashup to demo |
|---|---|---|
| ~11 months ago | **Church Family Picnic** | **Quick closed** (no settlement) |
| 24 Dec | **Christmas Eve Candlelight Service** | Free event with card and cash **donations**. Card settled net of **Yoco fees**, collection banked |
| Easter Sunday | **Easter Sunrise Service & Breakfast** | Cash banked less a **cash deposit fee** (bank charge), card fees, a **refund** |
| Mother's Day | **Mother's Day Brunch** | **Closed with R50 cash short → reopened → recounted → closed again.** The history shows all three steps |
| ~3½ months ago | **Youth Winter Camp** | Deposits and balances. **EFT pending** (POPs) at close, later **marked received** without reopening |
| ~2½ months ago | **Men's Golf Day** | Card-heavy, card fees written off, a refund |
| ~2 months ago | **Kids Holiday Club** | Cash-heavy door sales. **R20 cash shortfall written off**, **R500 kept on hand** as float |
| ~6 weeks ago | **Women's High Tea** | Closed with **R30 unaccounted**, shown in red |
| ~4 weeks ago | **Missions Fundraiser Dinner** | **R2 600 EFT still pending** (POP received, not cleared). Shows "Mark received" / "Write off" |
| ~10 days ago | **Worship Night** | **Still open, with the cash already counted.** Use it to do a cashup live in the demo |
Easter, the Youth Camp, the Holiday Club and the High Tea have **feedback forms** with
ratings and comments. Funnel stats older than 90 days are stored as daily totals, the
way the app rolls them up overnight, so the funnel report covers the whole year.
### Totals
A typical run gives roughly:
| | |
|---|---|
| Users | 130 (plus the admin) |
| Registrations | ~700 (paid, part-paid, pending, cancelled and refunded) |
| Payments | ~640, including donations, sponsorships and refunds |
| Tickets | ~780, with ~490 check-ins |
| Named campers | ~45 |
| Feedback responses | ~80 |
| Funnel rows | ~4 400 |
---
## Logging in
Every demo account uses the password **`Demo@1234`**.
| Role | Email | Good for showing |
|---|---|---|
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, coupons, viewing cashups |
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
| Staff | `anri.botha@example.com` | |
| Staff | `daniel.pillay@example.com` | |
Closing, reopening and settling pending money in a cashup are admin-only, so do the
live Worship Night cashup as your own admin account.
The other 126 people are ordinary members (role `user`), with emails like
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
registrations, tickets and payments. Any registrant's email from an event's attendee
list works.
Your own admin account keeps its existing password.
---
## Safety
- **Nothing is sent.** The script writes straight to the database and never calls the
email, WhatsApp or Yoco code.
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
(a reserved domain that can't receive mail) and have **no phone numbers**, so
reminders or broadcasts sent during a demo go nowhere. All parental consents are
already signed, so no consent reminders go out.
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
the demo site's Yoco settings in test mode or empty.
---
## Customising
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
`main()` decides which ones run. To change an event's name, prices, ticket types or
number of registrations, edit its function. To add an event, write a new `build…()`
and add it to the list in `main()`. `addBundle(event, { name, price }, [[option, quantity], …])`
adds a bundle deal. Registrations added after it get the deal automatically, worked out
the same way as the app does it.
The past events are in `buildPastEvents()`, one spec each: dates, ticket types, costs,
how many people bought online and at the door, and a `cashup` that says how it was
closed. Supported closes:
- `'quick'`
- `'full'`, with `lines` saying where the money ended up
- `'reclose'`
- `'none'`, which leaves the event open
A full close can add `later` to settle pending money after closing. The helpers
`cardLine`, `eftLine` and `cashLine` build the usual settlements: card net of Yoco fees,
EFT with an optional pending POP, and cash banked with an optional float, deposit fee or
shortfall.
+21 -4
View File
@@ -79,8 +79,8 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
@@ -99,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
>
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
>
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
---
@@ -141,6 +143,10 @@ npm run prisma:status
npm run prisma:generate
```
### Demo data
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
---
## Running the Server
@@ -333,6 +339,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
#### Yoco — `/api/yoco`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
#### Setup — `/api/setup`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -500,6 +515,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
## Payments (Yoco)
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
1. Frontend calls `POST /api/payments/yoco-checkout`
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
@@ -524,6 +541,6 @@ pm2 save && pm2 startup
- [ ] `JWT_SECRET` is long (32+ chars) and secret
- [ ] `FRONTEND_URL` lists only production origins
- [ ] `NODE_ENV=production`
- [ ] Yoco webhook registered and HTTPS
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
- [ ] WAWP credentials saved via admin panel
- [ ] SMTP credentials correct
+1731 -420
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -1,11 +1,11 @@
{
"name": "event-management-backend",
"version": "1.21.0",
"version": "1.37.4",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"dev": "node --watch src/index.js",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
@@ -13,14 +13,17 @@
"prisma:status": "prisma migrate status",
"prisma:reset": "prisma migrate reset --force",
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
"set-demo": "node scripts/set-demo.js"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
"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",
@@ -31,22 +34,25 @@
"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"
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
"prisma": "^7.10.1"
},
"overrides": {
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"@istanbuljs/load-nyc-config": {
"js-yaml": "^4.1.1"
},
"follow-redirects": "^1.16.1"
},
"allowScripts": {
"prisma@5.22.0": true,
"@prisma/client@5.22.0": true,
"@prisma/engines@5.22.0": true,
"prisma@7.10.1": true,
"@prisma/engines@7.10.1": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
}
+16
View File
@@ -0,0 +1,16 @@
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
require('dotenv/config');
const { defineConfig } = require('prisma/config');
module.exports = defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
// process.env rather than env() so `prisma generate` still works on a
// machine with no DATABASE_URL (generate never connects).
url: process.env.DATABASE_URL,
},
});
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsDaily" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"day" DATE NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'direct',
"device" TEXT NOT NULL DEFAULT 'unknown',
"sessions" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
-- CreateIndex
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
-- AddForeignKey
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
-- 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 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- 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 'yoco_config_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "CashupWriteOffCategory" AS ENUM ('card_fees', 'bank_charges', 'cash_shortfall', 'other');
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "bankAmount" DOUBLE PRECISION,
ADD COLUMN "onHandAmount" DOUBLE PRECISION,
ADD COLUMN "pendingAmount" DOUBLE PRECISION,
ADD COLUMN "pendingReference" TEXT,
ADD COLUMN "writeOffTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "EventCashupWriteOff" (
"id" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"category" "CashupWriteOffCategory" NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventCashupWriteOff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPendingResolution" (
"id" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"category" "CashupWriteOffCategory",
"notes" TEXT,
"performedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventCashupPendingResolution_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupWriteOff_lineId_idx" ON "EventCashupWriteOff"("lineId");
-- CreateIndex
CREATE INDEX "EventCashupPendingResolution_lineId_idx" ON "EventCashupPendingResolution"("lineId");
-- CreateIndex
CREATE INDEX "EventCashupPendingResolution_performedById_idx" ON "EventCashupPendingResolution"("performedById");
-- AddForeignKey
ALTER TABLE "EventCashupWriteOff" ADD CONSTRAINT "EventCashupWriteOff_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,83 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_deleted';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "bundleDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "EventBundle" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventBundle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventBundleItem" (
"id" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"quantity" INTEGER NOT NULL,
CONSTRAINT "EventBundleItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationBundle" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"discount" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationBundle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventBundle_eventId_idx" ON "EventBundle"("eventId");
-- CreateIndex
CREATE INDEX "EventBundleItem_bundleId_idx" ON "EventBundleItem"("bundleId");
-- CreateIndex
CREATE INDEX "EventBundleItem_eventOptionId_idx" ON "EventBundleItem"("eventOptionId");
-- CreateIndex
CREATE INDEX "EventBundleItem_variantId_idx" ON "EventBundleItem"("variantId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_registrationId_idx" ON "RegistrationBundle"("registrationId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_bundleId_idx" ON "RegistrationBundle"("bundleId");
-- AddForeignKey
ALTER TABLE "EventBundle" ADD CONSTRAINT "EventBundle_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "BundleKind" AS ENUM ('exact', 'mix', 'free', 'group');
-- CreateEnum
CREATE TYPE "BundleGroupPricing" AS ENUM ('per_ticket', 'percent');
-- AlterTable
ALTER TABLE "EventBundle" ADD COLUMN "freeCount" INTEGER,
ADD COLUMN "groupPricing" "BundleGroupPricing",
ADD COLUMN "kind" "BundleKind" NOT NULL DEFAULT 'exact',
ADD COLUMN "setSize" INTEGER;
@@ -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");
+427 -16
View File
@@ -7,7 +7,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
@@ -89,6 +88,7 @@ model User {
eventsClosed Event[] @relation("EventClosedBy")
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
pendingResolved EventCashupPendingResolution[] @relation("PendingResolutionBy")
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
@@ -111,11 +111,16 @@ model User {
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
transferRequests TicketTransferRequest[] @relation("TransferRequester")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
certificates Certificate[]
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
}
model Event {
@@ -153,6 +158,8 @@ model Event {
sponsorshipGoalSpots Int?
showSponsorshipProgress Boolean @default(true)
feedbackForm FeedbackForm?
analyticsEvents AnalyticsEvent[]
analyticsDaily AnalyticsDaily[]
allocationSets AllocationSet[]
waitlistOfferHours Int @default(24)
waitlistPartialOffers Boolean @default(true)
@@ -192,6 +199,29 @@ model Event {
consentRequests ConsentRequest[]
waitlistEntries WaitlistEntry[]
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])
@@ -207,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[]
@@ -215,6 +248,7 @@ model EventOption {
costs EventCost[]
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
@@index([eventId])
}
@@ -250,6 +284,7 @@ model OptionVariant {
earlyBirdTiers EarlyBirdTier[]
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
@@index([eventOptionId])
}
@@ -260,12 +295,16 @@ model Registration {
eventId String
status RegistrationStatus @default(pending)
checkoutId String? @unique
// Sum of this registration's active coupon discounts, kept in step with
// couponRedemptions. Stored here so every total-due calculation sees it no matter
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
// Sum of this registration's discounts: bundle deals plus active coupon discounts,
// kept in step with bundleApplications and couponRedemptions. Stored here so every
// total-due calculation sees it no matter which relations the caller loaded (see
// utils/pricing.js computeRegistrationTotalDue).
discountTotal Float @default(0)
// The bundle-deal part of discountTotal (the rest is coupons).
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -275,6 +314,7 @@ model Registration {
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
bundleApplications RegistrationBundle[]
attendees Attendee[]
waitlistEntries WaitlistEntry[]
@@ -300,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
@@ -345,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)
@@ -352,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])
@@ -384,6 +435,8 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
certificates Certificate[]
@@index([holderUserId])
@@index([eventId])
@@ -404,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)
@@ -411,6 +469,7 @@ model TicketUsage {
@@index([ticketId])
@@index([scannedAt])
@@index([scannedById, scannedAt])
@@index([sessionId])
}
model PasswordReset {
@@ -488,6 +547,9 @@ enum AdminAuditAction {
coupon_updated
coupon_disabled
coupon_applied_manual
bundle_created
bundle_updated
bundle_deleted
consent_override
consent_rerequested
medical_list_viewed
@@ -496,6 +558,19 @@ enum AdminAuditAction {
scan_lock_force_released
offline_sync
ticket_transferred
impersonation_started
impersonation_ended
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
@@ -610,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?
@@ -623,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
@@ -697,20 +800,68 @@ model EventCashup {
@@index([eventId, createdAt])
}
// Where one method's expected takings ended up. actualAmount = bank + on hand + pending
// (money held or on its way); write-offs explain money that's gone (fees, charges,
// shortfalls). expected - actual - writeOffTotal is what's still unaccounted for.
model EventCashupLine {
id String @id @default(uuid())
cashupId String
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String
expectedAmount Float @default(0)
actualAmount Float?
variance Float?
notes String?
denominations EventCashupDenomination[]
id String @id @default(uuid())
cashupId String
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String
expectedAmount Float @default(0)
actualAmount Float?
variance Float?
notes String?
bankAmount Float?
onHandAmount Float?
pendingAmount Float?
// Card/Yoco report or POP reference for the pending amount.
pendingReference String?
writeOffTotal Float @default(0)
denominations EventCashupDenomination[]
writeOffs EventCashupWriteOff[]
pendingResolutions EventCashupPendingResolution[]
@@index([cashupId])
}
enum CashupWriteOffCategory {
card_fees
bank_charges
cash_shortfall
other
}
model EventCashupWriteOff {
id String @id @default(uuid())
lineId String
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
category CashupWriteOffCategory
amount Float
reason String?
createdAt DateTime @default(now())
@@index([lineId])
}
// Pending money settled after the event was closed: it reached the bank ('received') or
// never will ('written_off'). Append-only, like EventCashup — the line itself is never edited.
model EventCashupPendingResolution {
id String @id @default(uuid())
lineId String
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
outcome String // 'received' | 'written_off'
amount Float
category CashupWriteOffCategory?
notes String?
performedById String?
performedBy User? @relation("PendingResolutionBy", fields: [performedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([lineId])
@@index([performedById])
}
model EventCashupDenomination {
id String @id @default(uuid())
lineId String
@@ -842,6 +993,85 @@ model CouponRedemption {
@@index([appliedById])
}
// ─── Bundle deals ─────────────────────────────────────────────────────────────
// How a bundle is made up (see utils/bundles.js):
// exact: these tickets in these numbers, for `price`, e.g. 5 Adult + 3 Child for R500
// mix: any `setSize` of the qualifying tickets, for `price`
// free: any `setSize` of the qualifying tickets, the cheapest `freeCount` free
// group: `setSize` or more qualifying tickets, all at `price` each (or `price`% off)
enum BundleKind {
exact
mix
free
group
}
enum BundleGroupPricing {
per_ticket
percent
}
// Tickets sold together at a better price. Applied automatically, as many times as a
// registration's items allow (a group discount once), and only when it beats what the
// items would cost anyway (see utils/bundles.js).
model EventBundle {
id String @id @default(uuid())
eventId String
name String
description String?
kind BundleKind @default(exact)
// exact/mix: the set's price. group: the per-ticket price, or the percentage off.
price Float
setSize Int?
freeCount Int?
groupPricing BundleGroupPricing?
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
items EventBundleItem[]
uses RegistrationBundle[]
@@index([eventId])
}
// One line of a bundle: for exact bundles a ticket and how many; for the others a
// qualifying ticket (quantity unused). variantId null = any variant of the option.
model EventBundleItem {
id String @id @default(uuid())
bundleId String
eventOptionId String
variantId String?
quantity Int
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@index([bundleId])
@@index([eventOptionId])
@@index([variantId])
}
// How many times a bundle applies to a registration and what it saved. Rebuilt each
// time the registration's discounts are recalculated.
model RegistrationBundle {
id String @id @default(uuid())
registrationId String
bundleId String
count Int
discount Float
createdAt DateTime @default(now())
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Restrict)
@@index([registrationId])
@@index([bundleId])
}
// ─── Group tickets ────────────────────────────────────────────────────────────
enum TicketDelivery {
@@ -851,6 +1081,70 @@ enum TicketDelivery {
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
model ImpersonationSession {
id String @id @default(uuid())
adminId String
targetUserId String
reason String
startedAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedReason String? // 'ended' | 'expired'
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
requests ImpersonationRequestLog[]
@@index([adminId])
@@index([targetUserId])
}
/// Every page viewed and API call made during an impersonation session.
model ImpersonationRequestLog {
id String @id @default(uuid())
sessionId String
method String
path String
blocked Boolean @default(false)
createdAt DateTime @default(now())
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId, createdAt])
}
/// First-party funnel tracking (only after cookie consent): one row per anonymous
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
/// registration that carries the session id. Rows older than 90 days are rolled up
/// into AnalyticsDaily and deleted.
model AnalyticsEvent {
id String @id @default(uuid())
sessionId String
eventId String
stage String
source String?
device String?
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([sessionId, eventId, stage])
@@index([eventId, createdAt])
}
/// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device.
model AnalyticsDaily {
id String @id @default(uuid())
eventId String
day DateTime @db.Date
stage String
source String @default("direct")
device String @default("unknown")
sessions Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([eventId, day, stage, source, device])
@@index([eventId, day])
}
/// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered),
/// with one reminder 3 days later. anonymous responses aren't linked to a person.
model FeedbackForm {
@@ -976,6 +1270,28 @@ model AllocationAssignment {
@@index([attendeeId])
}
/// A transfer waiting for the new holder to accept it from the link sent to the email /
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
/// holds what the sender entered (date of birth, gender, parent details on youth events).
model TicketTransferRequest {
id String @id @default(uuid())
ticketId String
requestedById String
toName String
toEmail String?
toPhone String?
details Json?
tokenHash String @unique
status String @default("pending") // pending | accepted | declined | cancelled | expired
expiresAt DateTime
respondedAt DateTime?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([ticketId, status])
}
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
@@ -1084,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
@@ -1099,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])
}
@@ -1156,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])
}
+7 -3
View File
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
// Load .env from the backend root
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient } = require('../src/config/db');
const crypto = require('crypto');
const DRY_RUN = process.argv.includes('--dry-run');
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
@@ -79,6 +79,10 @@ const MAPPINGS = [
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// Yoco payments
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
@@ -86,7 +90,7 @@ const MAPPINGS = [
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const prisma = new PrismaClient();
const prisma = createPrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Settings migration: .env → DB');
+3 -2
View File
@@ -3,10 +3,11 @@
* Run: node scripts/seed-users.js
*/
const { PrismaClient } = require('@prisma/client');
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const { createPrismaClient } = require('../src/config/db');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const prisma = createPrismaClient();
const firstNames = [
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -1,5 +1,17 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const prisma = new PrismaClient();
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000,
});
return new PrismaClient({ adapter });
}
module.exports = prisma;
const prisma = createPrismaClient();
module.exports = prisma;
module.exports.createPrismaClient = createPrismaClient;
@@ -0,0 +1,44 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { track, funnelFor } = require('../utils/analytics');
// @desc Record a funnel step (event page view, registration started)
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
// @access Public — only sent by browsers whose visitor accepted cookies
const trackStage = async (req, res) => {
try {
const { sessionId, eventId, stage, source } = req.body || {};
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
} catch (e) {
console.warn('[analytics] track failed:', e?.message || e);
}
// Always a quiet 204 — tracking must never get in a visitor's way.
res.status(204).end();
};
// @desc Registration funnel for one or more events
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
// @access Private/Supervisor
const getFunnel = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
// Per event too, so events can be compared.
const perEvent = [];
if (events.length > 1) {
for (const ev of events) {
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
}
} else if (events[0]) {
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
}
res.json({ ...total, perEvent });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { trackStage, getFunnel };
@@ -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,
};
+325
View File
@@ -0,0 +1,325 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
};
function shape(bundle, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return {
...bundle,
items,
summary: describeBundleItems(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw new Error('Give the bundle a name');
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
async function eventOr404(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) { res.status(404); throw new Error('Event not found'); }
return event;
}
// @desc An event's bundle deals with how often each has been used
// @route GET /api/bundles/event/:eventId
// @access Private/Supervisor
const listBundles = async (req, res) => {
try {
await eventOr404(req.params.eventId, res);
const bundles = await prisma.eventBundle.findMany({
where: { eventId: req.params.eventId },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
res.json(bundles.map(b => ({
...shape(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a bundle deal
// @route POST /api/bundles/event/:eventId
// @access Private/Supervisor
const createBundle = async (req, res) => {
try {
const event = await eventOr404(req.params.eventId, res);
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.create({
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Edit a bundle deal (including switching it off). Registrations that already
// have it are only re-worked when their own items change.
// @route PUT /api/bundles/:id
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
res.json(shape(bundle));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
// registrations that got it keep their price.
// @route DELETE /api/bundles/:id
// @access Private/Supervisor
const deleteBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) {
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
} else {
await prisma.eventBundle.delete({ where: { id: existing.id } });
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
ip: getClientIp(req),
});
res.json(used > 0
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true, message: 'Bundle deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc The bundle deals a basket gets, before any coupon code
// @route POST /api/bundles/quote
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
const quoteBundles = async (req, res) => {
try {
const { eventId, options, forUserId } = req.body || {};
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
const bundles = await previewBundles(eventId, lines);
res.json({
subtotal,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
total: round2(Math.max(0, subtotal - bundles.discount)),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** The deals to show on an event's page: available bundles, without internal fields. */
async function publicBundlesForEvent(eventId) {
const bundles = await prisma.eventBundle.findMany({
where: { eventId, isActive: true },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const now = new Date();
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
+126 -36
View File
@@ -1,7 +1,20 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
const { ALL_METHODS, WRITE_OFF_CATEGORIES, PENDING_OUTCOMES, settleLine, applyPendingResolutions, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// What the cashup screen sends per method: where the money ended up and what was written off.
function cleanDraftLine(l) {
return {
method: l.method,
bankAmount: l.bankAmount ?? null,
onHandAmount: l.onHandAmount ?? null,
pendingAmount: l.pendingAmount ?? null,
pendingReference: l.pendingReference || null,
writeOffs: (Array.isArray(l.writeOffs) ? l.writeOffs : []).map(w => ({ category: w.category, amount: w.amount ?? null, reason: w.reason || null })),
notes: l.notes || null
};
}
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -66,7 +79,7 @@ const saveEventCashupDraft = async (req, res) => {
await prisma.event.update({
where: { id: eventId },
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
data: { cashupDraft: { lines: (Array.isArray(lines) ? lines : []).filter(l => l && ALL_METHODS.includes(l.method)).map(cleanDraftLine), notes: notes || null, savedAt: new Date().toISOString() } }
});
res.json({ message: 'Draft saved' });
@@ -88,40 +101,32 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
// Each method says where its expected money ended up (see settleLine). Cash is never a
// single event-wide figure: when staff counted it, the count is the cash there is, sourced
// here from the per-person counts rather than from whatever the frontend sent.
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null
};
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
const { actual: cashCounted, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
cashupLines = ALL_METHODS.map(method => {
const input = lines.find(l => l && l.method === method) || {};
const expected = financials.expectedCashByMethod[method] || 0;
const settled = settleLine({ method, expected, counted: method === 'cash' ? cashCounted : null, input });
return {
id: uuidv4(),
method,
expectedAmount: expected,
actualAmount: settled.actual,
variance: settled.variance,
notes: input.notes || null,
bankAmount: settled.bankAmount,
onHandAmount: settled.onHandAmount,
pendingAmount: settled.pendingAmount,
pendingReference: settled.pendingReference,
writeOffTotal: settled.writeOffTotal,
writeOffs: settled.writeOffs.length > 0 ? { create: settled.writeOffs } : undefined,
denominations: method === 'cash' && cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
});
}
const totalActualRevenue = isFullCashup
@@ -142,7 +147,7 @@ const closeEvent = async (req, res) => {
performedById: req.user.id,
lines: { create: cashupLines }
},
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
include: { lines: { include: { denominations: true, writeOffs: true } }, performedBy: { select: { id: true, name: true, email: true } } }
});
await prisma.event.update({
@@ -185,9 +190,33 @@ const reopenEvent = async (req, res) => {
include: { performedBy: { select: { id: true, name: true, email: true } } }
});
// Start the next cashup from the last one, with pending money that has since been
// received or written off folded in, so nothing has to be typed in again.
const lastClose = await prisma.eventCashup.findFirst({
where: { eventId, action: { in: ['closed', 'quick_closed'] } },
orderBy: { createdAt: 'desc' },
include: { lines: { include: { writeOffs: true, pendingResolutions: true } } }
});
const cashupDraft = lastClose && lastClose.lines.length > 0 ? {
lines: lastClose.lines.map(l => {
const settled = applyPendingResolutions(l);
return cleanDraftLine({
method: l.method,
bankAmount: settled.bank,
onHandAmount: settled.onHand,
pendingAmount: settled.pendingOutstanding || null,
pendingReference: settled.pendingOutstanding ? settled.pendingReference : null,
writeOffs: settled.writeOffs,
notes: l.notes
});
}),
notes: null,
savedAt: new Date().toISOString()
} : undefined;
await prisma.event.update({
where: { id: eventId },
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id, ...(cashupDraft ? { cashupDraft } : {}) }
});
res.status(201).json(auditRow);
@@ -196,6 +225,66 @@ const reopenEvent = async (req, res) => {
}
};
// @desc Settle money that was pending when the event was closed (on the card report, or a
// POP we hadn't seen in the bank yet): it has now reached the bank, or it never will and
// is written off. Allowed while the event is closed — no reopen needed.
// @route POST /api/cashups/lines/:lineId/pending
// @access Private/Admin
const resolvePending = async (req, res) => {
try {
const { lineId } = req.params;
const { outcome, amount, category, notes } = req.body;
if (!PENDING_OUTCOMES.includes(outcome)) {
res.status(400);
throw new Error('Say whether the money was received or written off');
}
const value = Math.round(Number(amount) * 100) / 100;
if (!Number.isFinite(value) || value <= 0) {
res.status(400);
throw new Error('Enter an amount more than R0');
}
const resolution = await prisma.$transaction(async (tx) => {
// Lock the line so two people settling at once can't settle more than is pending.
await tx.$executeRaw`SELECT 1 FROM "EventCashupLine" WHERE id = ${lineId} FOR UPDATE`;
const line = await tx.eventCashupLine.findUnique({
where: { id: lineId },
include: { cashup: { select: { id: true, eventId: true } }, pendingResolutions: true }
});
if (!line) {
res.status(404);
throw new Error('Cashup line not found');
}
const latest = await tx.eventCashup.findFirst({ where: { eventId: line.cashup.eventId }, orderBy: { createdAt: 'desc' }, select: { id: true } });
if (!latest || latest.id !== line.cashupId) {
res.status(400);
throw new Error('Only the current cashup of a closed event can be updated');
}
const { pendingOutstanding } = applyPendingResolutions(line);
if (value > pendingOutstanding + 0.005) {
res.status(400);
throw new Error(`Only R${pendingOutstanding.toFixed(2)} is still pending`);
}
return tx.eventCashupPendingResolution.create({
data: {
lineId,
outcome,
amount: value,
category: outcome === 'written_off' ? (WRITE_OFF_CATEGORIES.includes(category) ? category : 'other') : null,
notes: notes ? String(notes).trim().slice(0, 200) : null,
performedById: req.user.id
},
include: { performedBy: { select: { id: true, name: true } } }
});
});
res.status(201).json(resolution);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
// @route GET /api/cashups/audit
// @access Private/Supervisor
@@ -234,5 +323,6 @@ module.exports = {
saveEventCashupDraft,
closeEvent,
reopenEvent,
resolvePending,
getCashupAudit
};
+72 -28
View File
@@ -4,6 +4,7 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
@@ -231,6 +232,44 @@ const updateCoupon = async (req, res) => {
}
};
/**
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
async function basketBuyer(req, forUserId) {
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
return { buyer, isStaff };
}
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
// 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;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
return buildCouponLines({ registrationOptions });
}
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
@@ -249,33 +288,12 @@ const validateCode = async (req, res) => {
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
// Price the basket the same way registration will.
const registrationOptions = [];
for (const o of options) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
const lines = buildCouponLines({ registrationOptions });
const { buyer, isStaff } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount } = await previewCoupon({
const { coupon, discount, bundles } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
@@ -287,8 +305,10 @@ const validateCode = async (req, res) => {
summary: describeCoupon(coupon),
description: coupon.description,
discount,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
subtotal,
total: Math.max(0, subtotal - discount),
total: Math.max(0, subtotal - bundles.discount - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
@@ -299,7 +319,7 @@ const validateCode = async (req, res) => {
}
};
// @desc Coupon uses for the Discounts report
// @desc Coupon and bundle-deal uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
@@ -322,10 +342,34 @@ const listRedemptions = async (req, res) => {
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
res.json([
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id,
kind: 'bundle',
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
]);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
+197 -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),
@@ -457,7 +601,8 @@ const getEventById = async (req, res) => {
url: toAbsoluteUrl(req, att.url)
})),
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 });
@@ -472,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) {
@@ -582,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
@@ -767,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: {
@@ -778,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) {
@@ -804,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 },
@@ -814,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,
}
});
@@ -1439,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) {
@@ -1450,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`,
@@ -1681,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 () => {
@@ -1691,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`,
@@ -0,0 +1,108 @@
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
// @desc Start viewing the site as a user (read-only, 30 minutes)
// @route POST /api/admin/impersonate/:userId { reason }
// @access Private/Admin (with 2FA)
const startImpersonation = async (req, res) => {
try {
if (req.readOnly) throw err('End the current impersonation first.', 403);
const reason = String(req.body?.reason || '').trim();
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
const blocker = impersonationBlocker(req.user, target);
if (blocker) throw err(blocker, target ? 403 : 404);
// One live session per admin.
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
});
// Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache');
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
const { sendMail } = require('../utils/email');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
sendMail({
to: target.email,
subject: `An administrator viewed your account — ${orgName}`,
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
}).catch(() => {});
}
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
} catch (error) {
fail(res, error);
}
};
// @desc End impersonation (called with the impersonation token)
// @route POST /api/admin/impersonate/end
// @access Impersonation token
const endImpersonation = async (req, res) => {
try {
const sessionId = req.impersonation?.id;
if (!sessionId) throw err('Not impersonating.', 400);
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
logAdminAction({
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
});
res.json({ message: 'Impersonation ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Record a page the admin viewed while impersonating
// @route POST /api/admin/impersonate/log { path }
// @access Impersonation token
const logPageView = async (req, res) => {
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
res.status(204).end();
};
// @desc The current impersonation session (for the banner)
// @route GET /api/admin/impersonate/current
// @access Impersonation token
const currentImpersonation = async (req, res) => {
const s = req.impersonation;
if (!s) return res.json(null);
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
};
// @desc Impersonation sessions with their request trail (audit log page)
// @route GET /api/admin/impersonation-sessions
// @access Private/Admin
const listSessions = async (req, res) => {
try {
const sessions = await prisma.impersonationSession.findMany({
orderBy: { startedAt: 'desc' },
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
include: {
admin: { select: { name: true, email: true } },
target: { select: { name: true, email: true } },
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
},
});
res.json(sessions);
} catch (error) {
fail(res, error);
}
};
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
+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 -210
View File
@@ -5,7 +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 payments = require('../payments');
const { createRegistrationCheckout } = require('../payments/checkouts');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -913,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
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
const coupon = couponDiscountLine(registration);
if (coupon) {
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
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 } } } },
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': `Bearer ${process.env.YOCO_SECRET_KEY}`, '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') {
@@ -1032,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: {
@@ -1052,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 } } } },
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': `Bearer ${process.env.YOCO_SECRET_KEY}`, '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': `Bearer ${process.env.YOCO_SECRET_KEY}`, '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.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 {
@@ -1459,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);
}
@@ -51,6 +52,8 @@ const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
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
@@ -90,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 };
}
@@ -110,7 +113,7 @@ async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -120,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) {
@@ -206,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');
}
@@ -221,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`);
@@ -238,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;
@@ -258,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;
@@ -319,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([
@@ -336,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 {
@@ -347,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: {
@@ -394,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: {
@@ -429,9 +445,9 @@ const createRegistration = async (req, res) => {
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
}
// Apply the coupon (or re-work an existing one over the merged items).
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
let couponError = null;
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
@@ -441,7 +457,7 @@ const createRegistration = async (req, res) => {
couponError = e.message;
}
}
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
@@ -449,6 +465,8 @@ const createRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
@@ -503,6 +521,12 @@ const createRegistration = async (req, res) => {
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics');
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
}
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -516,6 +540,8 @@ const getRegistrations = async (req, res) => {
try {
const include = {
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
@@ -580,7 +606,7 @@ const getUserRegistrations = async (req, res) => {
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -595,6 +621,8 @@ const getUserRegistrations = async (req, res) => {
}
},
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
formResponses: { select: { id: true, attendeeId: true } },
// Nest the event's form so the frontend can tell whether attendee forms are
@@ -645,6 +673,8 @@ const getRegistrationById = async (req, res) => {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
};
@@ -657,7 +687,7 @@ const getRegistrationById = async (req, res) => {
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
@@ -870,7 +900,7 @@ const getRegistrationsByEvent = async (req, res) => {
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -888,6 +918,8 @@ const getRegistrationsByEvent = async (req, res) => {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
},
orderBy: { createdAt: 'asc' }
@@ -924,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) {
@@ -945,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);
@@ -958,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));
@@ -969,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);
@@ -1141,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([
@@ -1160,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) }),
}
})
]);
@@ -1171,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: {
@@ -1219,6 +1259,8 @@ const createManualRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
registration.status = newExistingStatus;
@@ -1237,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: {
@@ -1256,6 +1300,7 @@ const createManualRegistration = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
});
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
@@ -1263,6 +1308,8 @@ const createManualRegistration = async (req, res) => {
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || hasDiscount) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
@@ -1270,6 +1317,8 @@ const createManualRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
}
@@ -1316,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);
}
}
@@ -1441,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);
@@ -1450,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);
@@ -1552,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');
@@ -1711,6 +1771,8 @@ const updateRegistrationOptions = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
@@ -2117,6 +2179,8 @@ const applyCoupon = async (req, res) => {
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
@@ -2248,7 +2312,7 @@ const updateAttendees = async (req, res) => {
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
+13 -3
View File
@@ -8,6 +8,13 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// 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',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
@@ -17,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',
@@ -44,6 +53,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
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
@@ -79,7 +89,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([, v]) => v !== undefined && v !== null)
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -118,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) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -195,7 +205,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
.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,4 +1,4 @@
const axios = require('axios');
const payments = require('../payments');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -44,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) => {
@@ -71,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',
@@ -84,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: `Bearer ${process.env.YOCO_SECRET_KEY}`, '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);
}
};
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
ticketsSold: ticketSumById[r.eventId] || 0,
}));
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
}
} catch (e) {
console.warn('[stats] funnel tile failed:', e?.message || e);
}
res.json({
funnel,
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
+224 -42
View File
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
@@ -102,7 +103,8 @@ 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: {
scannedBy: {
@@ -261,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",
@@ -274,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
@@ -302,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 },
@@ -316,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) });
@@ -336,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",
@@ -344,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
@@ -355,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) {
@@ -404,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,
@@ -451,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() }
})
]);
@@ -467,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,
@@ -490,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' },
@@ -703,20 +762,84 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
@@ -744,7 +867,7 @@ const transferTicketHandler = async (req, res) => {
}
res.json({
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
@@ -780,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;
@@ -800,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,
@@ -886,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();
}
@@ -919,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 = [];
@@ -980,5 +1158,9 @@ module.exports = {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+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 -570
View File
@@ -1,517 +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 { 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}`;
// Get the webhook secret from environment variables
const secret = process.env.YOCO_WEBHOOK_SECRET;
if (!secret) {
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
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 {
@@ -526,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;
}
}
+149
View File
@@ -0,0 +1,149 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { encrypt } = require('../utils/encryption');
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
} = require('../utils/yoco');
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
async function saveSettings(values) {
const ops = Object.entries(values).map(([key, value]) => {
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
});
await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache();
}
const audit = (req, action, metadata) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
});
// @desc Yoco configuration with secrets masked
// @route GET /api/yoco/config
// @access Admin
const getConfig = async (req, res) => {
try {
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
]);
res.json({
secretKeyMasked: maskSecret(secretKey),
secretKeySource: source.secretKey,
mode: yocoMode(secretKey),
webhookSecretMasked: maskSecret(webhookSecret),
webhookSecretSource: source.webhookSecret,
webhookId: webhookId || '',
webhookUrl: defaultWebhookUrl(req),
configured: !!(secretKey && webhookSecret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Save the secret key and/or webhook secret ('' clears)
// @route PUT /api/yoco/config
// @access Admin
const updateConfig = async (req, res) => {
try {
const { secretKey, webhookSecret } = req.body || {};
const updates = {};
if (typeof secretKey === 'string') {
const key = secretKey.trim();
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
}
// Check Yoco accepts the key before replacing a working one.
if (key) await listWebhooks(key);
updates[KEY_SECRET] = key;
}
if (typeof webhookSecret === 'string') {
const secret = webhookSecret.trim();
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
res.status(400); throw new Error('The webhook secret should start with whsec_.');
}
updates[KEY_WEBHOOK] = secret;
// A typed-in secret isn't tied to a webhook we registered.
updates[KEY_WEBHOOK_ID] = '';
}
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
await saveSettings(updates);
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
res.json({ message: 'Yoco settings saved' });
} catch (e) {
fail(res, e);
}
};
// @desc Webhooks registered on the Yoco account
// @route GET /api/yoco/webhooks
// @access Admin
const getWebhooks = async (req, res) => {
try {
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
const here = defaultWebhookUrl(req);
res.json({
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
defaultUrl: here,
});
} catch (e) {
fail(res, e);
}
};
// @desc Register a webhook at Yoco and store the secret it returns
// @route POST /api/yoco/webhooks
// @access Admin
const createWebhook = async (req, res) => {
try {
const name = String(req.body?.name || '').trim() || 'hope-events';
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
const problem = webhookUrlProblem(url);
if (problem) { res.status(400); throw new Error(problem); }
const hook = await registerWebhook(name, url);
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
res.status(201).json({
message: 'Webhook registered and its secret saved',
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
webhookSecretMasked: maskSecret(hook.secret),
});
} catch (e) {
fail(res, e);
}
};
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
// @route DELETE /api/yoco/webhooks/:id
// @access Admin
const removeWebhook = async (req, res) => {
try {
const { id } = req.params;
await deleteWebhook(id);
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
} catch (e) {
fail(res, e);
}
};
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
@@ -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
};
+162 -194
View File
@@ -24,13 +24,10 @@ const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const prisma = require('./config/db');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
@@ -103,6 +100,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -130,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');
@@ -145,6 +146,8 @@ const cashupRoutes = require('./routes/cashupRoutes');
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);
@@ -161,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);
@@ -176,13 +180,18 @@ app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/bundles', require('./routes/bundleRoutes'));
app.use('/api/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
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');
@@ -387,6 +396,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -510,6 +520,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -532,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 }}]},
@@ -587,6 +605,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -638,6 +662,21 @@ 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' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
]},
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
@@ -671,6 +710,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
@@ -702,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.' }}]},
@@ -718,7 +775,7 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
{ status:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
@@ -730,11 +787,34 @@ app.get('/docs', async (req, res) => {
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'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:'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/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/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.)',
@@ -770,6 +850,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -849,6 +933,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -873,6 +959,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -894,6 +982,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -934,22 +1024,27 @@ 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: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -970,7 +1065,19 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ 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' }}},
@@ -979,6 +1086,26 @@ 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 }}]},
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
@@ -1339,176 +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);
}
// 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 {
@@ -1570,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 };
+24 -2
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+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 };
+10
View File
@@ -1,8 +1,18 @@
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);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { trackStage, getFunnel } = require('../controllers/analyticsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const trackLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(204).end(), // silently drop floods
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, getFunnel);
module.exports = router;
+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;
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// What a basket gets off before registering (guests included)
router.post('/quote', optionalAuth, quoteBundles);
router.get('/event/:eventId', protect, supervisor, listBundles);
router.post('/event/:eventId', protect, supervisor, createBundle);
router.put('/:id', protect, supervisor, updateBundle);
router.delete('/:id', protect, supervisor, deleteBundle);
module.exports = router;
+2 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
@@ -10,5 +10,6 @@ router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePerso
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
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;
+9
View File
@@ -15,6 +15,10 @@ const {
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -24,6 +28,11 @@ router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
+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;
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.get('/webhooks', protect, admin, getWebhooks);
router.post('/webhooks', protect, admin, createWebhook);
router.delete('/webhooks/:id', protect, admin, removeWebhook);
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;
+3 -5
View File
@@ -40,11 +40,9 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
if (match.conflict) {
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
err.statusCode = 400;
throw err;
}
// Email and phone pointing at two different accounts: go with the email's account,
// without saying so — the person typing the details mustn't learn who has an account.
if (match.conflict) return { user: match.emailUser, created: false };
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
+220
View File
@@ -0,0 +1,220 @@
/**
* Registration funnel — first-party, consent-based tracking.
*
* The browser only tracks after the visitor accepts cookies: it keeps a random session
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
* recorded here when a registration arrives carrying that session id, and 'paid' is
* read from that registration's status, so no personal data is stored with the events —
* just a stage, a traffic source and a device type (worked out from the User-Agent,
* which isn't kept).
*
* Counts are "reached": a session counts at a stage if it got that far or further, so a
* visitor who went straight to the registration page still counts as a view. Raw rows
* older than 90 days are rolled up into AnalyticsDaily and deleted.
*/
const prisma = require('../config/db');
const STAGES = ['view', 'start', 'submit', 'paid'];
const TRACKED = ['view', 'start'];
const RAW_DAYS = 90;
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
// ─── Pure helpers ─────────────────────────────────────────────────────────────
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
function deviceFromUA(ua) {
const s = String(ua || '');
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
return 'desktop';
}
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
function cleanSource(src) {
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
return s || 'direct';
}
/**
* One entry per session: the furthest stage reached, its source/device, and the day each
* stage was reached. Pure.
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
* @param {Set} paidSessions - session ids whose registration is paid
*/
function sessionsFrom(events, paidSessions = new Set()) {
const map = new Map();
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
const idx = STAGES.indexOf(e.stage);
if (idx > s.reach) s.reach = idx;
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
if (!s.source && e.source) s.source = e.source;
if (!s.device && e.device) s.device = e.device;
map.set(e.sessionId, s);
}
for (const s of map.values()) {
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
// day of the next stage that was recorded.
for (let i = STAGES.length - 1; i >= 0; i--) {
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
}
s.source = s.source || 'direct';
s.device = s.device || 'unknown';
}
return [...map.values()];
}
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
function rollupRows(sessions) {
const by = new Map();
for (const s of sessions) {
for (let i = 0; i <= s.reach; i++) {
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
by.set(key, (by.get(key) || 0) + 1);
}
}
return [...by.entries()].map(([key, n]) => {
const [day, stage, source, device] = key.split('|');
return { day, stage, source, device, sessions: n };
});
}
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
/**
* The funnel report from raw sessions plus rolled-up rows. Pure.
* @returns {{ totals, conversion, trend, bySource, byDevice }}
*/
function funnelReport(sessions, daily = []) {
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
const totals = emptyCounts();
const trend = new Map(), bySource = new Map(), byDevice = new Map();
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
for (const r of rows) {
totals[r.stage] += r.sessions;
add(trend, r.day, r.stage, r.sessions);
add(bySource, r.source, r.stage, r.sessions);
add(byDevice, r.device, r.stage, r.sessions);
}
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
return {
totals,
conversion: {
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
},
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
bySource: list(bySource),
byDevice: list(byDevice),
};
}
// ─── Database ─────────────────────────────────────────────────────────────────
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
async function track({ sessionId, eventId, stage, source, userAgent }) {
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
const device = deviceFromUA(userAgent);
if (!device) return false; // bots
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return false;
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
return true;
}
/** 'submit' for a registration made on the public registration page. */
async function recordSubmit(registration, sessionId) {
if (!validSession(sessionId) || !registration?.id) return;
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
await prisma.analyticsEvent.createMany({
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
skipDuplicates: true,
});
}
async function paidSessionsFor(eventIds, sessionIds) {
if (!sessionIds.length) return new Set();
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
select: { analyticsSessionId: true },
});
return new Set(regs.map(r => r.analyticsSessionId));
}
/**
* The funnel for some events between two dates (by the day each stage was reached).
* Adds a by-ticket-option breakdown from the tracked registrations.
*/
async function funnelFor(eventIds, { from, to } = {}) {
const range = {};
if (from) range.gte = new Date(from);
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
// A session is per event (the same browser can look at several events).
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
const daily = await prisma.analyticsDaily.findMany({
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
select: { day: true, stage: true, source: true, device: true, sessions: true },
});
const report = funnelReport(sessionsFrom(keyed, paid), daily);
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
});
const byOption = new Map();
for (const r of regs) {
for (const ro of r.registrationOptions) {
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
c.submitted += 1;
if (r.status === 'paid') c.paid += 1;
byOption.set(key, c);
}
}
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
}
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
async function rollupOldEvents(now = new Date()) {
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
if (groups.length === 0) return 0;
const byEvent = new Map();
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
let rolled = 0;
for (const [eventId, sessionIds] of byEvent) {
for (let i = 0; i < sessionIds.length; i += 500) {
const batch = sessionIds.slice(i, i + 500);
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
const paid = await paidSessionsFor([eventId], batch);
const rows = rollupRows(sessionsFrom(events, paid));
await prisma.$transaction(async (tx) => {
for (const r of rows) {
await tx.analyticsDaily.upsert({
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
update: { sessions: { increment: r.sessions } },
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
});
}
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
});
rolled += batch.length;
}
}
return rolled;
}
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
+255
View File
@@ -0,0 +1,255 @@
/**
* Bundle deals — tickets sold together at a better price. Four kinds:
* - exact: these tickets in these numbers for a set price, e.g. 5 Adult + 3 Child for R500.
* - mix: any `setSize` of the qualifying tickets for a set price, e.g. any 4 for R250.
* - free: any `setSize` qualifying tickets with the cheapest `freeCount` free, e.g.
* buy 3 get 1 free (setSize 4, freeCount 1).
* - group: `setSize` or more qualifying tickets, all of them at `price` each
* (groupPricing per_ticket) or `price`% off (percent).
*
* Rules:
* - Applied automatically: no code needed. exact, mix and free apply as many times as
* the registration's items allow; leftover items are charged as usual. A group
* discount applies once, to every qualifying ticket.
* - Best price wins: a bundle only counts when it beats what its items cost at the
* prices actually charged (early-bird, staff price), so it never makes anything
* dearer. A group discount never raises a ticket's price. The most expensive matching
* units go into a bundle first, and when several bundles compete the one saving the
* most is applied first.
* - A bundle item with no variant matches any variant of its option.
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
* to the coupon code at their share of the bundle price (see utils/coupons.js).
* - The discount is stored on Registration.bundleDiscount (and included in
* discountTotal), with one RegistrationBundle row per bundle used.
* - A registration keeps any bundle it already has, even after the bundle is switched
* off or its dates pass, so editing a registration never takes a deal away.
*/
const prisma = require('../config/db');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const KINDS = ['exact', 'mix', 'free', 'group'];
/** Whether a bundle's settings make sense (bad rows are skipped, never applied). */
function isBundleUsable(bundle) {
const items = (bundle?.items || []).filter(i => i && i.eventOptionId);
if (items.length === 0 || !(Number(bundle.price) >= 0)) return false;
const kind = bundle.kind || 'exact';
const size = Number(bundle.setSize);
if (kind === 'exact') return items.some(i => Number(i.quantity) > 0);
if (!Number.isInteger(size) || size < 1) return false;
if (kind === 'free') return Number.isInteger(Number(bundle.freeCount)) && bundle.freeCount >= 1 && bundle.freeCount < size;
if (kind === 'group' && bundle.groupPricing === 'percent') return Number(bundle.price) > 0 && Number(bundle.price) <= 100;
return KINDS.includes(kind);
}
/** Whether a bundle can be offered right now (switched on, within its dates, set up properly). */
function isBundleAvailable(bundle, now = new Date()) {
if (!bundle || !bundle.isActive) return false;
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
return isBundleUsable(bundle);
}
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
const qualifies = (bundle, line) => (bundle.items || []).some(i => itemMatches(i, line));
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
function takeDearest(pool, left, need, matches) {
const takes = [];
const candidates = pool
.map((p, index) => ({ p, index }))
.filter(({ p, index }) => left[index] > 0 && matches(p.line))
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
for (const { p, index } of candidates) {
if (need === 0) break;
const q = Math.min(need, left[index]);
left[index] -= q;
need -= q;
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
}
return need > 0 ? null : takes;
}
// Every unit at the same share of a set price.
const atSetPrice = (takes, price) => {
const charged = takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0);
const ratio = charged > 0 ? price / charged : 0;
return { charged, takes: takes.map(t => ({ ...t, newPrice: t.unitPrice * ratio })) };
};
/**
* Works out one application of `bundle` against what's left in `pool`, without changing
* it. Returns { takes: [{ index, quantity, newPrice }], saving } or null when the
* bundle can't apply.
*/
function tryBundle(bundle, pool) {
const left = pool.map(p => p.remaining);
const kind = bundle.kind || 'exact';
const price = Number(bundle.price || 0);
let priced;
if (kind === 'exact') {
// Variant-specific items first, so an "any variant" item can't use up the units a
// specific one needs.
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
const takes = [];
for (const item of items) {
const got = takeDearest(pool, left, Number(item.quantity), line => itemMatches(item, line));
if (!got) return null;
takes.push(...got);
}
priced = atSetPrice(takes, price);
} else if (kind === 'mix' || kind === 'free') {
const takes = takeDearest(pool, left, Number(bundle.setSize), line => qualifies(bundle, line));
if (!takes) return null;
if (kind === 'mix') {
priced = atSetPrice(takes, price);
} else {
// The cheapest freeCount units cost nothing; split takes so only those are free.
let free = Number(bundle.freeCount);
const out = [];
for (const t of [...takes].sort((a, b) => a.unitPrice - b.unitPrice)) {
const f = Math.min(free, t.quantity);
free -= f;
if (f > 0) out.push({ ...t, quantity: f, newPrice: 0 });
if (t.quantity - f > 0) out.push({ ...t, quantity: t.quantity - f, newPrice: t.unitPrice });
}
priced = { charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0), takes: out };
}
} else if (kind === 'group') {
const total = pool.reduce((s, p, i) => s + (qualifies(bundle, p.line) ? left[i] : 0), 0);
if (total < Number(bundle.setSize)) return null;
const takes = takeDearest(pool, left, total, line => qualifies(bundle, line));
const pct = Math.min(100, Math.max(0, price)) / 100;
priced = {
charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0),
takes: takes.map(t => ({ ...t, newPrice: bundle.groupPricing === 'percent' ? t.unitPrice * (1 - pct) : Math.min(t.unitPrice, price) })),
};
} else {
return null;
}
const after = priced.takes.reduce((s, t) => s + t.quantity * t.newPrice, 0);
return { takes: priced.takes, saving: priced.charged - after };
}
/**
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
*
* @param {Array} bundles - [{ id, name, kind, price, setSize, freeCount, groupPricing,
* items: [{ eventOptionId, variantId?, quantity }] }]
* already filtered to the ones this registration may use
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
* `lines` are the input lines with bundled units split out at their bundle
* price (flagged `bundled: true`), ready for the coupon calculation.
*/
function applyBundles(bundles, lines) {
const usable = (bundles || []).filter(isBundleUsable);
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
const byBundle = new Map();
// Each round applies the single best-saving bundle once; stops when nothing saves.
const maxRounds = pool.reduce((s, p) => s + p.remaining, 0);
for (let round = 0; round < maxRounds; round++) {
let best = null;
for (const bundle of usable) {
// A group discount covers every qualifying ticket in one go, so it applies once.
if (bundle.kind === 'group' && byBundle.has(bundle.id)) continue;
const attempt = tryBundle(bundle, pool);
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
}
if (!best) break;
const { bundle, attempt } = best;
for (const t of attempt.takes) {
pool[t.index].remaining -= t.quantity;
pool[t.index].bundled.push({ quantity: t.quantity, unitPrice: t.newPrice });
}
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
entry.count += 1;
entry.discount += attempt.saving;
byBundle.set(bundle.id, entry);
}
const applications = [...byBundle.values()].map(a => ({ ...a, discount: round2(a.discount) }));
const outLines = [];
for (const p of pool) {
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
for (const b of p.bundled) {
outLines.push({ ...p.line, quantity: b.quantity, unitPrice: b.unitPrice, basePrice: b.unitPrice, bundled: true });
}
}
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
}
const itemLabel = (i) => `${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`;
/** "5 × Adult + 3 × Child (Large)", or for the other kinds "Adult, Child". Needs names loaded. */
function describeBundleItems(bundle) {
const items = bundle?.items || [];
if ((bundle?.kind || 'exact') === 'exact') return items.map(i => `${i.quantity} × ${itemLabel(i)}`).join(' + ');
return items.map(itemLabel).join(', ');
}
/**
* The deal in one line, e.g. "2 × Adult + 3 × Child for R220", "Any 4 of Adult, Child
* for R250", "Buy 3, get 1 free: Adult, Child", "10+ tickets (Adult, Child): R70 each".
*/
function describeBundle(bundle) {
const kind = bundle?.kind || 'exact';
const items = describeBundleItems(bundle);
const price = Number(bundle?.price || 0);
if (kind === 'mix') return `Any ${bundle.setSize} of ${items} for ${fmtR(price)}`;
if (kind === 'free') return `Buy ${bundle.setSize - bundle.freeCount}, get ${bundle.freeCount} free: ${items}`;
if (kind === 'group') {
return `${bundle.setSize}+ tickets (${items}): ${bundle.groupPricing === 'percent' ? `${price}% off` : `${fmtR(price)} each`}`;
}
return `${items} for ${fmtR(price)}`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
const BUNDLE_INCLUDE = {
items: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } } } },
};
// A registration's bundle deals, for API responses and receipts.
const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, discount: true, bundle: { select: { id: true, name: true } } } };
/**
* Bundles a registration on this event may use: the ones available now, plus any in
* `keepIds` (bundles the registration already has).
*/
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
if (!eventId) return [];
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && isBundleUsable(b)));
}
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
async function saveBundleApplications(registrationId, existing, applications, db = prisma) {
const key = (list) => JSON.stringify([...list].map(a => [a.bundleId, a.count, round2(a.discount)]).sort());
if (key(existing || []) === key(applications)) return;
await db.registrationBundle.deleteMany({ where: { registrationId } });
if (applications.length > 0) {
await db.registrationBundle.createMany({
data: applications.map(a => ({ registrationId, bundleId: a.bundleId, count: a.count, discount: a.discount })),
});
}
}
module.exports = {
KINDS,
isBundleUsable,
isBundleAvailable,
applyBundles,
describeBundleItems,
describeBundle,
loadBundlesFor,
saveBundleApplications,
BUNDLE_INCLUDE,
BUNDLE_APPS_INCLUDE,
};
+152 -8
View File
@@ -6,10 +6,114 @@ const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
// Standard South African Rand note/coin denominations used for the cash count grid.
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
const WRITE_OFF_CATEGORIES = ['card_fees', 'bank_charges', 'cash_shortfall', 'other'];
const PENDING_OUTCOMES = ['received', 'written_off'];
function emptyByMethod(fill = 0) {
return { cash: fill, card: fill, eft: fill, other: fill };
}
const cents = (n) => Math.round(n * 100) / 100;
const sum = (rows) => rows.reduce((s, r) => s + r.amount, 0);
// A non-negative amount rounded to cents, or null when left blank.
function amountOrNull(value, label) {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or more`);
return cents(n);
}
/**
* Where one method's expected takings ended up, as entered on the cashup screen. Pure.
*
* actual = in bank + on hand + pending (money held or on its way); write-offs explain money
* that's gone (card fees, bank charges, a cash shortfall). Whatever is left —
* expected − actual − write-offs — is unaccounted for. Cash has no pending; when staff
* counted it (per-person counts), the count is all the cash there is, so whatever wasn't
* deposited (or taken by the bank as a deposit fee) is on hand.
*
* @param {object} p
* @param {string} p.method - 'cash' | 'card' | 'eft' | 'other'
* @param {number} p.expected - expected amount for the method (payments − costs paid from it)
* @param {number|null} [p.counted] - cash counted by staff, or null when nobody counted
* @param {object} [p.input] - { bankAmount, onHandAmount, pendingAmount, pendingReference, writeOffs: [{ category, amount, reason }] }
*/
function settleLine({ method, expected, counted = null, input = {} }) {
const bankAmount = amountOrNull(input.bankAmount, 'In bank');
let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null;
const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending');
const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : [])
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
.map(w => {
if (!WRITE_OFF_CATEGORIES.includes(w.category)) throw new Error('Choose what each write-off is for');
const amount = amountOrNull(w.amount, 'Write-off');
if (!(amount > 0)) throw new Error('A write-off must be more than R0');
return { category: w.category, amount, reason: String(w.reason || '').trim().slice(0, 200) || null };
});
const writeOffTotal = cents(sum(writeOffs));
if (method === 'cash' && counted !== null && counted !== undefined) {
// A deposit fee comes out of cash that was counted and banked, so the count is what
// reached the bank + the fee + what's still on hand.
const depositFees = cents(sum(writeOffs.filter(w => w.category === 'bank_charges')));
if ((bankAmount || 0) + depositFees > counted + 0.005) throw new Error('More cash was deposited than was counted');
onHandAmount = cents(counted - (bankAmount || 0) - depositFees);
}
const parts = [bankAmount, onHandAmount, pendingAmount];
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
return {
bankAmount,
onHandAmount,
pendingAmount,
pendingReference: pendingAmount ? (String(input.pendingReference || '').trim().slice(0, 200) || null) : null,
writeOffs,
writeOffTotal,
actual,
variance: actual === null ? null : cents(actual - expected),
unaccounted: entered ? cents(expected - actual - writeOffTotal) : null,
};
}
/**
* A closed cashup line with any later pending resolutions folded in. Pure.
* 'received' moves pending money into the bank (actual unchanged); 'written_off' turns it
* into a write-off (actual goes down). Lines from before settlement existed have only
* actualAmount, so their unaccounted is the old variance.
*/
function applyPendingResolutions(line) {
const resolutions = line.pendingResolutions || [];
const received = cents(sum(resolutions.filter(r => r.outcome === 'received')));
const lateWriteOffs = resolutions.filter(r => r.outcome === 'written_off');
const writtenOff = cents(sum(lateWriteOffs));
const pending = line.pendingAmount || 0;
const actual = line.actualAmount === null || line.actualAmount === undefined ? null : cents(line.actualAmount - writtenOff);
const writeOffTotal = cents((line.writeOffTotal || 0) + writtenOff);
const expected = line.expectedAmount || 0;
return {
bank: line.bankAmount == null && received === 0 ? null : cents((line.bankAmount || 0) + received),
onHand: line.onHandAmount ?? null,
pending: line.pendingAmount ?? null,
pendingReference: line.pendingReference || null,
pendingReceived: received,
pendingOutstanding: cents(Math.max(pending - received - writtenOff, 0)),
writeOffs: [
...(line.writeOffs || []).map(w => ({ id: w.id, category: w.category, amount: w.amount, reason: w.reason, createdAt: w.createdAt, afterClose: false })),
...lateWriteOffs.map(r => ({ id: r.id, category: r.category || 'other', amount: r.amount, reason: r.notes, createdAt: r.createdAt, afterClose: true })),
],
writeOffTotal,
actual,
variance: actual === null ? null : cents(actual - expected),
unaccounted: actual === null && writeOffTotal === 0 ? null : cents(expected - (actual || 0) - writeOffTotal),
};
}
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
@@ -88,7 +192,13 @@ async function computeEventFinancials(eventId) {
prisma.eventCashup.findMany({
where: { eventId },
include: {
lines: { include: { denominations: true } },
lines: {
include: {
denominations: true,
writeOffs: { orderBy: { createdAt: 'asc' } },
pendingResolutions: { include: { performedBy: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }
}
},
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
@@ -169,29 +279,55 @@ async function computeEventFinancials(eventId) {
const out = {};
for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m);
const settled = line ? applyPendingResolutions(line) : null;
out[m] = {
lineId: line ? line.id : null,
expected: line ? line.expectedAmount : expectedCashByMethod[m],
actual: line && line.actualAmount != null ? line.actualAmount : null,
variance: line && line.variance != null ? line.variance : null,
actual: settled ? settled.actual : null,
variance: settled ? settled.variance : null,
notes: line ? line.notes : null,
denominations: line ? line.denominations : []
denominations: line ? line.denominations : [],
bank: settled ? settled.bank : null,
onHand: settled ? settled.onHand : null,
pending: settled ? settled.pending : null,
pendingReference: settled ? settled.pendingReference : null,
pendingReceived: settled ? settled.pendingReceived : 0,
pendingOutstanding: settled ? settled.pendingOutstanding : 0,
pendingResolutions: line ? line.pendingResolutions : [],
writeOffs: settled ? settled.writeOffs : [],
writeOffTotal: settled ? settled.writeOffTotal : 0,
unaccounted: settled ? settled.unaccounted : null
};
}
return out;
})()
} : null;
// Money written off at (or after) the cashup: card fees, bank charges, shortfalls.
const writeOffsByMethod = emptyByMethod();
const writeOffsByCategory = Object.fromEntries(WRITE_OFF_CATEGORIES.map(c => [c, 0]));
let pendingOutstandingTotal = 0;
for (const m of ALL_METHODS) {
const r = reconciled?.byMethod?.[m];
if (!r) continue;
writeOffsByMethod[m] = r.writeOffTotal;
pendingOutstandingTotal += r.pendingOutstanding;
for (const w of r.writeOffs) writeOffsByCategory[w.category] = cents((writeOffsByCategory[w.category] || 0) + w.amount);
}
const totalWriteOffs = cents(ALL_METHODS.reduce((s, m) => s + writeOffsByMethod[m], 0));
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
// Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure —
// costs get subtracted from profit exactly once, via totalCosts, never twice.
// Tagged costs and write-offs are added back so income stays a *gross* figure — costs and
// write-offs each come off profit exactly once, below, never twice. (Unaccounted money
// isn't added back: it lowers income, like any variance.)
const effectiveGrossIncomeByMethod = emptyByMethod();
for (const m of ALL_METHODS) {
const actual = reconciled?.byMethod?.[m]?.actual;
const base = actual != null ? actual : expectedCashByMethod[m];
const base = actual != null ? actual + writeOffsByMethod[m] : expectedCashByMethod[m];
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
}
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts;
const netProfit = effectiveTotalRevenue - totalCosts - totalWriteOffs;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
@@ -221,6 +357,10 @@ async function computeEventFinancials(eventId) {
reconciled,
effectiveGrossIncomeByMethod,
effectiveTotalRevenue,
writeOffsByMethod,
writeOffsByCategory,
totalWriteOffs,
pendingOutstandingTotal: cents(pendingOutstandingTotal),
netProfit,
unallocatedDonations,
unallocatedDonationsTotal,
@@ -362,6 +502,10 @@ module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
WRITE_OFF_CATEGORIES,
PENDING_OUTCOMES,
settleLine,
applyPendingResolutions,
bucketForMethod,
isDonationLeg,
assertEventOpen,
+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,
};
+43 -24
View File
@@ -15,9 +15,12 @@
* standard price, and only the part that beats the early-bird price is discounted.
* - Restricted codes (users, roles, email domain) need a known account, so guests
* can't use them; a match on any one restriction is enough.
* - Bundle deals (utils/bundles.js) come off first; the coupon is worked out on what's
* left. discountTotal = bundle discount + coupon discounts.
*/
const prisma = require('../config/db');
const { applyBundles, loadBundlesFor, saveBundleApplications } = require('./bundles');
function normalizeCode(code) {
return String(code || '').trim().toUpperCase();
@@ -166,9 +169,15 @@ const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
const REGISTRATION_INCLUDE = {
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
bundleApplications: true,
user: { select: { id: true, role: true, email: true } },
};
/** Bundle deals a basket gets before any coupon (nothing saved). */
async function previewBundles(eventId, lines, db = prisma) {
return applyBundles(await loadBundlesFor(eventId, {}, db), lines);
}
async function findCouponByCode(code, db = prisma) {
const normalized = normalizeCode(code);
if (!normalized) return null;
@@ -187,14 +196,16 @@ async function countCouponUses(couponId, userId, db = prisma) {
/**
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
* would give. Throws a coupon error with a user-facing message when it can't be used.
* would give after the basket's bundle deals (returned as `bundles`). Throws a coupon
* error with a user-facing message when it can't be used.
*/
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
const coupon = await findCouponByCode(code);
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
const bundles = await previewBundles(eventId, lines);
const check = validateCoupon(coupon, { eventId, user, lines: bundles.lines, deferRestrictions, ...uses });
if (!check.ok) throw couponError(check.message);
return { coupon, discount: computeCouponDiscount(coupon, lines) };
return { coupon, discount: computeCouponDiscount(coupon, bundles.lines), bundles };
}
/**
@@ -219,7 +230,7 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
: 'A code has already been applied to this registration.');
}
const lines = buildCouponLines(registration);
const { lines } = await registrationBundles(registration, tx);
const uses = await countCouponUses(coupon.id, registration.userId, tx);
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
if (!check.ok) throw couponError(check.message);
@@ -228,31 +239,37 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
const redemption = await tx.couponRedemption.create({
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
});
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
await recalculateRegistrationDiscount(registrationId, tx);
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
return { coupon, discount, redemption };
});
}
/** A loaded registration's bundle deals (bundles it already has stay usable). */
async function registrationBundles(registration, db = prisma) {
const bundles = await loadBundlesFor(registration.eventId, { keepIds: (registration.bundleApplications || []).map(a => a.bundleId) }, db);
return applyBundles(bundles, buildCouponLines(registration));
}
/**
* Re-works active discounts after a registration's items change: percentage and comp
* codes are recalculated over the new items, fixed codes keep their amount (capped at
* what's still in scope). Pass `tx` to run inside the caller's transaction.
* Re-works a registration's discounts: bundle deals first, then the coupon on what's
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
* coupon's amount (capped the same way): used when only prices changed, e.g. an
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
* discountTotal. Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) return 0;
if (registration.couponRedemptions.length === 0) {
if (registration.discountTotal !== 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return 0;
}
const lines = buildCouponLines(registration);
let total = 0;
const bundles = await registrationBundles(registration, db);
await saveBundleApplications(registrationId, registration.bundleApplications, bundles.applications, db);
const lines = bundles.lines;
let total = bundles.discount;
for (const r of registration.couponRedemptions) {
let amount;
if (r.coupon.type === 'fixed') {
if (r.coupon.type === 'fixed' || keepCouponAmounts) {
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
amount = round2(Math.min(r.discountAmount, inScope));
} else {
@@ -264,21 +281,22 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma) {
total += amount;
}
total = round2(total);
if (Math.abs(total - registration.discountTotal) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
}
return total;
}
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
/**
* Releases a registration's coupon uses so they can be claimed again (cancelled, or the
* code removed). Bundle deals stay.
*/
async function releaseRegistrationCoupons(registrationId, db = prisma) {
const released = await db.couponRedemption.updateMany({
where: { registrationId, releasedAt: null },
data: { releasedAt: new Date() },
});
if (released.count > 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
if (released.count > 0) await recalculateRegistrationDiscount(registrationId, db);
return released.count;
}
@@ -292,6 +310,7 @@ module.exports = {
findCouponByCode,
countCouponUses,
previewCoupon,
previewBundles,
applyCouponToRegistration,
recalculateRegistrationDiscount,
releaseRegistrationCoupons,
+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;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
+17 -18
View File
@@ -2,7 +2,7 @@ const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
const { discountLines } = require('./pdfDocs');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -90,7 +90,7 @@ async function loadRegistrationFull(registrationId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -137,7 +137,7 @@ async function loadPaymentFull(paymentId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -149,8 +149,8 @@ async function loadPaymentFull(paymentId) {
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
function selectionsTable(registrationOptions, couponLine = null) {
/** Renders the selections summary table, with each discount (bundle, coupon) as its own row. */
function selectionsTable(registrationOptions, discounts = []) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
@@ -164,10 +164,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
if (couponLine) {
for (const line of discounts || []) {
rows.push(`<tr>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${line.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(line.amount)}</td>
</tr>`);
}
@@ -181,11 +181,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
</table>`;
}
/** Plain-text selections, one bullet per item plus the coupon discount. */
/** Plain-text selections, one bullet per item plus each discount. */
function selectionsText(reg) {
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
const coupon = couponDiscountLine(reg);
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
for (const d of discountLines(reg)) lines.push(` • ${d.label} — -${fmtAmount(d.amount)}`);
return lines.join('\n');
}
@@ -317,7 +316,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${selectionsTable(reg.registrationOptions, discountLines(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
@@ -358,7 +357,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${selectionsTable(reg.registrationOptions, discountLines(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
@@ -956,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).
*/
@@ -969,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,
};
+30 -12
View File
@@ -95,16 +95,34 @@ function buildLineItems(registrationOptions) {
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
* Registration.discountTotal, which is always present.
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
* Registration.discountTotal less the bundle part, both always present.
*/
function couponDiscountLine(registration) {
const amount = Number(registration?.discountTotal || 0);
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
}
/**
* The registration's bundle-deal saving, labelled with the bundle names when
* `bundleApplications[].bundle.name` is loaded, e.g. "Bundle: Family deal ×2".
*/
function bundleDiscountLine(registration) {
const amount = Number(registration?.bundleDiscount || 0);
if (!(amount > 0.001)) return null;
const names = (registration.bundleApplications || [])
.map(a => a.bundle?.name ? `${a.bundle.name}${a.count > 1 ? ` ×${a.count}` : ''}` : null)
.filter(Boolean);
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
}
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
function discountLines(registration) {
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
@@ -210,13 +228,13 @@ async function generateReceiptPdf(payment) {
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
const coupon = couponDiscountLine(reg);
if (coupon) {
for (const line of discountLines(reg)) {
y += 8;
doc.font('Helvetica').fontSize(10).fillColor('#059669')
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 14;
.text(`${line.label}: -${fmtAmount(line.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 6;
}
if (discountLines(reg).length > 0) y += 8;
y += 20;
const boxW = 160, boxH = 32;
@@ -259,7 +277,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const coupon = couponDiscountLine(registration);
const extraDiscounts = discountLines(registration);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
@@ -323,10 +341,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01 || coupon) {
if (discount > 0.01 || extraDiscounts.length > 0) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
if (discount > 0.01) totalLine(extraDiscounts.length ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
for (const line of extraDiscounts) totalLine(line.label.toUpperCase(), `-${fmtAmount(line.amount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
@@ -369,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber, 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;
+17 -4
View File
@@ -25,10 +25,12 @@
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
* never touched.
* never touched. When a price changes, bundle deals are re-worked (they depend on the
* prices charged) and coupon amounts are kept.
*/
const prisma = require('../config/db');
const { withLatePrice } = require('./sessions');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
@@ -90,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) {
@@ -146,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) {
@@ -269,7 +272,12 @@ async function refreshPricingForRegistration(registrationId) {
return false;
}));
return { changed: results.some(Boolean) };
const changed = results.some(Boolean);
if (changed) {
const { recalculateRegistrationDiscount } = require('./coupons');
await recalculateRegistrationDiscount(registrationId, prisma, { keepCouponAmounts: true });
}
return { changed };
}
/**
@@ -346,6 +354,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
// Never send which named attendees have an account: the person who typed their email or
// phone mustn't be able to tell (the link is used server-side only).
if (Array.isArray(registration.attendees)) {
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
}
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
+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,
};
+2 -2
View File
@@ -4,14 +4,14 @@
* take effect without a server restart.
* Falls back to environment variables when a key is not found in the DB.
*
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
* Encrypted keys (SMTP credentials, Yoco secrets) are automatically decrypted on read.
*/
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']);
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));
+153 -2
View File
@@ -1,6 +1,12 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* Privacy first: the sender only types the new holder's name and email/phone — there is
* no user search, and nothing tells the sender whether those details belong to an
* account. A TicketTransferRequest is created and a link is sent to the details typed;
* nothing changes on the ticket until the new holder accepts (requestTransfer →
* acceptTransfer). Accepting runs the transfer itself:
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
@@ -17,6 +23,13 @@ const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
const OFFER_DAYS = 7;
const crypto = require('crypto');
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
function transferLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/transfer/${token}`;
}
function transferError(message, statusCode = 400) {
const err = new Error(message);
@@ -71,7 +84,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
@@ -126,6 +139,141 @@ async function transferTicket({ ticketId, actor, ...holder }) {
};
}
/** Checks a transfer the way transferTicket will, without touching anything. */
async function checkTransfer({ ticketId, actor, ...holder }) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
return { ticket, clean };
}
async function sendTransferOffer(request, ticket, token, fromName) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = transferLink(token);
const event = ticket.event;
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
const sent = [];
if (request.toEmail) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
${ctaButton('View and accept the ticket', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
`, { preheader: subject });
try {
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[transfer] offer email failed:', e?.message || e);
}
}
if (request.toPhone && isValidZAPhone(request.toPhone)) {
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
* the ticket changes yet, and no account is looked up.
*/
async function requestTransfer({ ticketId, actor, ...holder }) {
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
const token = crypto.randomBytes(24).toString('hex');
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
const request = await prisma.$transaction(async (tx) => {
// One open offer per ticket: a new one replaces the old.
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
return tx.ticketTransferRequest.create({
data: {
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
tokenHash: hashToken(token), expiresAt,
},
});
});
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
const sent = await sendTransferOffer(request, ticket, token, fromName);
return { request, sent };
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
const request = await prisma.ticketTransferRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: {
requestedBy: { select: { id: true, name: true, role: true } },
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
},
});
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
request.status = 'expired';
}
return request;
}
/** The new holder accepts: the transfer happens now, with every check run again. */
async function acceptTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
// Claim it first so a double-click can't run the transfer twice.
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
try {
const d = request.details || {};
const result = await transferTicket({
ticketId: request.ticketId, actor: request.requestedBy,
name: request.toName, email: request.toEmail, phone: request.toPhone,
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
});
return { ...result, request };
} catch (e) {
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
throw e;
}
}
async function declineTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
return request;
}
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
async function cancelTransfer(ticketId, actor) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
if (!ticket) throw transferError('Ticket not found', 404);
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
@@ -137,4 +285,7 @@ function transferredMessage(transfer) {
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
module.exports = {
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};
+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);
}
+2 -3
View File
@@ -47,11 +47,10 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
const coupon = couponDiscountLine(reg);
const { discountLines } = require('./pdfDocs');
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
...discountLines(reg).map(d => `- ${d.label} — -${fmtAmount(d.amount)}`),
].join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+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,
+137
View File
@@ -0,0 +1,137 @@
/**
* Yoco configuration and API helpers.
*
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
* database has no value, so older deployments keep working until an admin saves them.
*/
const axios = require('axios');
const prisma = require('../config/db');
const { getSetting } = require('./settingsCache');
const YOCO_API = 'https://payments.yoco.com/api';
const KEY_SECRET = 'yoco_secret_key';
const KEY_WEBHOOK = 'yoco_webhook_secret';
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
async function getYocoConfigSource() {
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
return {
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
};
}
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
/**
* Mask a secret like a card number: keep the prefix and the last 3 characters.
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
*/
function maskSecret(value) {
if (!value) return '';
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
const rest = value.slice(prefix.length);
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
}
function yocoMode(key) {
if (!key) return 'unknown';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'unknown';
}
function notConfiguredError() {
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
err.statusCode = 503;
return err;
}
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
async function yocoAuthHeader() {
const key = await getYocoSecretKey();
if (!key) throw notConfiguredError();
return `Bearer ${key}`;
}
/** Turn a Yoco API error into a message an admin can act on. */
function friendlyYocoError(e) {
const status = e.response?.status;
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
let message;
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
else message = e.message || 'Could not reach Yoco.';
const err = new Error(message);
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
return err;
}
/**
* 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 },
timeout: 15000,
});
return res.data;
} catch (e) {
throw friendlyYocoError(e);
}
}
async function listWebhooks(key) {
const data = await yocoRequest('get', '/webhooks', undefined, key);
return data?.subscriptions || [];
}
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
/**
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
* current request (correct behind a proxy when TRUST_PROXY is set).
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
function webhookUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'The webhook URL must be a public address, not localhost.';
}
return null;
}
module.exports = {
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
maskSecret, yocoMode, yocoAuthHeader,
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
defaultWebhookUrl, webhookUrlProblem,
};
+70
View File
@@ -0,0 +1,70 @@
const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
describe('deviceFromUA', () => {
test('mobile, tablet, desktop and bots', () => {
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
expect(deviceFromUA('')).toBeNull();
});
});
describe('cleanSource', () => {
test('tidies, defaulting to direct', () => {
expect(cleanSource(' Facebook ')).toBe('facebook');
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
expect(cleanSource('')).toBe('direct');
});
});
describe('sessionsFrom', () => {
test('a session counts as having reached every earlier stage', () => {
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
expect(s.reach).toBe(1);
expect(s.days.view).toBe('2026-01-02');
});
test('paid only counts for sessions that submitted', () => {
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
const a = out.find(s => s.sessionId === 'a');
expect(a.reach).toBe(3);
expect(a.days.paid).toBe('2026-01-03');
expect(a.source).toBe('fb');
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
});
});
describe('funnelReport', () => {
const sessions = sessionsFrom([
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
], new Set(['a']));
test('counts and conversion per stage', () => {
const r = funnelReport(sessions);
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
expect(r.conversion.start).toBe(50);
expect(r.conversion.paid).toBe(25);
expect(r.conversion.startToSubmit).toBe(50);
});
test('daily trend and breakdowns', () => {
const r = funnelReport(sessions);
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
});
test('rolled-up rows add to the raw counts', () => {
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
const r = funnelReport(sessions, daily);
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
});
});
+194
View File
@@ -0,0 +1,194 @@
const { applyBundles, isBundleAvailable } = require('../src/utils/bundles');
const { computeCouponDiscount } = require('../src/utils/coupons');
const line = (eventOptionId, quantity, unitPrice, extra = {}) => ({ eventOptionId, variantId: null, quantity, unitPrice, basePrice: unitPrice, ...extra });
const family = { id: 'b1', name: 'Family deal', price: 500, items: [{ eventOptionId: 'adult', quantity: 5 }, { eventOptionId: 'child', quantity: 3 }] };
const charged = (lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
describe('applyBundles', () => {
test('5 adults + 3 children for R500 instead of R550', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50)]);
expect(r.discount).toBe(50);
expect(r.applications).toEqual([{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 }]);
expect(charged(r.lines)).toBeCloseTo(500, 6);
expect(r.lines.every(l => l.bundled)).toBe(true);
});
test('applies as many times as fits, leftovers at full price', () => {
const r = applyBundles([family], [line('adult', 11, 80), line('child', 7, 50)]);
expect(r.applications[0].count).toBe(2);
expect(r.discount).toBe(100);
// 1 adult and 1 child left over at full price.
expect(charged(r.lines)).toBeCloseTo(1000 + 80 + 50, 6);
expect(r.lines.filter(l => !l.bundled).map(l => [l.eventOptionId, l.quantity])).toEqual([['adult', 1], ['child', 1]]);
});
test('nothing applies when the items are not all there', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 2, 50)]);
expect(r.discount).toBe(0);
expect(r.applications).toEqual([]);
expect(charged(r.lines)).toBe(500);
});
test('best price wins: no discount when early-bird prices already beat the bundle', () => {
const r = applyBundles([family], [line('adult', 5, 60, { basePrice: 80 }), line('child', 3, 40, { basePrice: 50 })]);
expect(r.discount).toBe(0);
expect(charged(r.lines)).toBe(420);
});
test('the most expensive units go into the bundle first', () => {
// Two adults bought at early-bird R60, four at R80: the bundle should take the R80 ones.
const r = applyBundles([family], [line('adult', 2, 60), line('adult', 4, 80), line('child', 3, 50)]);
// 4×80 + 1×60 + 3×50 = 530 → R30 saving.
expect(r.discount).toBe(30);
expect(r.lines.find(l => !l.bundled)).toMatchObject({ eventOptionId: 'adult', quantity: 1, unitPrice: 60 });
});
test('when bundles compete, the bigger saving wins', () => {
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
const r = applyBundles([family, pair], [line('adult', 5, 80), line('child', 3, 50)]);
// Family saves 50 once; pairs save 10 each (two of them = 20), and would block the family deal.
expect(r.applications.map(a => a.bundleId)).toEqual(['b1']);
expect(r.discount).toBe(50);
});
test('different bundles can stack on different items', () => {
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
const r = applyBundles([family, pair], [line('adult', 7, 80), line('child', 3, 50)]);
expect(r.applications).toEqual([
{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 },
{ bundleId: 'b2', name: 'Pair', count: 1, discount: 10 },
]);
});
test('a variant-specific item only takes that variant; an any-variant item takes the rest', () => {
const shirts = { id: 'b3', name: 'Shirts', price: 300, items: [{ eventOptionId: 'shirt', variantId: 'L', quantity: 1 }, { eventOptionId: 'shirt', quantity: 2 }] };
const lines = [
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 150, basePrice: 150 },
{ eventOptionId: 'shirt', variantId: 'S', quantity: 2, unitPrice: 120, basePrice: 120 },
];
const r = applyBundles([shirts], lines);
expect(r.discount).toBe(90);
const noLarge = applyBundles([shirts], [{ ...lines[1], quantity: 3 }]);
expect(noLarge.discount).toBe(0);
});
test('a coupon after the bundle works out on the bundle price', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50), line('lunch', 2, 40)]);
const tenPercent = { type: 'percent', value: 10, stackEarlyBird: true, options: [] };
// (500 + 80) × 10% = 58
expect(computeCouponDiscount(tenPercent, r.lines)).toBe(58);
// Best-price-wins coupons also see the bundle price as the base, so it still gives 10%.
expect(computeCouponDiscount({ ...tenPercent, stackEarlyBird: false }, r.lines)).toBe(58);
const childOnly = { ...tenPercent, options: [{ eventOptionId: 'child' }] };
// The 3 children cost 150 of 550 → 136.36 of the R500 bundle → 13.64 off.
expect(computeCouponDiscount(childOnly, r.lines)).toBe(13.64);
});
test('a free bundle (R0) still works', () => {
const r = applyBundles([{ ...family, price: 0 }], [line('adult', 5, 80), line('child', 3, 50)]);
expect(r.discount).toBe(550);
expect(charged(r.lines)).toBe(0);
});
test('bundles with no items are ignored', () => {
expect(applyBundles([{ id: 'x', name: 'x', price: 1, items: [] }], [line('adult', 3, 80)]).discount).toBe(0);
});
});
describe('pick any (mix)', () => {
const anyFour = { id: 'm', name: 'Any 4', kind: 'mix', price: 250, setSize: 4, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('2 adults + 2 children or 4 adults both make a set', () => {
expect(applyBundles([anyFour], [line('adult', 2, 80), line('child', 2, 50)]).discount).toBe(10);
expect(applyBundles([anyFour], [line('adult', 4, 80)]).discount).toBe(70);
});
test('repeats, dearest tickets first, leftovers at full price', () => {
const r = applyBundles([anyFour], [line('adult', 5, 80), line('child', 4, 50)]);
// Set 1: 4 adults (320 → 250). Set 2: 1 adult + 3 children (230): no saving, so not applied.
expect(r.applications[0].count).toBe(1);
expect(r.discount).toBe(70);
expect(charged(r.lines)).toBeCloseTo(250 + 80 + 200, 6);
});
test('non-qualifying tickets are left alone', () => {
const r = applyBundles([anyFour], [line('adult', 2, 80), line('lunch', 5, 65)]);
expect(r.discount).toBe(0);
});
});
describe('buy X get Y free', () => {
const b3g1 = { id: 'f', name: 'Buy 3 get 1', kind: 'free', price: 0, setSize: 4, freeCount: 1, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('the cheapest ticket in the set is free', () => {
const r = applyBundles([b3g1], [line('adult', 3, 80), line('child', 1, 50)]);
expect(r.discount).toBe(50);
expect(r.lines.find(l => l.unitPrice === 0)).toMatchObject({ eventOptionId: 'child', quantity: 1, bundled: true });
});
test('8 tickets make two sets', () => {
const r = applyBundles([b3g1], [line('adult', 8, 80)]);
expect(r.applications[0].count).toBe(2);
expect(r.discount).toBe(160);
});
test('three tickets get nothing', () => {
expect(applyBundles([b3g1], [line('adult', 3, 80)]).discount).toBe(0);
});
test('a free count that is not below the set size is ignored', () => {
expect(applyBundles([{ ...b3g1, freeCount: 4 }], [line('adult', 8, 80)]).discount).toBe(0);
});
});
describe('group discount', () => {
const tenPlus = { id: 'g', name: 'Groups', kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('10+ qualifying tickets all get the group price, once, never dearer', () => {
const r = applyBundles([tenPlus], [line('adult', 8, 80), line('child', 4, 50)]);
// Adults 80 → 70 (8 × 10 = 80 saved); children already cheaper, unchanged.
expect(r.discount).toBe(80);
expect(r.applications).toEqual([{ bundleId: 'g', name: 'Groups', count: 1, discount: 80 }]);
expect(charged(r.lines)).toBeCloseTo(8 * 70 + 4 * 50, 6);
});
test('below the minimum: nothing', () => {
expect(applyBundles([tenPlus], [line('adult', 9, 80)]).discount).toBe(0);
});
test('percentage off everything in the group', () => {
const pct = { ...tenPlus, groupPricing: 'percent', price: 10 };
expect(applyBundles([pct], [line('adult', 10, 80), line('child', 2, 50)]).discount).toBe(90);
});
test('an exact bundle beats a group discount when it saves more', () => {
const r = applyBundles([family, tenPlus], [line('adult', 5, 80), line('child', 5, 50)]);
// Group: 5 × 10 = 50 saved. Family: 50 saved on 5A+3C, then group can't reach 10 again.
// Equal savings: the first found wins, either way the total is 50.
expect(r.discount).toBe(50);
});
});
describe('describeBundle', () => {
const { describeBundle } = require('../src/utils/bundles');
const names = [{ eventOptionId: 'a', quantity: 2, eventOption: { name: 'Adult' } }, { eventOptionId: 'c', quantity: 3, eventOption: { name: 'Child' } }];
test('one line per kind', () => {
expect(describeBundle({ kind: 'exact', price: 220, items: names })).toBe('2 × Adult + 3 × Child for R220');
expect(describeBundle({ kind: 'mix', price: 250, setSize: 4, items: names })).toBe('Any 4 of Adult, Child for R250');
expect(describeBundle({ kind: 'free', setSize: 4, freeCount: 1, items: names })).toBe('Buy 3, get 1 free: Adult, Child');
expect(describeBundle({ kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: names })).toBe('10+ tickets (Adult, Child): R70 each');
expect(describeBundle({ kind: 'group', price: 15, setSize: 10, groupPricing: 'percent', items: names })).toBe('10+ tickets (Adult, Child): 15% off');
});
});
describe('isBundleAvailable', () => {
const now = new Date('2026-06-01T10:00:00Z');
test('switched off, not started, ended and empty bundles are not available', () => {
expect(isBundleAvailable({ ...family, isActive: true }, now)).toBe(true);
expect(isBundleAvailable({ ...family, isActive: false }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, validFrom: '2026-06-02' }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, validUntil: '2026-05-31' }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, items: [] }, now)).toBe(false);
});
});

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