Compare commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00
102 changed files with 4184 additions and 1507 deletions
+62
View File
@@ -7,6 +7,68 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.37.3] - 2026-10-23
### Fixed
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
## [1.37.2] - 2026-10-23
### Added
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
## [1.37.1] - 2026-10-23
### Changed
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
## [1.37.0] - 2026-10-23
### Added
- **PayFast.** A second online payment provider (card, instant EFT and more). Switch it on in Site settings → Payments, then enter the merchant ID, merchant key and salt passphrase from your PayFast (or Sandbox) dashboard and choose Sandbox or Live. Payments are confirmed by PayFast's notification (ITN) at `/api/webhooks/payfast`, which is checked the way PayFast requires: the signature, that it came from PayFast, that PayFast confirms it, and that the amount matches the checkout. PayFast starts switched off.
- **Choosing how to pay.** When more than one provider is on, payers choose on a new "Pay online" page (`/pay/…`). Payment links in emails, WhatsApp and invoices go there too, so they work for every provider.
- Payer-facing wording no longer says Yoco: "Pay online" after registering, a neutral redirect message, and the Terms and Privacy pages name both providers (Yoco or PayFast), including that PayFast may receive your name and email to fill in its payment page.
- A payment of a different amount from the checkout (e.g. changed along the way) isn't recorded automatically: it waits in "Unreconciled Online Payments" with a note explaining why.
## [1.36.0] - 2026-10-23
### Added
- **Site settings → Payments** (replaces the Yoco tab): turn each online payment provider on or off and set their order. The first one enabled and set up is used by default. Yoco's keys and webhook settings are on the same tab. Yoco is the only provider for now; PayFast, SnapScan and Zapper follow in the next releases. If no provider is on and set up, online payments are off and people pay at the door.
### Changed
- Online payments go through one payment provider layer (`backend/src/payments`). Yoco works exactly as before: the same checkout, the same webhook URL (`/api/webhooks/yoco`) and the same reconciliation.
- **Online payments must be at least R15**, for every kind of online payment (balance, part-payment, donation, sponsorship). A smaller balance is paid at the door.
- Payments record which provider took them (`provider`, e.g. "yoco"); existing online payments are marked as Yoco. The payment method (card, Apple Pay…) is unchanged, so cashups and reports add up as before. Provider fees are still never deducted or added — they're handled at cashup.
- Yoco transactions are now "provider transactions" (`/api/provider-transactions`, with `/api/yoco-transactions` still working). The Payments page's reconcile list is called "Unreconciled Online Payments" and shows the provider. The database table is renamed in place, so stored transactions are kept.
- The checkout endpoint is now `POST /api/payments/checkout` (with an optional `provider`); `/api/payments/yoco-checkout` still works.
## [1.35.1] - 2026-10-23
### Added
- **Waitlist per session.** On pick-sessions events, a full session shows "Join waitlist" in the session list, and the waitlist works per session: when a place in that session frees up, the next person waiting for it gets the offer. Offers never run past the session's start, and nobody is offered a session that has started or was cancelled. The claim link ticks the session for them. My Events, the claim page and the supervisor Waitlist page show which session an entry is for.
- **"All sessions" tickets on pick-sessions events let people choose their sessions.** When an event's "How people book" is "Pick sessions", every ticket now shows the session list. A ticket set to "All sessions" is still one ticket at one price, whatever is ticked. The ticket then only scans on the ticked sessions (online and offline), and the attendance register, certificates, session reminders and session capacity follow them. Tickets and the ticket email list the ticked dates. A ticket set to "Pick sessions" is still charged per session. Events booked as "The whole course" show no session list. Bookings made before this cover every session, as before.
- **Session picker at the door.** The At the door "Select items" pop-up now has the session picker for per-session tickets, so walk-ins can be booked straight in. For a new attendee, a place in today's session is selected by default when there's room.
### Changed
- Per-session tickets show their list of sessions straight away (on the registration page, Manual registration, At the door and the self-service kiosk). Before, the list only appeared after pressing "+", so it wasn't obvious you could choose sessions. Ticking a session sets the quantity to 1.
### Fixed
- Per-session stock limits on pick-sessions tickets weren't being enforced, and the public event page lost all stock information on events with such a ticket.
- The offline-scanning service worker served the app's script files from its cache first, for good. On a development server, where those files keep their names when they change, a browser that had opened the scanner kept running old code on every page, even after a hard reload. It now fetches them from the network and uses the cache only when offline. Its cache is renamed, so old copies are cleared.
- At the door, editing a registration that has per-session tickets could no longer be saved ("below the quantity already issued").
- Whole-course tickets: the waitlist now also counts room in sessions that have a capacity, so it no longer says "places available" when a session is full. Places held for waitlist offers now count towards session capacity.
## [1.35.0] - 2026-10-23
### Added
+24 -6
View File
@@ -104,7 +104,12 @@ Feedback → Attendance**.
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. Not available on youth events.
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
@@ -169,7 +174,8 @@ Feedback → Attendance**.
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
@@ -194,7 +200,17 @@ Feedback → Attendance**.
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook. Keys and the webhook are set up from
Site Settings → Yoco.
Site Settings → Payments, where providers are switched on or off and put in order
(the first one is the default). Online payments are at least R15; payments are
recorded at the full amount and provider fees are dealt with at cashup.
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
modes. When several providers are on, payers choose on the "Pay online" page; a
payment of a different amount from the checkout is held for review instead of being
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
online.
- **Payment status checks** — if a provider's notification never arrives, Payments →
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
@@ -245,7 +261,7 @@ Feedback → Attendance**.
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
@@ -495,7 +511,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Payments | Online payment providers: Enabled toggle and order; PayFast merchant ID/key, passphrase and sandbox/live; Yoco's secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
@@ -550,6 +566,7 @@ can't start, the same jobs run on in-process timers.
| 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`) |
@@ -590,7 +607,8 @@ can't start, the same jobs run on in-process timers.
| `/[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 |
+2 -2
View File
@@ -67,7 +67,7 @@ Documentation: see Frontend README for environment and scripts.
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
- Yoco payments are set up in Admin → Site Settings → Payments: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
### 3) Run the apps
@@ -156,7 +156,7 @@ To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on th
- **`OptionVariant` model**: event options can now have sub-variants (sizes, colours, ticket types — e.g. Adult/Child/VIP under a single "Ticket" option). Variants have an optional `price` override and their own `stockLimit`.
- **Stock limits**: `stockLimit` added to `EventOption` and `EarlyBirdTier`. A value of `0` means unlimited. All stock counts exclude cancelled registrations.
- **Early-bird concurrency handling**: once a Yoco checkout has been issued at an early-bird price, that price is honoured even if another user simultaneously exhausts the tier's stock. Stock-based forfeiture only applies at registration time (via `resolveOptionPrice`). Deadline expiry at payment time forfeits the price; stock races after the checkout is issued do not.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/yoco-checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
- **`priceSnapshot` / `appliedTierId`** on `RegistrationOption`: price and tier are snapshotted at registration creation. `computeRegistrationTotalDue` uses `priceSnapshot` as authoritative when present (legacy rows without a snapshot fall back to deadline-only re-evaluation).
- **Cancelled registration tickets**: `GET /api/tickets/mytickets` now excludes tickets from cancelled registrations at the database level.
- **Cancellation payment guard**: `DELETE /api/registrations/:id` blocks non-admins from cancelling a registration that has positive payments. Admins can cancel at any time.
+6 -2
View File
@@ -57,12 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Payments (Yoco)
## Payments
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
| Setting | Default when unset | Notes |
|---|---|---|
| `payfast_merchant_id` / `payfast_merchant_key` | `PAYFAST_MERCHANT_ID` / `PAYFAST_MERCHANT_KEY` env vars, else *(blank)* | From the PayFast (or Sandbox) dashboard. Both are needed before PayFast can take payments. The key isn't secret (it's in the payment form), so it's shown in full. Managed through `/api/payfast`. |
| `payfast_passphrase` | `PAYFAST_PASSPHRASE` env var, else *(blank)* | Stored encrypted. Must match the dashboard's salt passphrase exactly, or PayFast refuses payments ("signature does not match"). |
| `payfast_mode` | `PAYFAST_MODE` env var, else `sandbox` | `sandbox` or `live`: where payers are sent and where notifications are confirmed. |
| `payment_providers` | Yoco enabled | JSON `{ order: [...], enabled: { yoco: true } }`: which online payment providers are on, and their order (the first one enabled and set up is the default). Providers not listed yet come last, switched off (Yoco on, as it was the only provider). If none is on and set up, online payments are off. |
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
+12
View File
@@ -39,6 +39,16 @@ FRONTEND_URL=http://localhost:3000
# YOCO_SECRET_KEY=sk_test_...
# YOCO_WEBHOOK_SECRET=whsec_...
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
# PAYFAST_MERCHANT_ID=
# PAYFAST_MERCHANT_KEY=
# PAYFAST_PASSPHRASE=
# PAYFAST_MODE=sandbox
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
# PAYFAST_SKIP_IP_CHECK=true
# ─── Public URLs ──────────────────────────────────────────────────────────────
# Used to build links in emails (password reset, activation, etc.)
APP_BASE_URL=https://your-frontend-domain.com
@@ -59,6 +69,8 @@ CONSENT_JOBS_ENABLED=true
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
WAITLIST_JOB_ENABLED=true
# WAITLIST_JOB_INTERVAL_MS=600000
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
PAYMENT_STATUS_JOB_ENABLED=true
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
+32 -14
View File
@@ -41,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)
@@ -465,7 +465,11 @@ In a pick-mode event, a `pick` option is bought per session: registration lines
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. Pick-mode events stay open until the last session starts
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.
@@ -504,10 +508,10 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
| 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" }` |
@@ -519,6 +523,7 @@ How offers work:
- 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
@@ -570,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 |
@@ -590,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-...",
@@ -739,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 |
---
@@ -775,7 +788,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets |
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
---
@@ -796,7 +810,11 @@ For what each individual setting key defaults to when it's never been saved, see
## Yoco
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
Managed from Admin → Site Settings → Payments.
PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping.
Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/<checkoutId>` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.37.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.37.3",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.37.3",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -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");
+44 -3
View File
@@ -344,6 +344,9 @@ model RegistrationOption {
// 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])
@@ -390,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)
@@ -397,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])
@@ -557,6 +563,8 @@ enum AdminAuditAction {
yoco_config_updated
yoco_webhook_registered
yoco_webhook_deleted
payment_providers_updated
payfast_config_updated
event_sessions_changed
attendance_marked
attendance_unmarked
@@ -677,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?
@@ -690,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
@@ -1364,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
@@ -1379,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])
}
@@ -1483,6 +1523,7 @@ model EventSession {
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
waitlistEntries WaitlistEntry[]
@@index([eventId, startsAt])
}
@@ -42,7 +42,7 @@ async function buildRegister(eventId) {
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, eventOption: { select: { name: true } }, variant: { select: { name: 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' },
@@ -68,9 +68,10 @@ async function buildRegister(eventId) {
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked sessions: which sessions this person booked, and the ticket for each.
// 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.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
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 || '',
@@ -117,7 +118,7 @@ const markAttendance = async (req, res) => {
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 } } } });
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';
@@ -126,7 +127,7 @@ const markAttendance = async (req, res) => {
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?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
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');
+5 -2
View File
@@ -250,8 +250,11 @@ async function basketBuyer(req, forUserId) {
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
// A "pick sessions" line counts once per chosen session.
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
// 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);
+6 -6
View File
@@ -1613,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) {
@@ -1624,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`,
@@ -1855,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 () => {
@@ -1865,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,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 };
+62 -221
View File
@@ -5,8 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const payments = require('../payments');
const { createRegistrationCheckout } = require('../payments/checkouts');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
@@ -914,122 +914,23 @@ const unassignDonationFromRegistration = async (req, res) => {
}
};
/**
* Yoco line items for a registration: one per item at each price actually charged
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
* bundle saving and coupon discount as negative lines. Previous/partial payment lines are
* added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
for (const line of discountLines(registration)) {
items.push({ displayName: line.label, quantity: 1, pricingDetails: { price: -Math.round(line.amount * 100) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
// donationAmount (optional) is added on top as its own line; the webhook splits it off into a
// separate donation payment using the amount carried in the checkout metadata.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0 } = {}) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
});
if (!registration) throw new Error('Registration not found');
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
const donationInCents = Math.round((Number(donationAmount) || 0) * 100);
const amountInCents = Math.round(remainingAmount * 100) + donationInCents;
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
if (donationInCents > 0) {
lineItems.push({ displayName: 'Donation', quantity: 1, pricingDetails: { price: donationInCents } });
}
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: {
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {})
},
successUrl,
cancelUrl,
failureUrl,
lineItems
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
await prisma.registration.update({
where: { id: registrationId },
data: { checkoutId: response.data.id, updatedAt: new Date() }
});
return {
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: amountInCents / 100,
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, donationAmount, 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') {
@@ -1042,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: {
@@ -1062,47 +962,10 @@ const createYocoCheckout = async (req, res) => {
});
}
// If a specific partial amount was passed, fall through to the old inline path
// Part of the balance (supervisor payments page)
if (amount && !isNaN(parseFloat(amount))) {
// Re-use old inline logic for partial payments
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
});
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const remainingAmount = totalDue - totalPaid;
if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); }
const requested = parseFloat(amount);
if (requested <= 0) { res.status(400); throw new Error('Amount must be greater than 0'); }
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
const charge = Math.min(requested, remainingAmount);
const amountInCents = Math.round(charge * 100);
const lineItems = checkoutLineItems(registration);
// Previous payments deduction
if (totalPaid > 0) {
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
}
// Partial payment deduction — amount being deferred to a future payment
if (charge < remainingAmount) {
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
}
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
return res.status(200).json(result);
}
// Optional donation added at registration ("Donation amount" on the register form)
@@ -1110,85 +973,64 @@ const createYocoCheckout = async (req, res) => {
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 < 15) { res.status(400); throw new Error('Minimum donation is R15'); }
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 createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation });
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
return res.status(200).json(result);
}
// Branch 2: Donation to an event
if (eventId) {
// Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < 15) {
res.status(400);
throw new Error('Minimum donation is R15');
}
const amountInCents = Math.round(amt * 100);
const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`;
const metadata = {
userId,
eventId,
eventTitle: event.title,
type: 'donation',
isDonation: true
};
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata,
successUrl,
cancelUrl,
failureUrl,
lineItems: [
{ displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } }
]
};
const response = await axios.post(
'https://payments.yoco.com/api/checkouts',
checkoutData,
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
);
return res.status(200).json({
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: amt,
event: { id: event.id, title: event.title }
});
// A donation to an event
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
} catch (error) {
console.error('Yoco checkout error:', error.response?.data || error.message);
res.status(error.statusCode || error.response?.status || 400).json({
message: error.response?.data?.message || error.message
if (!event.isActive) {
res.status(400);
throw new Error('Cannot make donation to inactive event');
}
if (event.cashupStatus === 'closed') {
res.status(400);
throw new Error('This event is closed and no longer accepting donations.');
}
const amt = parseFloat(amount);
if (!(amt > 0)) {
res.status(400);
throw new Error('Valid amount is required for donation');
}
if (amt < payments.MIN_ONLINE_AMOUNT) {
res.status(400);
throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
}
const amountInCents = Math.round(amt * 100);
const checkout = await payments.createCheckout(provider, {
amountCents: amountInCents,
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
description: `Donation to ${event.title}`,
payer: { name: req.user.name || '', email: req.user.email || '' },
successUrl, cancelUrl, failureUrl,
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
});
return res.status(200).json({
checkoutId: checkout.checkoutId,
redirectUrl: checkout.redirectUrl,
provider: checkout.provider,
amount: amt,
event: { id: event.id, title: event.title }
});
} catch (error) {
console.error('Checkout error:', error.message);
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp
// @route POST /api/payments/yoco-checkout/send
// @desc Deliver a previously-generated payment link to the registrant via email or WhatsApp
// @route POST /api/payments/checkout/send (also /api/payments/yoco-checkout/send)
// @access Private/Supervisor
const sendPaymentLink = async (req, res) => {
try {
@@ -1477,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
};
@@ -53,7 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
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
@@ -226,11 +226,12 @@ const createRegistration = async (req, res) => {
// "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 });
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
// (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);
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
@@ -347,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 {
@@ -359,6 +360,7 @@ const createRegistration = async (req, res) => {
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -407,6 +409,7 @@ const createRegistration = async (req, res) => {
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1193,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) }),
}
})
]);
@@ -1205,6 +1210,7 @@ const createManualRegistration = async (req, res) => {
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1274,6 +1280,7 @@ const createManualRegistration = async (req, res) => {
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1358,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);
}
}
+10 -7
View File
@@ -8,9 +8,12 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
// through the generic settings endpoints.
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
// Payment provider settings are only readable (secrets masked) and writable through
// /api/yoco and /api/payfast — never through the generic settings endpoints.
const PAYMENT_PRIVATE_KEYS = new Set([
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
]);
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
@@ -50,7 +53,7 @@ const getAllSettings = async (req, res) => {
const rows = await prisma.appSetting.findMany();
const settings = {};
for (const r of rows) {
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
if (ENCRYPTED_KEYS.has(r.key)) {
// Return a sentinel so the UI knows the value is set, without exposing it.
// smtp_user (email address) we can safely return as-is after decryption so
@@ -86,7 +89,7 @@ const updateSettings = async (req, res) => {
}
const ops = Object.entries(updates)
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
@@ -125,7 +128,7 @@ const updateSettings = async (req, res) => {
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
ip: getClientIp(req),
});
}
@@ -202,7 +205,7 @@ const runSetup = async (req, res) => {
const toSave = { ...settings, setup_complete: 'true' };
const ops = Object.entries(toSave)
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
.map(([key, value]) => {
let storedValue = String(value);
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
@@ -1,5 +1,4 @@
const axios = require('axios');
const { yocoAuthHeader } = require('../utils/yoco');
const payments = require('../payments');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
@@ -45,7 +44,7 @@ const getEventSponsorship = async (req, res) => {
}
};
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
// @desc Start an online checkout to sponsor spots (or a custom amount) at an event
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
// @access Public (guests give a name and email for the receipt)
const createSponsorCheckout = async (req, res) => {
@@ -72,9 +71,8 @@ const createSponsorCheckout = async (req, res) => {
const back = `${base}/events/${encodeURIComponent(event.id)}`;
const amountInCents = Math.round(charge.amount * 100);
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
const checkout = await payments.createCheckout(req.body?.provider || null, {
amountCents: amountInCents,
metadata: {
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
sponsorAnonymous: isAnonymous ? 'true' : 'false',
@@ -85,14 +83,14 @@ const createSponsorCheckout = async (req, res) => {
successUrl: `${back}?sponsored=1`,
cancelUrl: back,
failureUrl: `${back}?sponsored=failed`,
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
description: label,
payer: { name: payer.name || '', email: payer.email || '' },
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
} catch (error) {
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
if (error.statusCode >= 500) console.error('Sponsor checkout error:', error.message);
fail(res, error);
}
};
+9 -4
View File
@@ -264,6 +264,7 @@ const getScanPreview = async (req, res) => {
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",
@@ -316,7 +317,7 @@ const getScanPreview = async (req, res) => {
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);
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) };
@@ -364,6 +365,7 @@ const scanTicket = async (req, res) => {
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",
@@ -441,7 +443,7 @@ const scanTicket = async (req, res) => {
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);
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;
}
@@ -917,7 +919,7 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
// 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)
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;
@@ -1092,7 +1094,10 @@ async function emailTicketsInternal(req, res, userOverride) {
const eventTitle = tickets[0].event.title;
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
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) || (tickets[0].event.startDate
// 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`;
+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 -605
View File
@@ -1,552 +1,9 @@
const crypto = require('crypto');
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { getYocoWebhookSecret } = require('../utils/yoco');
const { handleProviderWebhook } = require('../payments/webhook');
// @desc Handle Yoco webhook events
// @desc Handle Yoco webhook events (signature check and processing: src/payments)
// @route POST /api/webhooks/yoco
// @access Public
const handleYocoWebhook = async (req, res) => {
try {
const headers = req.headers;
const requestBody = req.rawBody;
// Log every JSON request body for webhook controller
try {
const contentType = headers['content-type'] || headers['Content-Type'] || '';
let parsedBody = null;
if (typeof requestBody === 'string') {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
// Not valid JSON; keep as raw string
}
} else if (requestBody && typeof requestBody === 'object') {
parsedBody = requestBody;
}
} catch (logErr) {
// Fail-safe: never block webhook processing due to logging issues
console.error('Failed to log webhook JSON request:', logErr);
}
// Verify webhook signature
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const rawSigHeader = headers['webhook-signature'];
if (!id || !timestamp || !rawSigHeader) {
const responseObj = {
success: false,
message: 'Missing webhook headers',
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
};
return res.status(400).json(responseObj);
}
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
}
// Construct the signed content
const signedContent = `${id}.${timestamp}.${requestBody}`;
// Webhook secret from Site Settings → Yoco (falls back to .env)
const secret = await getYocoWebhookSecret();
if (!secret) {
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
const responseObj = {
success: false,
message: 'Server configuration error',
details: { error: 'Missing webhook secret configuration' }
};
return res.status(500).json(responseObj);
}
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Accept if any of the space-separated signatures match (supports key rotation)
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
const signatureValid = signatures.some(sig => {
try {
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
} catch {
return false;
}
});
if (!signatureValid) {
console.error('Invalid webhook signature');
const responseObj = {
success: false,
message: 'Invalid signature',
details: { error: 'Webhook signature verification failed' }
};
return res.status(403).json(responseObj);
}
// Parse the webhook payload
const webhookData = JSON.parse(requestBody);
// Extract checkoutId from payload or metadata
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
try {
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
if (hasFilter) {
const idMatches = receivedCheckoutId && (
allowedIds.includes(receivedCheckoutId) ||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
);
if (!idMatches) {
// Store event for audit/unreconciled tracking
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
// Acknowledge but ignore processing for unrelated checkout flows
const responseObj = {
success: true,
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
data: {
filtered: true,
receivedCheckoutId,
allowedIdsCount: allowedIds.length,
allowedPrefixesCount: allowedPrefixes.length
}
};
return res.status(200).json(responseObj);
}
}
} catch (filterErr) {
// Never block webhook due to filter parsing errors; log only
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
}
// Automatic filtering by database registration record, with donation allowance
try {
if (receivedCheckoutId) {
const existingRegistration = await prisma.registration.findFirst({
where: { checkoutId: receivedCheckoutId },
select: { id: true }
});
if (!existingRegistration) {
// Allow donations (no registration) to proceed based on metadata
const meta = webhookData?.payload?.metadata || {};
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
if (!isDonation) {
try {
await saveYocoTransaction(webhookData);
} catch (saveErr) {
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
}
const responseObj = {
success: true,
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
data: {
filtered: true,
reason: 'no_registration_for_checkoutId',
receivedCheckoutId
}
};
return res.status(200).json(responseObj);
}
}
}
} catch (dbFilterErr) {
// Do not fail webhook due to DB filter issues
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
}
let processedData = null;
// Process different event types
switch (webhookData.type) {
case 'payment.succeeded':
processedData = await handlePaymentSucceeded(webhookData);
break;
// Add more event types as needed
default:
console.log(`Unhandled webhook event type: ${webhookData.type}`);
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
const responseObj = {
success: true,
message: 'Webhook received but event type not handled',
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp
}
};
return res.status(200).json(responseObj);
}
// Return detailed success response
const responseObj = {
success: true,
message: `Successfully processed ${webhookData.type} webhook`,
data: {
eventType: webhookData.type,
webhookId: id,
timestamp: timestamp,
processedData
}
};
return res.status(200).json(responseObj);
} catch (error) {
console.error('Webhook error:', error);
const responseObj = {
success: false,
message: error.message,
details: {
error: error.stack,
timestamp: new Date().toISOString()
}
};
return res.status(500).json(responseObj);
}
};
// Handle payment.succeeded event
const handlePaymentSucceeded = async (webhookData) => {
try {
const paymentData = webhookData.payload;
// Log the payment data
console.log('Payment succeeded:', paymentData);
// Extract payment details
const {
id: yocoPaymentId,
amount,
currency,
status,
metadata,
paymentMethodDetails: method,
checkoutId
} = paymentData;
// Check if payment already exists to avoid duplicates
const existingPayment = await prisma.payment.findFirst({
where: {
externalId: yocoPaymentId
}
});
if (existingPayment) {
console.log(`Payment ${yocoPaymentId} already processed`);
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
return {
status: 'already_processed',
paymentId: existingPayment.id,
externalId: yocoPaymentId
};
}
// Find the registration by checkoutId
let registration = null;
if (checkoutId) {
registration = await prisma.registration.findFirst({
where: {
checkoutId: checkoutId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// If no registration found by checkoutId, try metadata
if (!registration && metadata && metadata.registrationId) {
registration = await prisma.registration.findUnique({
where: {
id: metadata.registrationId
},
include: {
event: {
select: {
id: true,
title: true
}
},
user: {
select: {
id: true,
name: true,
email: true
}
}
}
});
}
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
let resolvedUserId = registration?.userId || metadata?.userId || null;
if (!resolvedUserId) {
try {
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
resolvedUserId = adminUser?.id || null;
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// 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 = amount / 100; // Convert cents to your currency unit
const donationPart = registration?.id
? Math.min(Math.max(Number(metadata?.donationAmount) || 0, 0), grossAmount)
: 0;
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: Math.round((grossAmount - donationPart) * 100) / 100,
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
}
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: donationPart,
method: method.type,
status: status === 'succeeded' ? 'completed' : status,
userId: resolvedUserId,
recordedById: resolvedUserId,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
originalPaymentId: payment.id,
}
});
}
// 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 _whDonationId = donationPayment?.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 (_whDonationId) {
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(_whDonationId);
} catch (e) {
console.error('Failed to send webhook donation 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 {
@@ -561,62 +18,3 @@ module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
// Helper: save or update Yoco transactions for any webhook event
async function saveYocoTransaction(webhookData, updates = {}) {
try {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
if (!externalId) {
throw new Error('Missing external id in webhook payload');
}
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
const currency = payload?.currency || null;
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
const methodType = payload?.paymentMethodDetails?.type || null;
// Try find existing
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing) {
// Update with any provided updates
if (updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({
where: { id: existing.id },
data: { ...updates, updatedAt: new Date() }
});
}
return existing;
}
// Create new
const record = await prisma.yocoTransaction.create({
data: {
externalId,
amount,
currency,
createdDate,
checkoutId,
methodType,
reconciled: false,
raw: webhookData,
...updates
}
});
return record;
} catch (e) {
// Unique constraint race: fetch existing
if (e?.code === 'P2002') {
const payload = webhookData?.payload || {};
const externalId = payload?.id || webhookData?.id;
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
if (existing && updates && Object.keys(updates).length > 0) {
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
}
return existing;
}
throw e;
}
}
@@ -1,282 +0,0 @@
const prisma = require('../config/db');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
function getYocoModel() {
// Gracefully handle environments where the Prisma client hasn't been regenerated
// and YocoTransaction model is not available yet.
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
}
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
// @route GET /api/yoco-transactions
// @access Private/Admin or Supervisor
const getAllYocoTransactions = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const { reconciled, ignored } = req.query;
const where = {};
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where,
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
// @route GET /api/yoco-transactions/unreconciled
// @access Private/Admin or Supervisor
const getUnreconciledYocoTransactions = async (req, res) => {
try {
// Basic role check if auth middleware sets req.user
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const items = await Yoco.findMany({
where: { reconciled: false, ignored: false },
orderBy: { createdDate: 'desc' }
});
return res.status(200).json({ success: true, data: items });
} catch (error) {
console.error('Failed to fetch unreconciled Yoco transactions:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
}
};
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
// @route POST /api/yoco-transactions/:id/reconcile
// @access Private/Admin or Supervisor
const reconcileYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.paymentId) {
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
}
// Load registration if provided
let registration = null;
if (registrationId) {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
event: true,
user: true,
registrationOptions: { include: { eventOption: true } },
payments: true
}
});
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
}
// Determine payment fields
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
if (!amountFloat || amountFloat <= 0) {
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
}
const externalId = ytx.externalId || undefined;
// Resolve a valid userId for the Payment to satisfy FK constraints
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
if (exists) resolvedUserId = metaUserId;
} catch {}
}
if (!resolvedUserId && req.user?.id) {
// Fallback to the acting supervisor/admin to avoid FK violations
resolvedUserId = req.user.id;
}
if (!resolvedUserId) {
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
}
// Build payment data
const paymentData = {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
externalId: externalId,
status: 'completed'
};
// Ensure donation has an eventId
if (!registration && !paymentData.eventId) {
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
}
// Create payment
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Optionally update registration status when applicable and generate tickets if paid.
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
let generatedTickets = [];
if (registration) {
try {
const updatedReg = await updateRegistrationStatus(registration.id);
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
} catch (genErr) {
console.error('Error generating tickets after Yoco reconciliation:', genErr);
}
}
} catch (e) {
// log but do not fail reconciliation
console.warn('Failed to update registration after reconciliation:', e?.message || e);
}
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
}
})();
// Update yoco transaction as reconciled
const updatedTx = await Yoco.update({
where: { id: ytx.id },
data: { reconciled: true, paymentId: payment.id }
});
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
} catch (error) {
console.error('Failed to reconcile Yoco transaction:', error);
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
if (error?.code === 'P2002') {
try {
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
if (existing) {
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
if (updatedTx) {
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
}
}
} catch {}
}
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
// Minimal registration status update mirroring webhook logic
async function updateRegistrationStatus(registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
payments: true
}
});
if (!registration) return null;
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
let newStatus = 'pending';
if (totalPaid >= totalDue) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
}
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
// @route POST /api/yoco-transactions/:id/ignore
// @access Private/Admin or Supervisor
const ignoreYocoTransaction = async (req, res) => {
try {
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
const Yoco = getYocoModel();
if (!Yoco) {
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
}
const { id } = req.params;
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
if (ytx.reconciled && ytx.ignored) {
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
}
const updated = await Yoco.update({
where: { id },
data: { ignored: true, reconciled: true }
});
return res.status(200).json({ success: true, data: updated });
} catch (error) {
console.error('Failed to ignore Yoco transaction:', error);
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
}
};
module.exports = {
getAllYocoTransactions,
getUnreconciledYocoTransactions,
reconcileYocoTransaction,
ignoreYocoTransaction
};
+38 -19
View File
@@ -131,7 +131,7 @@ const webhookRoutes = require('./routes/webhookRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
const reportRoutes = require('./routes/reportRoutes');
const formRoutes = require('./routes/formRoutes');
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
const providerTransactionRoutes = require('./routes/providerTransactionRoutes');
const automationRoutes = require('./routes/automationRoutes');
const broadcastRoutes = require('./routes/broadcastRoutes');
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
@@ -147,6 +147,7 @@ const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes');
const payfastRoutes = require('./routes/payfastRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -163,7 +164,8 @@ app.use('/api/tickets', ticketRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/forms', formRoutes);
app.use('/api/yoco-transactions', yocoTransactionRoutes);
app.use('/api/provider-transactions', providerTransactionRoutes);
app.use('/api/yoco-transactions', providerTransactionRoutes); // older name
app.use('/api/automations', automationRoutes);
app.use('/api/broadcasts', broadcastRoutes);
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
@@ -189,6 +191,7 @@ 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');
@@ -797,12 +800,21 @@ app.get('/docs', async (req, res) => {
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
{ method:'POST', path:'/api/payments/checkout', auth:'user+', desc:'Start an online checkout (also at /api/payments/yoco-checkout) with body.provider, or the first enabled provider. At least R15 (all providers). Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, provider:'yoco', successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Checkout created — send the payer to redirectUrl', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', provider:'yoco', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
]},
{ method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)',
@@ -1012,19 +1024,20 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]},
]},
{ title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [
{ method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]},
{ method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment',
pathParams:{ ':id':'YocoTransaction UUID' },
request:{ body:{ paymentId:'pay-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]},
{ method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'YocoTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]},
{ title: 'Provider Transactions', base: '/api/provider-transactions', endpoints: [
{ method:'GET', path:'/api/provider-transactions', auth:'supervisor+', desc:'Transactions payment providers sent by webhook (also at /api/yoco-transactions)',
queryParams:{ provider:'yoco', reconciled:'true|false', ignored:'true|false' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }] }}]},
{ method:'GET', path:'/api/provider-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
queryParams:{ provider:'yoco' },
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }] }}]},
{ method:'POST', path:'/api/provider-transactions/:id/reconcile', auth:'supervisor+', desc:'Record the transaction as a payment on a registration, or as a donation to an event',
pathParams:{ ':id':'ProviderTransaction UUID' },
request:{ body:{ registrationId:'reg-uuid-...', eventId:'(donation) event-uuid-...', userId:'(donation, optional) user-uuid-...' }},
responses:[{ status:200, desc:'Reconciled', body:{ success:true, data:{ transaction:{ id:'ptx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }, payment:{ id:'pay-uuid-...', provider:'yoco' } } }}]},
{ method:'POST', path:'/api/provider-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
pathParams:{ ':id':'ProviderTransaction UUID' },
responses:[{ status:200, desc:'Ignored', body:{ success:true, data:{ id:'ptx-uuid-...', ignored:true } }}]},
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
@@ -1064,6 +1077,7 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
@@ -1072,6 +1086,11 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
]},
{ title: 'PayFast', base: '/api/payfast', endpoints: [
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
]},
{ title: 'Yoco', base: '/api/yoco', endpoints: [
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
+4
View File
@@ -29,6 +29,10 @@ const JOBS = [
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(),
+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
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig);
router.post('/test', protect, admin, testConnection);
module.exports = router;
+13 -2
View File
@@ -9,17 +9,28 @@ const {
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createCheckout,
sendPaymentLink,
createRefund,
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
// Protected routes
router.post('/', protect, supervisor, createPayment);
router.post('/yoco-checkout', protect, createYocoCheckout);
router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider);
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout);
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
// Older names, kept for existing clients
router.post('/yoco-checkout', protect, createCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
@@ -0,0 +1,20 @@
const express = require('express');
const router = express.Router();
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
const { protect, supervisor} = require('../middleware/authMiddleware');
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
// GET all payment provider transactions (admin/supervisor)
router.get('/', protect, supervisor, getAllProviderTransactions);
// GET all unreconciled transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
// POST reconcile a transaction
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
// POST ignore a transaction
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
module.exports = router;
+3 -1
View File
@@ -1,9 +1,11 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Yoco webhook endpoint
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
module.exports = router;
@@ -1,18 +0,0 @@
const express = require('express');
const router = express.Router();
const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController');
const { protect, supervisor} = require('../middleware/authMiddleware');
// GET all Yoco transactions (admin/supervisor)
router.get('/', protect, supervisor, getAllYocoTransactions);
// GET all unreconciled Yoco transactions (admin/supervisor)
router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions);
// POST reconcile a Yoco transaction
router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction);
// POST ignore a Yoco transaction
router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction);
module.exports = router;
+1 -1
View File
@@ -57,7 +57,7 @@ async function attendanceForCertificates(eventId) {
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 } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true } },
},
orderBy: { createdAt: 'asc' },
});
+5 -5
View File
@@ -955,12 +955,12 @@ function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0)
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* reusing (or, if none was supplied, creating) a checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails — callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* Resolves a checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
@@ -968,16 +968,16 @@ async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHi
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const { createRegistrationCheckout } = require('../payments/checkouts');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
const checkout = await createRegistrationCheckout(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
console.warn('Could not create a checkout for the invoice PDF:', e?.message || e);
return null;
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* PayFast configuration (Admin → Site Settings → Payments → PayFast).
*
* Stored in AppSetting: payfast_merchant_id, payfast_merchant_key, payfast_passphrase
* (AES-256-GCM encrypted — it signs every payment) and payfast_mode ('sandbox' | 'live',
* default sandbox). PAYFAST_MERCHANT_ID / PAYFAST_MERCHANT_KEY / PAYFAST_PASSPHRASE /
* PAYFAST_MODE in .env are fallbacks when the database has no value. The merchant key isn't
* secret: it travels in the payment form the payer's browser posts to PayFast.
*/
const { getSetting } = require('./settingsCache');
const KEY_MERCHANT_ID = 'payfast_merchant_id';
const KEY_MERCHANT_KEY = 'payfast_merchant_key';
const KEY_PASSPHRASE = 'payfast_passphrase';
const KEY_MODE = 'payfast_mode';
const HOSTS = { live: 'www.payfast.co.za', sandbox: 'sandbox.payfast.co.za' };
async function getPayfastConfig() {
const [merchantId, merchantKey, passphrase, mode] = await Promise.all([
getSetting(KEY_MERCHANT_ID, process.env.PAYFAST_MERCHANT_ID),
getSetting(KEY_MERCHANT_KEY, process.env.PAYFAST_MERCHANT_KEY),
getSetting(KEY_PASSPHRASE, process.env.PAYFAST_PASSPHRASE),
getSetting(KEY_MODE, process.env.PAYFAST_MODE),
]);
return {
merchantId: String(merchantId || '').trim(),
merchantKey: String(merchantKey || '').trim(),
passphrase: String(passphrase || ''),
mode: mode === 'live' ? 'live' : 'sandbox',
};
}
/** PayFast's host for a mode: where payers are sent and where ITNs are confirmed. */
const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
function notifyUrlProblem(url) {
let u;
try { u = new URL(url); } catch { return 'Set BACKEND_URL on the server so PayFast knows where to send payment notifications.'; }
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
return 'PayFast can\'t reach localhost, so payments won\'t be confirmed automatically. Use a public BACKEND_URL (or a tunnel) to test notifications.';
}
return null;
}
module.exports = {
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
getPayfastConfig, payfastHost, payfastNotifyUrl, notifyUrlProblem,
};
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -147,8 +147,8 @@ async function snapshotFor(lock) {
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
tickets: tickets.map(t => {
// A picked-session ticket for another session has nothing left today.
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
// 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);
@@ -204,7 +204,7 @@ async function syncScans(lock, scans) {
state = {
sessionId: session?.id || null,
noSession: hasSessions && !session,
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
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),
+2 -2
View File
@@ -21,10 +21,10 @@ 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 plus picked places on it. Map userId → user. */
/** 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 }, { sessionId }] } },
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 } },
+116 -31
View File
@@ -17,7 +17,12 @@
* 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; a pick option's stock limit is places per session.
* 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.
*/
@@ -138,6 +143,11 @@ function registrationClosesAt(event, sessions) {
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';
@@ -145,68 +155,125 @@ function isPickOption(event, option) {
/**
* Turns requested lines into registration lines: a pick option with sessionIds becomes
* one line per session (same quantity); other options stay as they are. Checks the
* sessions can still be booked: public — before the session starts; staff — until it ends.
* 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) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
out.push({ ...line, sessionId: null });
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) {
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.`);
const { sessionIds, ...rest } = line;
void sessionIds;
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.
* 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) {
const lines = await db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true },
});
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 whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
const out = new Map(sessions.map(s => [s.id, whole]));
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
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) {
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();
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
for (const s of limited) wanted.set(s.id, whole);
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
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);
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) {
@@ -384,9 +451,17 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
* (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) {
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);
@@ -412,9 +487,18 @@ function ticketDateText(event, style = 'short', session = null, sessions = null)
/**
* scanSession for a ticket: a picked-session ticket only checks in on its own session
* (whatever the scanner has selected).
* (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()) {
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' };
@@ -430,7 +514,8 @@ function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null,
* 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 }.
* 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) {
@@ -438,7 +523,7 @@ function groupTickets(tickets) {
for (const t of tickets) {
const ro = t.registrationOption || {};
if (!ro.sessionId) {
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
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 || ''}`;
@@ -487,7 +572,7 @@ module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
registrationClosesAt, wholeCourseClosesAt, choosesSessions, isPickOption, expandPickLines, lineSessions, lineCovers, mergeSessionIds, sessionBookings, sessionPlacesFree, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+1 -1
View File
@@ -11,7 +11,7 @@ const prisma = require('../config/db');
const { decrypt, isEncrypted } = require('./encryption');
// Keys stored encrypted in the DB — decrypted transparently on read.
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret', 'payfast_passphrase']);
let _cache = {};
let _lastFetch = 0;
+5 -3
View File
@@ -37,15 +37,17 @@ async function takenCount({ eventOptionId, variantId = null, sessionId = null },
/**
* 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));
+63 -14
View File
@@ -11,11 +11,16 @@
* offered what's free; otherwise they're skipped for the next person who fits.
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
* - "Pick sessions" options are waited for per session (entry.sessionId): free places are
* that session's, an offer never runs past the session's start, and nobody is offered a
* session that has started or was cancelled. Main tickets also need room in the
* session(s) they cover when sessions have a capacity (utils/sessions.js).
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { remainingFor } = require('./stock');
const { activeSessions, isPickOption, sessionBookings, sessionPlacesFree, sessionLabel, wholeCourseClosesAt } = require('./sessions');
const ACTIVE = ['waiting', 'offered'];
@@ -27,6 +32,42 @@ function waitlistError(message, statusCode = 400) {
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
/**
* Places free on a waitlist line: option/variant stock (per session for a picked line)
* and, for main tickets on events with session capacities, room in the session(s).
* @param {object} event - with sessions
* @returns {Promise<number|null>} null when unlimited
*/
async function placesFree(event, option, variant, sessionId = null, { db = prisma, excludeEntryId = null } = {}) {
const limits = [await remainingFor(option, variant, { sessionId, db, excludeEntryId })];
if (event.scheduleType === 'sessions' && option.isMainTicket && (event.sessions || []).some(s => s.capacity > 0)) {
limits.push(sessionPlacesFree(event.sessions, await sessionBookings(event.id, db, { excludeEntryId }), sessionId));
}
const known = limits.filter(n => n !== null);
return known.length ? Math.min(...known) : null;
}
/** Whether a waitlist line can still be offered at `now` (its session hasn't started, etc.). */
function lineOpen(event, sessionId, now = new Date()) {
if (!sessionId) return event.scheduleType !== 'sessions' || now < wholeCourseClosesAt(event, event.sessions);
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
return !!session && new Date(session.startsAt) > now;
}
/** When an offer made now lapses: the event's offer hours, but never after its session starts. */
function offerExpiry(hours, session = null, now = new Date()) {
const expires = new Date(now.getTime() + (hours || 24) * 60 * 60 * 1000);
return session && new Date(session.startsAt) < expires ? new Date(session.startsAt) : expires;
}
/** "Drop-in (Large)" or "Drop-in — Class 2, Wed 4 Nov" for messages. */
function lineName(entry, sessions = entry.event?.sessions) {
const base = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
if (!entry.session) return base;
const when = new Date(entry.session.startsAt).toLocaleDateString('en-GB', { timeZone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', weekday: 'short', day: 'numeric', month: 'short' });
return `${base} — ${sessionLabel(entry.session, sessions || [entry.session])}, ${when}`;
}
function claimLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/waitlist/claim/${token}`;
@@ -63,7 +104,7 @@ async function notifyOffer(entry, token) {
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const user = entry.user;
const link = claimLink(token);
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
const item = lineName(entry);
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const partialNote = entry.offeredQuantity < entry.quantity
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
@@ -86,10 +127,10 @@ async function notifyOffer(entry, token) {
await Promise.allSettled(sends);
}
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
const OFFER_INCLUDE = { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, hours) {
/** Puts an entry on offer until `expiresAt` (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, expiresAt) {
const token = crypto.randomBytes(24).toString('hex');
await db.waitlistEntry.update({
where: { id: entry.id },
@@ -98,7 +139,7 @@ async function writeOffer(db, entry, quantity, hours) {
offeredQuantity: quantity,
offerTokenHash: hashToken(token),
offeredAt: new Date(),
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
offerExpiresAt: expiresAt,
},
});
return token;
@@ -113,8 +154,8 @@ async function notifyOffers(offers) {
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
async function makeOffer(entryId, quantity) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true, session: true } });
const token = await writeOffer(prisma, entry, quantity, offerExpiry(entry.event.waitlistOfferHours, entry.session));
await notifyOffers([{ entryId, token }]);
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
}
@@ -127,7 +168,7 @@ async function makeOffer(entryId, quantity) {
async function processWaitlist(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { variants: true } } },
include: { eventOptions: { include: { variants: true } }, sessions: true },
});
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
if (new Date(event.endDate) < new Date()) return 0;
@@ -136,16 +177,20 @@ async function processWaitlist(eventId) {
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
const lineKey = (w) => `${w.eventOptionId}::${w.variantId || ''}::${w.sessionId || ''}`;
const lines = [...new Map(waiting.map(w => [lineKey(w), w])).values()];
for (const sample of lines) {
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
if (!option) continue;
const free = await remainingFor(option, variant, { db: tx });
if (!option || !lineOpen(event, sample.sessionId)) continue;
// An option switched between per-session and whole-course after people joined.
if (!!sample.sessionId !== isPickOption(event, option)) continue;
const free = await placesFree(event, option, variant, sample.sessionId, { db: tx });
if (free === null || free <= 0) continue;
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
const session = sample.sessionId ? event.sessions.find(s => s.id === sample.sessionId) : null;
const queue = waiting.filter(w => lineKey(w) === lineKey(sample));
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, offerExpiry(event.waitlistOfferHours, session));
made.push({ entryId: offer.entryId, token });
}
}
@@ -183,7 +228,7 @@ async function findOfferByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.waitlistEntry.findUnique({
where: { offerTokenHash: hashToken(token) },
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
include: { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true } } },
});
}
@@ -192,6 +237,10 @@ module.exports = {
waitlistError,
hashToken,
pickOffers,
placesFree,
lineOpen,
offerExpiry,
lineName,
makeOffer,
processWaitlist,
expireOffers,
+6 -3
View File
@@ -80,15 +80,18 @@ function friendlyYocoError(e) {
return err;
}
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
async function yocoRequest(method, path, body, key) {
/**
* Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it);
* `headers` adds request headers (e.g. Idempotency-Key).
*/
async function yocoRequest(method, path, body, key, headers = {}) {
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
try {
const res = await axios({
method,
url: `${YOCO_API}${path}`,
data: body,
headers: { 'Content-Type': 'application/json', Authorization: auth },
headers: { 'Content-Type': 'application/json', Authorization: auth, ...headers },
timeout: 15000,
});
return res.data;
+136
View File
@@ -0,0 +1,136 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const crypto = require('crypto');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payfast = require('../src/payments/providers/payfast');
const status = require('../src/payments/status');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
// As PayFast's sandbox returned it (CRLF lines, quoted amounts with thousands separators)
const CSV = [
'Date,Type,Sign,Party,Name,Description,Currency,"Funding Type",Gross,Fee,Net,Balance,"M Payment ID","PF Payment ID","custom str1"',
'"2026-10-23 14:19:24",FUNDS_RECEIVED,CREDIT,"Test User 01","Family Fun Day",Adult,ZAR,WALLET,80.00,-2.30,77.70,"22,574.02",chk_a,3471967,',
'"2026-10-23 15:00:00",FUNDS_RECEIVED,CREDIT,"Test User 01","Big ""Camp""",,ZAR,CC,"1,250.00",-20.00,"1,230.00",,chk_b,3471999,',
'"2026-10-23 16:00:00",REFUND,DEBIT,"Test User 01",x,,ZAR,CC,-80.00,0,-80.00,,chk_c,3472000,',
].join('\r\n');
beforeEach(() => {
jest.clearAllMocks();
settings(PF);
});
describe('PayFast API', () => {
test('requests are signed over the alphabetically sorted variables and passphrase', () => {
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
expect(payfast.apiSignature({ version: 'v1', 'merchant-id': '10000100', timestamp: '2026-10-23T10:00:00+00:00', passphrase: 'Salt pass', from: '' }))
.toBe(md5('merchant-id=10000100&passphrase=Salt+pass&timestamp=2026-10-23T10%3A00%3A00%2B00%3A00&version=v1'));
});
test('reads PayFast CSV, quoted commas and quotes included', () => {
const rows = payfast.parseCsv(CSV);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ Type: 'FUNDS_RECEIVED', Gross: '80.00', Balance: '22,574.02', 'M Payment ID': 'chk_a', 'PF Payment ID': '3471967' });
expect(rows[1]).toMatchObject({ Name: 'Big "Camp"', Gross: '1,250.00' });
});
test('findPayments matches history rows on our checkout id (money received only)', async () => {
const get = jest.fn().mockResolvedValue({ data: CSV });
const list = ['chk_a', 'chk_b', 'chk_c'].map(id => ({ id, createdAt: new Date() }));
const found = await payfast.findPayments(list, { get });
expect([...found.keys()]).toEqual(['chk_a', 'chk_b']);
expect(found.get('chk_a')).toMatchObject({ externalId: '3471967', amountCents: 8000, checkoutId: 'chk_a', status: 'succeeded' });
expect(found.get('chk_a').createdDate.toISOString()).toBe('2026-10-23T12:19:24.000Z');
expect(found.get('chk_b').amountCents).toBe(125000);
const [url, opts] = get.mock.calls[0];
expect(url).toBe('https://api.payfast.co.za/transactions/history');
expect(opts.params).toMatchObject({ testing: 'true', limit: 1000, offset: 0 });
expect(opts.headers).toMatchObject({ 'merchant-id': '10000100', version: 'v1', signature: expect.any(String) });
});
test('live mode leaves out testing=true', async () => {
settings({ ...PF, payfast_mode: 'live' });
const get = jest.fn().mockResolvedValue({ data: CSV });
await payfast.findPayments([{ id: 'chk_a', createdAt: new Date() }], { get });
expect(get.mock.calls[0][1].params.testing).toBeUndefined();
});
test('a refused passphrase reads clearly', async () => {
const get = jest.fn().mockRejectedValue({ response: { status: 401, data: '{"data":{"response":"Merchant authorization failed."}}' } });
await expect(payfast.testConnection({ get })).rejects.toThrow(/refused the merchant ID or passphrase/);
});
});
describe('checkCheckouts', () => {
const checkout = (id, provider, cents) => ({ id, provider, externalId: null, amountCents: cents, metadata: { registrationId: 'r9', userId: 'u9' }, createdAt: new Date() });
let spy;
beforeEach(() => {
prisma.providerTransaction.findFirst.mockResolvedValue(null);
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', status: 'pending', registrationOptions: [], payments: [{ amount: 80 }] });
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Fun Day' }, user: { id: 'u9', name: 'T' } });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', ...data }));
computeRegistrationTotalDue.mockReturnValue(80);
prisma.payment.findFirst.mockResolvedValue(null);
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.ticket.findMany.mockResolvedValue([]);
spy = jest.spyOn(payfast, 'findPayments');
});
afterEach(() => spy.mockRestore());
test('a payment PayFast received is recorded as its ITN would have been', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '3471967', amountCents: 8000, currency: 'ZAR', createdDate: new Date(), checkoutId: 'chk_a', methodType: null, metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000)]);
expect(out.get('chk_a')).toEqual({ status: 'recorded' });
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 80, provider: 'payfast', externalId: '3471967', registrationId: 'r9', status: 'completed' });
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_a'] } } }));
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '3471967', reconciled: true }) });
});
test('a different amount is held for review; nothing found is not paid', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '1', amountCents: 2000, checkoutId: 'chk_a', metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000), checkout('chk_n', 'payfast', 5000)]);
expect(out.get('chk_a')).toMatchObject({ status: 'needs_review', note: expect.stringMatching(/Paid R20.00/) });
expect(out.get('chk_n')).toEqual({ status: 'not_paid' });
expect(prisma.payment.create).not.toHaveBeenCalled();
});
test('already paid checkouts are skipped; Yoco can\'t be asked', async () => {
prisma.providerTransaction.findFirst.mockImplementation(({ where }) => Promise.resolve(where.checkoutId.in.includes('chk_paid') && where.paymentId ? { id: 'x' } : null));
spy.mockResolvedValue(new Map());
const out = await status.checkCheckouts([checkout('chk_paid', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
expect(out.get('chk_paid')).toEqual({ status: 'paid' });
expect(out.get('chk_y')).toEqual({ status: 'cant_check' });
expect(spy).not.toHaveBeenCalled();
});
test('the job checks checkouts between 3 days and 10 minutes old, with providers that can be asked', async () => {
const now = new Date('2026-10-23T12:00:00Z');
prisma.providerCheckout.findMany.mockResolvedValue([checkout('chk_a', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
spy.mockResolvedValue(new Map());
await expect(status.runPaymentStatusJob(now)).resolves.toEqual({ checked: 1, recorded: 0, needsReview: 0 });
expect(prisma.providerCheckout.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { provider: { not: null }, createdAt: { gte: new Date('2026-10-20T12:00:00Z'), lte: new Date('2026-10-23T11:50:00Z') } },
}));
expect(spy).toHaveBeenCalledWith([expect.objectContaining({ id: 'chk_a' })]);
});
});
+375
View File
@@ -0,0 +1,375 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); fn.get = jest.fn(); return fn; });
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const axios = require('axios');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payments = require('../src/payments');
const yoco = require('../src/payments/providers/yoco');
const payfast = require('../src/payments/providers/payfast');
const { handleProviderWebhook } = require('../src/payments/webhook');
const SECRET = 'whsec_' + Buffer.from('test-webhook-secret').toString('base64');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
const ENV = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ENV };
delete process.env.YOCO_SECRET_KEY;
delete process.env.YOCO_WEBHOOK_SECRET;
for (const k of ['PAYFAST_MERCHANT_ID', 'PAYFAST_MERCHANT_KEY', 'PAYFAST_PASSPHRASE', 'PAYFAST_MODE', 'PAYFAST_SKIP_IP_CHECK']) delete process.env[k];
process.env.FRONTEND_URL = 'https://events.example.org';
process.env.BACKEND_URL = 'https://api.example.org';
prisma.providerCheckout.create.mockImplementation(({ data }) => Promise.resolve({ id: 'chk_1', provider: null, externalId: null, redirectUrl: null, ...data }));
prisma.providerCheckout.update.mockResolvedValue({});
prisma.providerCheckout.delete.mockResolvedValue({});
});
describe('provider settings', () => {
test('Yoco is on by default (it was the only provider)', () => {
expect(payments.parseProviderSettings('', ['yoco', 'payfast'])).toEqual([
{ id: 'yoco', enabled: true }, { id: 'payfast', enabled: false },
]);
});
test('stored order and flags win; unknown ids are dropped, new ones come last', () => {
const raw = JSON.stringify({ order: ['payfast', 'gone', 'yoco'], enabled: { yoco: false, payfast: true } });
expect(payments.parseProviderSettings(raw, ['yoco', 'payfast', 'snapscan'])).toEqual([
{ id: 'payfast', enabled: true }, { id: 'yoco', enabled: false }, { id: 'snapscan', enabled: false },
]);
});
test('bad JSON falls back to the defaults', () => {
expect(payments.parseProviderSettings('{nope', ['yoco'])).toEqual([{ id: 'yoco', enabled: true }]);
});
test('serialising keeps the order and drops unknown providers', () => {
expect(JSON.parse(payments.serializeProviderSettings([{ id: 'yoco', enabled: false }, { id: 'zzz', enabled: true }])))
.toEqual({ order: ['yoco'], enabled: { yoco: false } });
});
});
describe('createCheckout', () => {
test('online payments must be at least R15', async () => {
await expect(payments.createCheckout(null, { amountCents: 1499 })).rejects.toThrow(/at least R15/);
expect(prisma.providerCheckout.create).not.toHaveBeenCalled();
});
test('nothing enabled: online payments are off', async () => {
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/turned off/) });
});
test('asking for a provider that is switched off is refused', async () => {
settings({ payment_providers: JSON.stringify({ order: ['yoco'], enabled: { yoco: false } }) });
await expect(payments.createCheckout('yoco', { amountCents: 5000 })).rejects.toThrow(/Yoco is turned off/);
});
test('enabled but no key: the provider explains, and the checkout record is removed', async () => {
settings({});
axios.mockRejectedValue(new Error('should not be called'));
await expect(payments.createCheckout(null, { amountCents: 5000 })).rejects.toThrow(/Yoco secret key/);
expect(prisma.providerCheckout.delete).toHaveBeenCalledWith({ where: { id: 'chk_1' } });
});
test('one provider available: starts it straight away and records the checkout', async () => {
settings({ yoco_secret_key: 'sk_test_abc' });
const spy = jest.spyOn(yoco, 'createCheckout').mockResolvedValue({ externalId: 'ch_1', redirectUrl: 'https://pay/1' });
const out = await payments.createCheckout(null, { amountCents: 5000, metadata: { a: 1 }, description: 'Camp' });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'yoco', redirectUrl: 'https://pay/1' });
expect(prisma.providerCheckout.create).toHaveBeenCalledWith({ data: { amountCents: 5000, currency: 'ZAR', metadata: { a: 1 }, request: { description: 'Camp' } } });
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ checkoutId: 'chk_1', amountCents: 5000, currency: 'ZAR', metadata: { a: 1 } }));
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'yoco', externalId: 'ch_1', redirectUrl: 'https://pay/1' } });
spy.mockRestore();
});
test('several available: the payer chooses on /pay/<id>', async () => {
settings({ yoco_secret_key: 'sk_test_abc', ...PF, payment_providers: JSON.stringify({ order: ['payfast', 'yoco'], enabled: { yoco: true, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: null, redirectUrl: 'https://events.example.org/pay/chk_1' });
});
test('a form provider (PayFast) sends the payer to /pay/<id>, which posts the form', async () => {
settings({ ...PF, payment_providers: JSON.stringify({ order: ['payfast'], enabled: { yoco: false, payfast: true } }) });
const out = await payments.createCheckout(null, { amountCents: 5000 });
expect(out).toEqual({ checkoutId: 'chk_1', provider: 'payfast', redirectUrl: 'https://events.example.org/pay/chk_1' });
expect(prisma.providerCheckout.update).toHaveBeenCalledWith({ where: { id: 'chk_1' }, data: { provider: 'payfast' } });
});
});
describe('Yoco provider', () => {
const now = 1_800_000_000_000;
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_1', amount: 12000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_1', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r1' } } });
const signed = (overrides = {}) => {
const ts = String(Math.floor(now / 1000));
return { headers: { 'webhook-id': 'msg_1', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_1', ts, body)}`, ...overrides }, rawBody: body };
};
test('line items map to Yoco cents, deductions stay negative', () => {
expect(yoco.toYocoLineItems([{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }])).toEqual([
{ displayName: 'Ticket', quantity: 2, pricingDetails: { price: 5000 } },
{ displayName: 'Coupon', quantity: 1, pricingDetails: { price: -1000 } },
]);
});
test('a correctly signed webhook verifies', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: true, webhookId: 'msg_1' });
});
test('any of several signatures may match (key rotation)', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
const req = signed();
req.headers['webhook-signature'] = `v1,bm9wZQ== ${req.headers['webhook-signature']}`;
await expect(yoco.verifyWebhook(req, now)).resolves.toMatchObject({ ok: true });
});
test('bad signature, old timestamp, missing headers, no secret', async () => {
process.env.YOCO_WEBHOOK_SECRET = SECRET;
settings({});
await expect(yoco.verifyWebhook(signed({ 'webhook-signature': 'v1,bm9wZQ==' }), now)).resolves.toMatchObject({ ok: false, status: 403 });
await expect(yoco.verifyWebhook(signed(), now + 4 * 60 * 1000)).resolves.toMatchObject({ ok: false, status: 400, message: expect.stringMatching(/timestamp/) });
await expect(yoco.verifyWebhook({ headers: {}, rawBody: body }, now)).resolves.toMatchObject({ ok: false, status: 400, details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'] } });
delete process.env.YOCO_WEBHOOK_SECRET;
settings({});
await expect(yoco.verifyWebhook(signed(), now)).resolves.toMatchObject({ ok: false, status: 500 });
});
test('parses a payment.succeeded body', () => {
expect(yoco.parseWebhook(JSON.parse(body))).toEqual({
type: 'payment.succeeded',
transaction: {
externalId: 'p_1', amountCents: 12000, currency: 'ZAR', createdDate: null, checkoutId: 'ch_1',
methodType: 'card', metadata: { registrationId: 'r1' }, status: 'succeeded',
},
});
});
});
describe('PayFast provider', () => {
test('encodes like PHP urlencode: upper-case escapes, + for spaces, and \' ( ) ! * ~ escaped', () => {
expect(payfast.pfEncode("Men's Camp (Youth)! *~")).toBe('Men%27s+Camp+%28Youth%29%21+%2A%7E');
expect(payfast.pfEncode('https://x.org/a?b=1&c=2')).toBe('https%3A%2F%2Fx.org%2Fa%3Fb%3D1%26c%3D2');
expect(payfast.pfEncode(' padded ')).toBe('padded');
});
test('a checkout signs the non-blank fields in order; an ITN signs every field', () => {
const pairs = [['a', '1'], ['b', ''], ['c', 'x y']];
expect(payfast.paramString(pairs)).toBe('a=1&c=x+y');
expect(payfast.paramString(pairs, { allFields: true })).toBe('a=1&b=&c=x+y');
const md5 = (s) => require('crypto').createHash('md5').update(s).digest('hex');
expect(payfast.signature(pairs, 'my pass')).toBe(md5('a=1&c=x+y&passphrase=my+pass'));
expect(payfast.signature(pairs)).toBe(md5('a=1&c=x+y'));
});
test('builds the signed form in PayFast\'s field order', async () => {
settings(PF);
const { form } = await payfast.createCheckout({
checkoutId: 'chk_9', amountCents: 12550, successUrl: 'https://e.org/ok', cancelUrl: 'https://e.org/no',
payer: { name: "Thandi O'Neil Mokoena", email: 'thandi@example.org' }, description: "Men's Camp (Youth)",
lineItems: [{ name: 'Ticket', quantity: 2, unitCents: 5000 }, { name: 'Coupon', quantity: 1, unitCents: -1000 }],
});
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
expect(signature).toBe(payfast.signature(Object.entries(rest), 'Salt pass'));
});
test('live mode posts to www.payfast.co.za; test emails are left out', async () => {
settings({ ...PF, payfast_mode: 'live' });
const { form } = await payfast.createCheckout({ checkoutId: 'c', amountCents: 2000, payer: { name: 'A', email: 'walkin@guest.local' } });
expect(form.action).toBe('https://www.payfast.co.za/eng/process');
expect(form.fields.email_address).toBeUndefined();
});
test('not set up: says what is missing', async () => {
settings({});
await expect(payfast.createCheckout({ checkoutId: 'c', amountCents: 2000 })).rejects.toMatchObject({ statusCode: 503, message: expect.stringMatching(/merchant ID and key/) });
});
describe('ITN checks', () => {
// An ITN as PayFast posts it: every field, blanks included, signature last
const itn = (overrides = {}, passphrase = 'Salt pass') => {
const pairs = Object.entries({
m_payment_id: 'chk_9', pf_payment_id: '1089250', payment_status: 'COMPLETE', item_name: "Men's Camp (Youth)", item_description: '',
amount_gross: '125.50', amount_fee: '-4.60', amount_net: '120.90', custom_str1: '', name_first: 'Thandi', name_last: '', email_address: 'thandi@example.org',
merchant_id: '10000100', ...overrides,
});
const sig = payfast.signature(pairs, passphrase, { allFields: true });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${sig}`;
};
const req = (rawBody, ip = '197.97.145.144') => ({ rawBody, ip, headers: {} });
const deps = (reply = 'VALID', ips = ['197.97.145.144']) => ({ lookupIps: async () => new Set(ips), post: jest.fn().mockResolvedValue({ data: reply }) });
beforeEach(() => settings(PF));
test('a genuine ITN passes, and is confirmed with PayFast over every field', async () => {
const d = deps();
await expect(payfast.verifyWebhook(req(itn()), d)).resolves.toMatchObject({ ok: true, webhookId: '1089250' });
const [url, sent] = d.post.mock.calls[0];
expect(url).toBe('https://sandbox.payfast.co.za/eng/query/validate');
expect(sent).toContain('item_description=&');
expect(sent).not.toContain('signature=');
});
test('a forged or wrongly signed ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({}, 'wrong passphrase')), deps())).resolves.toMatchObject({ ok: false, status: 403, message: 'Invalid signature' });
const tampered = itn().replace('amount_gross=125.50', 'amount_gross=1.00');
await expect(payfast.verifyWebhook(req(tampered), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('not from a PayFast address: refused (unless the check is switched off)', async () => {
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: false, status: 403, message: expect.stringMatching(/did not come from PayFast/) });
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
await expect(payfast.verifyWebhook(req(itn(), '10.0.0.5'), deps())).resolves.toMatchObject({ ok: true });
});
test('PayFast doesn\'t confirm it, or is unreachable', async () => {
await expect(payfast.verifyWebhook(req(itn()), deps('INVALID'))).resolves.toMatchObject({ ok: false, status: 403 });
const down = { lookupIps: async () => new Set(['197.97.145.144']), post: jest.fn().mockRejectedValue(new Error('timeout')) };
await expect(payfast.verifyWebhook(req(itn()), down)).resolves.toMatchObject({ ok: false, status: 502 });
});
test('another merchant\'s ITN is refused', async () => {
await expect(payfast.verifyWebhook(req(itn({ merchant_id: '99999999' })), deps())).resolves.toMatchObject({ ok: false, status: 403 });
});
test('parses COMPLETE as a successful payment', () => {
expect(payfast.parseWebhook(payfast.readWebhookBody(itn()))).toEqual({
type: 'payment.succeeded',
transaction: { externalId: '1089250', amountCents: 12550, currency: 'ZAR', createdDate: null, checkoutId: 'chk_9', methodType: null, metadata: {}, status: 'succeeded' },
});
expect(payfast.parseWebhook({ payment_status: 'CANCELLED' }).type).toBe('payment.cancelled');
});
});
});
describe('webhook flow', () => {
const response = () => {
const res = { statusCode: 200, body: null };
res.status = (c) => { res.statusCode = c; return res; };
res.json = (b) => { res.body = b; return res; };
return res;
};
beforeEach(() => {
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Camp' }, user: { id: 'u9', name: 'T' } });
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', registrationOptions: [], payments: [{ amount: 100 }] });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', userId: 'u9', eventId: 'e9', ...data }));
computeRegistrationTotalDue.mockReturnValue(100);
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.providerCheckout.findUnique.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue(null);
prisma.ticket.findMany.mockResolvedValue([]);
});
describe('Yoco', () => {
const body = JSON.stringify({ type: 'payment.succeeded', payload: { id: 'p_9', amount: 15000, currency: 'ZAR', status: 'succeeded', checkoutId: 'ch_9', paymentMethodDetails: { type: 'card' }, metadata: { registrationId: 'r9', userId: 'u9', donationAmount: '50.00' } } });
const request = () => {
const ts = String(Math.floor(Date.now() / 1000));
return { headers: { 'webhook-id': 'msg_9', 'webhook-timestamp': ts, 'webhook-signature': `v1,${yoco.webhookSignature(SECRET, 'msg_9', ts, body)}` }, rawBody: body };
};
beforeEach(() => { process.env.YOCO_WEBHOOK_SECRET = SECRET; settings({}); });
test('records the payment (donation split off), tagged with the provider, and a reconciled transaction', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.statusCode).toBe(200);
expect(res.body.data.processedData.registration).toMatchObject({ id: 'r9', status: 'paid' });
const [main, donation] = prisma.payment.create.mock.calls.map(c => c[0].data);
expect(main).toMatchObject({ amount: 100, method: 'card', provider: 'yoco', externalId: 'p_9', registrationId: 'r9', isDonation: false, status: 'completed' });
expect(donation).toMatchObject({ amount: 50, provider: 'yoco', isDonation: true, originalPaymentId: main.id });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'yoco', externalId: 'p_9', amount: 15000, reconciled: true, paymentId: main.id }) });
});
test('found through our checkout record: the registration is looked up by our id too', async () => {
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findFirst.mockResolvedValue({ id: 'chk_ours', provider: 'yoco', externalId: 'ch_9', amountCents: 15000, metadata: { registrationId: 'r9' } });
await handleProviderWebhook('yoco')(request(), response());
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_ours', 'ch_9'] } } }));
expect(prisma.payment.create).toHaveBeenCalled();
});
test('a repeated webhook records nothing new', async () => {
prisma.payment.findFirst.mockResolvedValue({ id: 'existing' });
prisma.providerTransaction.findUnique.mockResolvedValue({ id: 'ptx' });
prisma.providerTransaction.update.mockResolvedValue({ id: 'ptx' });
const res = response();
await handleProviderWebhook('yoco')(request(), res);
expect(res.body.data.processedData).toMatchObject({ status: 'already_processed', paymentId: 'existing' });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.update).toHaveBeenCalledWith({ where: { id: 'ptx' }, data: { reconciled: true, paymentId: 'existing' } });
});
test('a bad signature is refused before anything is stored', async () => {
const req = request();
req.headers['webhook-signature'] = 'v1,bm9wZQ==';
const res = response();
await handleProviderWebhook('yoco')(req, res);
expect(res.statusCode).toBe(403);
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).not.toHaveBeenCalled();
});
});
describe('PayFast', () => {
const itn = (amount = '100.00') => {
const pairs = Object.entries({ m_payment_id: 'chk_pf', pf_payment_id: '555', payment_status: 'COMPLETE', item_name: 'Camp', amount_gross: amount, merchant_id: '10000100' });
return pairs.map(([k, v]) => `${k}=${payfast.pfEncode(v)}`).join('&') + `&signature=${payfast.signature(pairs, 'Salt pass', { allFields: true })}`;
};
beforeEach(() => {
settings(PF);
process.env.PAYFAST_SKIP_IP_CHECK = 'true';
axios.post.mockResolvedValue({ data: 'VALID' });
prisma.payment.findFirst.mockResolvedValue(null);
prisma.providerCheckout.findUnique.mockResolvedValue({ id: 'chk_pf', provider: 'payfast', amountCents: 10000, metadata: { registrationId: 'r9', userId: 'u9', eventId: 'e9', type: 'registration_payment' } });
});
test('a confirmed ITN records the payment against the checkout\'s registration', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn(), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 100, provider: 'payfast', method: 'card', externalId: '555', registrationId: 'r9', status: 'completed' });
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '555', checkoutId: 'chk_pf', reconciled: true }) });
});
test('paid a different amount from the checkout: held for review, not recorded', async () => {
const res = response();
await handleProviderWebhook('payfast')({ rawBody: itn('20.00'), ip: '1.2.3.4', headers: {} }, res);
expect(res.statusCode).toBe(200);
expect(res.body.data).toEqual({ needsReview: true });
expect(prisma.payment.create).not.toHaveBeenCalled();
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', reconciled: false, note: expect.stringMatching(/Paid R20.00, but the checkout was for R100.00/) }) });
});
});
});
+39 -4
View File
@@ -19,6 +19,10 @@ const {
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
choosesSessions,
lineSessions,
mergeSessionIds,
ticketDateText,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
@@ -281,28 +285,59 @@ describe('pick sessions', () => {
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('choosesSessions: pick-mode multi-day events only', () => {
expect(choosesSessions(pickEvent)).toBe(true);
expect(choosesSessions({ ...pickEvent, attendanceMode: 'whole' })).toBe(false);
expect(choosesSessions({ ...pickEvent, scheduleType: 'single' })).toBe(false);
});
test('lineSessions / mergeSessionIds', () => {
expect(lineSessions({ sessionId: 's2', sessionIds: [] })).toEqual(['s2']);
expect(lineSessions({ sessionId: null, sessionIds: ['s1', 's3'] })).toEqual(['s1', 's3']);
expect(lineSessions({ sessionId: null, sessionIds: [] })).toBeNull();
expect(mergeSessionIds(['s1'], ['s3', 's1'])).toEqual(['s1', 's3']);
expect(mergeSessionIds(['s1'], [])).toEqual([]);
});
test('an all-sessions ticket with sessions ticked only scans on those', () => {
const ev = { checkInWindow: 'whole_day' };
const onDay2 = new Date(wed(11).getTime() + 30 * 60000);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s2', 's4']).session.id).toBe('s2');
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s1', 's4']).error).toMatch(/isn't booked for Session 2/);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, []).session.id).toBe('s2');
});
test('ticket dates list the ticked sessions', () => {
const ev = { ...pickEvent, sessions: COURSE };
expect(ticketDateText(ev, 'short', null, null, ['s2', 's4'])).toBe('2 sessions · 11 Nov, 25 Nov');
expect(ticketDateText(ev, 'long', null, null, ['s2'])).toBe('Wednesday, 11 November 2026 (Session 2)');
});
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
});
test('a pick line becomes one line per session; others stay as they are', () => {
test('a pick line becomes one line per session; an all-sessions line keeps its ticked sessions', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
{ eventOptionId: 'full', quantity: 1, sessionIds: ['s2', 's5'] },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: [] },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: ['s2', 's5'] },
]);
});
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
test('sessions must be chosen, upcoming, and only on events where people choose them', () => {
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines({ ...pickEvent, attendanceMode: 'whole' }, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
});
+60
View File
@@ -29,3 +29,63 @@ describe('pickOffers', () => {
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
});
});
const { lineOpen, offerExpiry, lineName } = require('../src/utils/waitlist');
const { sessionPlacesFree } = require('../src/utils/sessions');
const at = (h) => new Date(Date.UTC(2026, 10, 4, h));
const sessions = [
{ id: 's1', startsAt: at(8), endsAt: at(9), capacity: 10, cancelledAt: null },
{ id: 's2', startsAt: at(10), endsAt: at(11), capacity: 4, cancelledAt: null },
{ id: 's3', startsAt: at(12), endsAt: at(13), capacity: null, cancelledAt: null },
{ id: 's4', startsAt: at(14), endsAt: at(15), capacity: 1, cancelledAt: at(1) },
];
describe('sessionPlacesFree', () => {
const booked = new Map([['s1', 7], ['s2', 4], ['s3', 50]]);
test('a picked session: its own capacity', () => {
expect(sessionPlacesFree(sessions, booked, 's1')).toBe(3);
expect(sessionPlacesFree(sessions, booked, 's2')).toBe(0);
});
test('a session without a capacity is unlimited', () => {
expect(sessionPlacesFree(sessions, booked, 's3')).toBeNull();
});
test('whole-course: the fullest limited session, cancelled ones ignored', () => {
expect(sessionPlacesFree(sessions, booked, null)).toBe(0);
expect(sessionPlacesFree(sessions, new Map([['s1', 2], ['s2', 1]]), null)).toBe(3);
});
});
describe('lineOpen', () => {
const event = { scheduleType: 'sessions', attendanceMode: 'pick', endDate: at(13), sessions };
test('a session can be offered until it starts', () => {
expect(lineOpen(event, 's2', at(9))).toBe(true);
expect(lineOpen(event, 's2', at(10))).toBe(false);
});
test('cancelled or unknown sessions are not offered', () => {
expect(lineOpen(event, 's4', at(1))).toBe(false);
expect(lineOpen(event, 'nope', at(1))).toBe(false);
});
test('whole-course lines close when the course does', () => {
expect(lineOpen(event, null, at(7))).toBe(true);
expect(lineOpen(event, null, at(8))).toBe(false);
expect(lineOpen({ scheduleType: 'single' }, null, at(20))).toBe(true);
});
});
describe('offerExpiry', () => {
test('the offer hours, but never after the session starts', () => {
expect(offerExpiry(24, null, at(0))).toEqual(new Date(at(0).getTime() + 24 * 3600 * 1000));
expect(offerExpiry(24, sessions[1], at(0))).toEqual(at(10));
expect(offerExpiry(2, sessions[1], at(0))).toEqual(at(2));
});
});
describe('lineName', () => {
test('names the session for picked lines', () => {
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: null, session: null })).toBe('Drop-in');
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: { name: 'Adult' }, session: { ...sessions[1], label: 'Class 2' } }, sessions))
.toBe('Drop-in (Adult) — Class 2, Wed 4 Nov');
expect(lineName({ eventOption: { name: 'Drop-in' }, session: sessions[1] }, sessions)).toBe('Drop-in — Session 2, Wed 4 Nov');
});
});
+40 -15
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.35.0**. The plan itself is in
Last updated: 2026-10-23, after **1.37.3**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -12,20 +12,17 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
| 1.37.1 | "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap |
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
## Next up
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
provider transaction table; Site settings → Payments with an Enabled toggle per
provider and display order. Decisions: fees are **never** deducted or passed on
(people handle fees at cashup); minimum online payment **R15** for all providers.
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
current API documentation links from Joshua before building each one, and don't
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
1. **1.38.0–1.39.0 — POPIA toolkit** (spec §3 in the roadmap).
2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`).
## Working on this repo
@@ -35,6 +32,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
@@ -52,6 +50,12 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
## Tests
- Backend unit tests: `cd backend && npm test` (Jest).
- PayFast e2e (`e2e/payfast.spec.ts`) pays into a real PayFast sandbox account and is skipped
unless `E2E_PAYFAST_MERCHANT_ID`, `E2E_PAYFAST_MERCHANT_KEY` and `E2E_PAYFAST_PASSPHRASE` are
set (from the Sandbox dashboard). PayFast's documented public test merchant (10000100) no
longer accepts payments. ITNs can't reach localhost; they're covered by unit tests
(`backend/tests/payments.test.js`) — to try one for real, use a tunnel, a public
`BACKEND_URL` and `PAYFAST_SKIP_IP_CHECK=true`.
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
`npx playwright install chromium`). They start their own backend (5100) and
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
@@ -81,10 +85,31 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
## Where the payment code lives
- `backend/src/payments/index.js` — the provider registry and the `PaymentProvider` interface
(documented at the top), provider on/off and order (AppSetting `payment_providers`), the R15
minimum, and `createCheckout`. Tests: `backend/tests/payments.test.js`.
- `backend/src/payments/providers/yoco.js` — Yoco: checkouts, webhook signature check, webhook
parsing. Keys/webhook admin stays in `utils/yoco.js` + `controllers/yocoController.js`.
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
donation split, registration status, tickets, emails, `ProviderTransaction`).
- `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation).
- `backend/src/payments/status.js` — status checks for notifications that never arrived
(`payment-status` job, Payments → Reconcile → "Waiting for payment"). PayFast looks payments
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
(`routes/webhookRoutes.js`), and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`.
## Known gaps / follow-ups
- The at-the-door booking pop-up doesn't have the session picker; per-session tickets
are booked from Manual registration (the page says so).
- The waitlist works per ticket option, not per session.
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
+25 -22
View File
@@ -15,8 +15,12 @@ so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity). **Next: 1.36.0 payment provider interface (Yoco
moved behind it), then PayFast, SnapScan, Zapper.**
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23).
**Next: 1.38.0 POPIA toolkit.**
**Repositories:**
@@ -30,7 +34,7 @@ moved behind it), then PayFast, SnapScan, Zapper.**
## Contents
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
@@ -265,11 +269,12 @@ it's not used for check-in decisions; "checked in" is per session
---
## 2. Payment providers: PayFast, SnapScan, Zapper
## 2. Payment providers: PayFast
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
**Zapper**, and let the admin **enable one or more** in Site settings. At
checkout the user picks from the enabled ones.
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
settings. At checkout the user picks from the enabled ones. (Shipped in
1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
two providers are enough.)
**Who:** Admins configure providers. Users choose at payment. Supervisors
reconcile.
@@ -278,10 +283,10 @@ reconcile.
| Setting | Options |
|---------|---------|
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
| Providers | Yoco · PayFast — each with an **Enabled** toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast |
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
**Fees are not handled automatically.** Payments are recorded at the full
@@ -298,10 +303,7 @@ total. Fees are dealt with by people during **cashup**, as today. There is no
`getTransaction`, `refund` (if the provider supports it via API),
`listTransactions` (for reconciliation).
- Yoco is moved behind the same interface first (no behaviour change), then
PayFast, SnapScan and Zapper are added one release each.
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
deep-link into the app; the page polls/waits for the webhook and moves on
automatically when payment lands.
PayFast is added.
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) so the existing reconciliation page
works for all providers.
@@ -724,14 +726,14 @@ keep adding to.
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 – 1.39.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.40.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -754,7 +756,8 @@ feature.
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
+60
View File
@@ -0,0 +1,60 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
// Pays into a real PayFast sandbox account, so it needs one:
// E2E_PAYFAST_MERCHANT_ID, E2E_PAYFAST_MERCHANT_KEY, E2E_PAYFAST_PASSPHRASE
// (from the Sandbox dashboard). Skipped when they aren't set, e.g. in CI.
const PF = {
merchantId: process.env.E2E_PAYFAST_MERCHANT_ID || "",
merchantKey: process.env.E2E_PAYFAST_MERCHANT_KEY || "",
passphrase: process.env.E2E_PAYFAST_PASSPHRASE || "",
};
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
type Registration = { id: string };
type Checkout = { checkoutId: string; redirectUrl: string; provider: string | null };
test.describe("PayFast", () => {
test.skip(!PF.merchantId, "Set E2E_PAYFAST_* to test against a PayFast sandbox account");
test("choose PayFast on the pay page and land on PayFast's payment page", async ({ page }) => {
await api(page, "admin", "PUT", "/api/payfast/config", { ...PF, mode: "sandbox" });
try {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: true }] });
// A booking for the paid conference, then an online checkout for it
const event = await api<EventWithOptions>(page, "staff", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<Registration>(page, "staff", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
const origin = new URL(page.url() === "about:blank" ? "http://localhost:3100" : page.url()).origin;
const checkout = await api<Checkout>(page, "staff", "POST", "/api/payments/checkout", {
registrationId: reg.id, successUrl: `${origin}/payment/success`, cancelUrl: `${origin}/payment/cancel`, failureUrl: `${origin}/payment/failure`,
});
// Yoco and PayFast are both on, so the payer chooses
expect(checkout.provider).toBeNull();
expect(checkout.redirectUrl).toContain(`/pay/${checkout.checkoutId}`);
await loginAs(page, "staff");
await page.goto(new URL(checkout.redirectUrl).pathname);
await expect(page.getByText("R150.00")).toBeVisible();
await expect(page.getByRole("button", { name: "Pay with Yoco" })).toBeVisible();
await page.getByRole("button", { name: "Pay with PayFast" }).click();
// The browser posts the signed form to the sandbox, which shows its payment page
await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 });
await expect(page.locator("body")).not.toContainText(/signature/i);
await expect(page.locator("body")).toContainText(/150[.,]00/);
// Not paid at PayFast: the status check asks PayFast's API (signed) and says so
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await page.getByText("Reconcile", { exact: true }).click();
const row = page.getByRole("row").filter({ hasText: "PayFast" }).filter({ hasText: "R 150.00" }).first();
await row.getByRole("button", { name: "Check status" }).click();
await expect(row.getByText("Not paid (yet)")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No new payments found.")).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" });
}
});
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
test.describe("payment providers", () => {
test("admin: Payments tab turns a provider on and off", async ({ page }) => {
await loginAs(page, "admin");
// The tab's older name still opens it.
await page.goto("/dashboard/admin/settings?tab=yoco");
await expect(page.getByRole("heading", { name: "Payments", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeVisible();
const providers = page.getByRole("list", { name: "Payment providers" });
const yoco = providers.getByRole("checkbox", { name: "Enable Yoco" });
await expect(yoco).toBeChecked();
try {
await yoco.uncheck();
await expect(page.getByText("Payment providers saved")).toBeVisible();
await expect(page.getByText(/Online payments are off/)).toBeVisible();
// A switched-off provider's settings are hidden
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeHidden();
const off = await api<{ providers: { id: string }[]; minimumAmount: number }>(page, "user", "GET", "/api/payments/providers");
expect(off.providers).toEqual([]);
expect(off.minimumAmount).toBe(15);
await yoco.check();
await expect(yoco).toBeChecked();
await expect(page.getByRole("heading", { name: "Yoco payments" })).toBeVisible();
const settings = await api<{ providers: ProviderSetting[] }>(page, "admin", "GET", "/api/payments/providers/settings");
expect(settings.providers.find((p) => p.id === "yoco")?.enabled).toBe(true);
} finally {
// Leave Yoco on for the other tests.
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] });
}
});
test("with no provider on, nothing offers to pay online", async ({ page }) => {
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
const event = await api<EventWithOptions>(page, "admin", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<{ id: string }>(page, "admin", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: false }, { id: "payfast", enabled: false }] });
try {
await loginAs(page, "admin");
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByText(/Online payment isn.t available/)).toBeVisible();
await expect(page.getByRole("button", { name: "Pay online" })).toHaveCount(0);
await page.goto("/dashboard/user");
await expect(page.getByRole("button", { name: "Make donation" })).toHaveCount(0);
await page.goto("/dashboard/user/donate");
await expect(page.getByText(/Online donations aren.t available/)).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
}
// Back on (when Yoco is set up in this environment), the button returns
const on = await api<{ providers: unknown[] }>(page, "admin", "GET", "/api/payments/providers");
if (on.providers.length > 0) {
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByRole("button", { name: "Pay online" })).toBeVisible();
}
});
});
+69 -1
View File
@@ -1,6 +1,7 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
test.describe("pick sessions", () => {
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
@@ -23,4 +24,71 @@ test.describe("pick sessions", () => {
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
});
test("an all-sessions pass: tick the days, pay one price", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.conference}`);
await expect(page.getByText("R200.00", { exact: true })).toBeVisible();
await expect(page.getByText("per session")).toHaveCount(0);
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(0).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R200.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const line = regs.find((r) => r.eventId === seed().events.conference)?.registrationOptions[0];
expect(line?.sessionId ?? null).toBeNull();
expect(line?.sessionIds).toHaveLength(2);
expect(line?.priceSnapshot).toBe(200);
expect(line?.quantity).toBe(1);
});
test("join the waitlist for a full class", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/register/${seed().events.dropIn}`);
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/Drop-in — Class 1/)).toBeVisible();
await page.locator("form").filter({ hasText: "Join the waitlist" }).getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/You're on the waitlist/)).toBeVisible();
const mine = await api<WaitlistEntry[]>(page, "admin", "GET", "/api/waitlist/mine");
const entry = mine.find((e) => e.eventId === seed().events.dropIn);
expect(entry?.sessionId).toBeTruthy();
expect(entry?.quantity).toBe(1);
});
test("at the door: book a class for a walk-in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
// The page picks a default event once its list loads; choose ours after that.
await page.waitForLoadState("networkidle");
await page.locator("select").first().selectOption(value!);
await page.waitForLoadState("networkidle");
await expect(page.locator("select").first()).toHaveValue(value!);
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
await page.getByText('New attendee "Door Walk In"…').click();
await page.getByPlaceholder("Email address").fill("door-walkin@e2e.test");
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await expect(page.getByText("R 50.00 per session")).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(1).check();
await expect(page.getByText("Total: R 50.00")).toBeVisible();
await page.getByRole("button", { name: "Confirm Registration" }).click();
await expect(page.getByText("Select Items")).toBeHidden();
const regs = await api<Registration[]>(page, "supervisor", "GET", `/api/registrations/event/${seed().events.dropIn}`);
const reg = regs.find((r) => r.user?.name === "Door Walk In");
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(1);
});
});
+17 -2
View File
@@ -110,12 +110,27 @@ async function seed(url) {
data: {
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
scheduleType: 'sessions', attendanceMode: 'pick', waitlistEnabled: true,
sessions: { create: classes },
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
// A conference sold per pass: people tick the days they'll come, one price for the pass.
const days = [2, 3, 4].map((d, i) => {
const startsAt = at(d, 7, 0);
return { label: `Day ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 8 * 60 * 60 * 1000), order: i };
});
const conference = await prisma.event.create({
data: {
title: 'E2E Conference', description: 'Three days; come to the days you can.', startDate: days[0].startsAt, endDate: days[2].endsAt,
price: 200, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
sessions: { create: days },
eventOptions: { create: [{ name: 'Conference pass', price: 200, isMainTicket: true, sessionMode: 'all' }] },
},
});
// Someone else already has the only place in class 1.
await prisma.registration.create({
data: {
@@ -138,7 +153,7 @@ async function seed(url) {
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
};
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.37.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.37.3",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.37.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+17 -9
View File
@@ -1,14 +1,18 @@
/*
* Service worker for offline ticket scanning.
*
* Only the scanner page and the static files it needs are cached:
* - /_next/static/* (hashed, never change): cache-first.
* - The scanner page itself: network-first, falling back to the cached copy offline.
* Only the scanner page and the static files it needs are cached, both network-first
* with the cached copy as the offline fallback:
* - /_next/static/*. Not cache-first: under `next dev` these files keep their names when
* their content changes, so cache-first served stale code on every page, for good.
* (Production files are hashed and come from the browser's HTTP cache anyway.)
* - The scanner page itself.
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
* API calls are never cached — offline scans go through IndexedDB instead.
*/
const CACHE = 'hope-scanner-v1';
// v2: drops v1 caches, which could hold stale development scripts (see above).
const CACHE = 'hope-scanner-v2';
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
self.addEventListener('install', () => self.skipWaiting());
@@ -41,11 +45,15 @@ self.addEventListener('fetch', (event) => {
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const hit = await cache.match(req);
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
return;
}
@@ -11,13 +11,13 @@ import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { YocoTab } from "@/components/admin/YocoTab";
import { PaymentsTab } from "@/components/admin/PaymentsTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -26,7 +26,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "payments", label: "Payments", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
];
@@ -97,7 +97,9 @@ function SiteSettingsPageInner() {
const searchParams = useSearchParams();
const { reload: reloadSettings } = useSiteSettings();
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
// ?tab=yoco is the Payments tab's older name.
const tabParam = searchParams.get("tab") === "yoco" ? "payments" : searchParams.get("tab");
const initialTab = TABS.some(t => t.id === tabParam) ? (tabParam as TabId) : "organisation";
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
const [loadingInitial, setLoadingInitial] = useState(true);
@@ -703,7 +705,7 @@ function SiteSettingsPageInner() {
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
{activeTab === "payments" && <PaymentsTab active={activeTab === "payments"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
@@ -16,7 +15,8 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
import { isPickOption, todaysSession } from "@/lib/sessions";
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -98,6 +98,10 @@ export default function AtTheDoorPage() {
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
// "Pick sessions" events: the event (for its sessions) and the sessions chosen per
// quantity key. Picked sessions are booked on new registrations only.
const [doorEvent, setDoorEvent] = useState<Event | null>(null);
const [picks, setPicks] = useState<Record<string, string[]>>({});
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
@@ -160,16 +164,16 @@ export default function AtTheDoorPage() {
authToken: token
});
// Per-session tickets ("pick sessions") are booked from Manual registration,
// which has the session picker.
const all = ev.options || ev.eventOptions || [];
setEventOptions(all.filter(o => !isPickOption(ev, o)));
setDoorHasPickOptions(all.some(o => isPickOption(ev, o)));
setDoorEvent(ev);
setEventOptions(all);
setDoorHasPickOptions(choosesSessions(ev));
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
setDoorBundles(ev.bundles || []);
} catch {
setDoorEvent(null);
setEventOptions([]);
setDoorBundles([]);
}
@@ -213,6 +217,10 @@ export default function AtTheDoorPage() {
}, [activeRegistration]);
// Charged per session / has sessions to tick (every ticket on a "pick sessions" event).
const isPick = (optionId: string) => isPickOption(doorEvent, eventOptions.find(o => o.id === optionId));
const choosing = choosesSessions(doorEvent);
const confirmRegistration = async () => {
if (confirming) return;
@@ -220,13 +228,18 @@ export default function AtTheDoorPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choosing && !pendingEditReg ? { sessionIds: picks[key] || [] } : {}) };
});
if (!opts.length) {
setError("Select at least one item");
return;
}
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
if (noSessions) {
setError(`Choose the sessions for "${eventOptions.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`);
return;
}
for (const [key, minQty] of Object.entries(minQuantities)) {
if (minQty > 0 && (quantities[key] || 0) < minQty) {
@@ -244,6 +257,8 @@ export default function AtTheDoorPage() {
if (missing) { setError(missing); return; }
}
// Picked-session lines take no names.
const nameOptions = eventOptions.filter(o => !isPick(o.id));
setConfirming(true);
try {
if (pendingEditReg) {
@@ -286,8 +301,8 @@ export default function AtTheDoorPage() {
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
: {}),
}
});
@@ -311,16 +326,20 @@ export default function AtTheDoorPage() {
// Called when NewAttendeeModal is confirmed — proceed to options selection
const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => {
const qtyMap = initQuantities(eventOptions);
// Default main ticket to 1 (first variant if variants exist)
const pickMap: Record<string, string[]> = {};
// Default main ticket to 1 (first variant if variants exist). On a "pick sessions"
// event with a session today that still has room, that's one place in today's session.
const today = todaysSession(doorEvent);
const todayPick = today && doorEvent && choosing ? eventOptions.find(o => o.isMainTicket
&& bookableSessions(doorEvent, o, true).some(s => s.id === today.id && (s.left === null || s.left > 0))) : undefined;
eventOptions.forEach(o => {
if (!o.isMainTicket) return;
if ((o.variants || []).length > 0) {
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
} else {
qtyMap[o.id] = 1;
}
if (!o.isMainTicket || (todayPick ? o !== todayPick : choosing)) return;
const key = (o.variants || []).length > 0 ? `${o.id}::${(o.variants || [])[0].id}` : o.id;
qtyMap[key] = 1;
if (o === todayPick && today) pickMap[key] = [today.id];
});
setQuantities(qtyMap);
setPicks(pickMap);
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
@@ -345,8 +364,8 @@ export default function AtTheDoorPage() {
return;
}
const qtyMap = initQuantities(eventOptions);
const regOptions = freshReg.registrationOptions || [];
const qtyMap = initQuantities(eventOptions.filter(o => !isPick(o.id)));
const regOptions = (freshReg.registrationOptions || []).filter(ro => !ro.sessionId);
regOptions.forEach((ro) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
@@ -361,6 +380,7 @@ export default function AtTheDoorPage() {
});
setQuantities(qtyMap);
setPicks({});
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
@@ -499,15 +519,11 @@ export default function AtTheDoorPage() {
{mode === "refund" && (
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
)}
{doorHasPickOptions && (
<div className="mt-4 p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
This event has per-session tickets. Book those from <Link className="underline font-medium" href="/dashboard/supervisor/manual">Manual registration</Link>, which lets you choose the sessions.
</div>
)}
<OptionsModal
open={showOptionsModal}
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
options={eventOptions}
options={pendingEditReg ? eventOptions.filter(o => !isPick(o.id)) : eventOptions}
pick={doorEvent && doorHasPickOptions ? { event: doorEvent, picks, setPicks } : null}
quantities={quantities}
setQuantities={setQuantities}
minQuantities={minQuantities}
@@ -1194,15 +1210,30 @@ type OptionsModalProps = {
bundles: PublicBundle[];
// Named-attendee editor settings, or null when the event doesn't take names.
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
// A "pick sessions" event: its sessions and the sessions chosen per quantity key.
pick: { event: Event; picks: Record<string, string[]>; setPicks: React.Dispatch<React.SetStateAction<Record<string, string[]>>> } | null;
};
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names, pick }: OptionsModalProps) {
const pickOpt = (optionId: string) => !!pick && isPickOption(pick.event, (options || []).find(o => o.id === optionId));
// Sessions are ticked on new bookings only (edits leave booked sessions as they are).
const choiceOpt = (optionId: string) => !!pick && !isEdit && (options || []).some(o => o.id === optionId);
// A picked option is priced per session.
const times = (key: string) => (pick && pickOpt(key.split("::")[0]) ? (pick.picks[key] || []).length : 1);
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
return { eventOptionId, quantity, variantId: variantId || null, ...(pick && choiceOpt(eventOptionId) ? { sessionIds: pick.picks[key] || [] } : {}) };
});
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const picker = (key: string, option: EventOption) => {
if (!pick || !choiceOpt(option.id)) return null;
const qty = quantities[key] || 0;
return <SessionPicker event={pick.event} option={option} staff quantity={Math.max(1, qty)} selected={qty > 0 ? pick.picks[key] || [] : []}
onChange={ids => { pick.setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const perSession = (o: EventOption) => (pickOpt(o.id) ? " per session" : "");
// Bundle deals apply to the whole registration, so this holds for edits too.
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
@@ -1213,9 +1244,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const total = safeOptions.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
}, 0);
const afterBundles = bundleQuote ? bundleQuote.total : total;
@@ -1236,6 +1267,11 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="text-xl font-semibold">
{isEdit ? "Edit Registration" : "Select Items"}
</div>
{isEdit && pick && (
<div className="text-sm text-gray-500 mt-1">
Sessions already booked stay as they are. To add sessions, book them as a new registration.
</div>
)}
{isEdit && totalPaid > 0 && (
<div className="text-sm text-gray-500 mt-1">
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> — new total must be at least this amount.
@@ -1267,11 +1303,12 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const key = `${opt.id}::${v.id}`;
const minQty = minQuantities[key] || 0;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
<div key={v.id} className="px-4 py-2.5 border-b last:border-b-0">
<div className="flex items-center justify-between">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
@@ -1282,6 +1319,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
{picker(key, opt)}
</div>
);
})}
</div>
@@ -1293,7 +1332,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div key={opt.id} className="border rounded-xl p-3">
<div className="font-medium">{opt.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
@@ -1303,6 +1342,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
{picker(opt.id, opt)}
</div>
);
})}
@@ -1323,7 +1363,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{names && (
<div className="mb-3 max-h-64 overflow-y-auto">
<AttendeeNamesEditor
options={safeOptions}
options={safeOptions.filter(o => !pickOpt(o.id))}
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
{...names}
payerLabel="the person registering"
@@ -587,7 +587,7 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> — the event title.</li>
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
<li><code>{'{{payment.link}}'}</code> — a direct online payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
@@ -12,7 +12,7 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { isPickOption } from "@/lib/sessions";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -289,11 +289,19 @@ export default function ManualRegistrationPage() {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
};
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
const choiceOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && choosesSessions(fullEvent) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !fullEvent || !(quantities[key] > 0)) return null;
return <SessionPicker event={fullEvent} option={o} staff quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
const o = choiceOpt(optionId);
if (!o || !fullEvent) return null;
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const qty = quantities[key] || 0;
return <SessionPicker event={fullEvent} option={o} staff quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const totalDue = useMemo(() => {
@@ -307,7 +315,7 @@ export default function ManualRegistrationPage() {
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null, ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, variantId: variantId || null, ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
@@ -322,7 +330,7 @@ export default function ManualRegistrationPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -35,16 +37,20 @@ function refundMethodForOriginal(method: string | null | undefined): string {
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
type YocoTx = {
// An unreconciled payment provider transaction (GET /api/provider-transactions/unreconciled).
// `metadata` is the checkout metadata: who/what the payment was for.
type ProviderTx = {
id: string;
provider: string;
providerLabel: string;
amount: number;
checkoutId?: string | null;
externalId?: string | null;
methodType?: string | null;
createdDate?: string | null;
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
metadata?: { userId?: string; eventId?: string } | null;
// Why it wasn't recorded automatically (e.g. paid a different amount from the checkout)
note?: string | null;
};
function RegistrationOptions({ regs, regOutstanding }: {
@@ -141,6 +147,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
function PaymentsContent() {
const { user, loading, token } = useAuth();
const online = useOnlinePayments();
const router = useRouter();
const search = useSearchParams();
@@ -278,7 +285,7 @@ function PaymentsContent() {
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
setLinkGenerating(true);
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/yoco-checkout", {
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
method: "POST",
authToken: token,
body: {
@@ -319,7 +326,7 @@ function PaymentsContent() {
setError(null); setInfo(null);
try {
setLinkSending(channel);
await apiFetch("/api/payments/yoco-checkout/send", {
await apiFetch("/api/payments/checkout/send", {
method: "POST",
authToken: token,
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
@@ -421,34 +428,34 @@ function PaymentsContent() {
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
// Yoco unreconciled list and actions
const [yocoLoading, setYocoLoading] = useState(false);
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
const loadYocoUnreconciled = useCallback(async () => {
// Unreconciled online payments (from payment providers) and actions
const [txLoading, setTxLoading] = useState(false);
const [providerTxs, setProviderTxs] = useState<ProviderTx[]>([]);
const loadUnreconciled = useCallback(async () => {
if (!token) return;
try {
setYocoLoading(true);
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
setTxLoading(true);
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setYocoTxs(items);
setProviderTxs(items);
} catch {
// ignore silently, shown on demand via error state if needed
} finally {
setYocoLoading(false);
setTxLoading(false);
}
}, [token]);
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
useEffect(() => { loadUnreconciled(); }, [loadUnreconciled]);
// Inline reconcile form state per-row
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
const openReconcileToRegistration = (tx: YocoTx) => {
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
const openReconcileToRegistration = (tx: ProviderTx) => {
const metaUser = tx.metadata?.userId ? String(tx.metadata.userId) : '';
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
};
const openReconcileAsDonation = (tx: YocoTx) => {
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
const openReconcileAsDonation = (tx: ProviderTx) => {
const metaEvent = tx.metadata?.eventId ? String(tx.metadata.eventId) : '';
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
};
@@ -467,7 +474,7 @@ function PaymentsContent() {
setReconcileForm(prev => ({ ...prev, submitting: true }));
if (type === 'reg') {
if (!registrationId) { setError('Please select a registration'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { registrationId }
@@ -475,7 +482,7 @@ function PaymentsContent() {
setInfo('Reconciled to registration');
} else {
if (!eventId) { setError('Please select an event'); return; }
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { eventId, userId: userId || undefined }
@@ -483,7 +490,7 @@ function PaymentsContent() {
setInfo('Reconciled as donation');
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
await Promise.all([loadUnreconciled(), loadPayments(), loadRegistrations()]);
} catch (e) {
setError(errorMessage(e) || 'Failed to reconcile');
} finally {
@@ -537,31 +544,39 @@ function PaymentsContent() {
<ScanLine className="w-4 h-4" />
Reconcile
</label>
{/* Payment links are paid online, so only while a payment provider is on */}
{online.available && (
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" />
Payment Link
</label>
)}
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Yoco reconciliation panel */}
{mode === 'reconcile' && (
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
)}
{/* Online payment reconciliation panel */}
{mode === 'reconcile' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">Unreconciled Yoco Payments</div>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadYocoUnreconciled} disabled={yocoLoading}>{yocoLoading ? 'Refreshing…' : 'Refresh'}</button>
<div className="text-lg font-semibold">Unreconciled Online Payments</div>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadUnreconciled} disabled={txLoading}>{txLoading ? 'Refreshing…' : 'Refresh'}</button>
</div>
{yocoTxs.length === 0 ? (
<div className="text-sm text-gray-500">No unreconciled Yoco transactions.</div>
{providerTxs.length === 0 ? (
<div className="text-sm text-gray-500">No unreconciled online payments.</div>
) : (
<div className="overflow-x-auto w-full">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Created</th>
<th className="py-2 pr-3">Provider</th>
<th className="py-2 pr-3">External ID</th>
<th className="py-2 pr-3">Amount</th>
<th className="py-2 pr-3">Checkout</th>
@@ -570,10 +585,14 @@ function PaymentsContent() {
</tr>
</thead>
<tbody>
{yocoTxs.map(tx => (
{providerTxs.map(tx => (
<React.Fragment key={tx.id}>
<tr className="border-b hover:bg-gray-50">
<td className="py-2 pr-3 whitespace-nowrap">{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'}</td>
<td className="py-2 pr-3">
{tx.providerLabel}
{tx.note && <div className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded px-1.5 py-0.5 mt-1 max-w-[16rem]">{tx.note}</div>}
</td>
<td className="py-2 pr-3 font-mono text-xs break-all max-w-[10rem]">{tx.externalId}</td>
<td className="py-2 pr-3">R {(Number(tx.amount || 0)/100).toFixed(2)}</td>
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
@@ -586,9 +605,9 @@ function PaymentsContent() {
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
if (!ok) return;
try {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
setInfo('Transaction ignored');
await loadYocoUnreconciled();
await loadUnreconciled();
} catch (e) {
setError(errorMessage(e) || 'Failed to ignore');
}
@@ -597,7 +616,7 @@ function PaymentsContent() {
</tr>
{reconcileForm.txId === tx.id && (
<tr className="bg-gray-50">
<td colSpan={6} className="p-3">
<td colSpan={7} className="p-3">
{reconcileForm.type === 'reg' ? (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div>
@@ -749,7 +768,7 @@ function PaymentsContent() {
</>
)}
{mode === 'link' && (
{mode === 'link' && online.available && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment link</div>
<div className="grid sm:grid-cols-2 gap-3">
@@ -9,6 +9,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -22,6 +23,7 @@ type Entry = {
createdAt: string;
eventOption: { name: string };
variant: { name: string } | null;
session: { label: string | null; startsAt: string } | null;
user: { name: string; email: string; phoneNumber: string | null };
};
@@ -153,7 +155,7 @@ export default function WaitlistPage() {
<div className="text-sm font-medium">{e.user.name}</div>
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
</TableCell>
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
<TableCell className="text-sm">{entryLineName(e)}</TableCell>
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
<TableCell>
@@ -699,7 +699,7 @@ function WhatsAppAttendeesPageInner() {
<li><code>{"{{event.title}}"}</code> — the event title.</li>
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
<li><code>{"{{payment.link}}"}</code> — a direct online payment link for the attendee&apos;s outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
export default function DonatePage() {
const { token } = useAuth();
const online = useOnlinePayments();
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
@@ -39,7 +41,7 @@ export default function DonatePage() {
setError(null);
setInfo(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/checkout", {
method: "POST",
body: {
eventId,
@@ -72,6 +74,11 @@ export default function DonatePage() {
</div>
</div>
{online.ready && !online.available ? (
<div className="border rounded-xl p-5 bg-white shadow-sm text-sm text-gray-700">
Online donations aren&apos;t available at the moment. You can give at an event, or contact the organiser.
</div>
) : (
<div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
@@ -99,6 +106,7 @@ export default function DonatePage() {
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
)}
</div>
);
}
+15 -7
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { printTicketCards } from "@/lib/ticketPrint";
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
export default function UserDashboardPage() {
const { token, user } = useAuth();
const online = useOnlinePayments();
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null);
@@ -313,13 +315,13 @@ export default function UserDashboardPage() {
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// Creates an online checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we redirect you to Yoco." });
setDialog({ open: true, message: "Creating payment link…", loading: true, loadingTitle: "Creating payment link…", loadingSubtitle: "Please wait while we take you to the secure payment page." });
try {
const res = await createFullPaymentCheckout(token, registrationId);
if (res.priceUpdated) {
@@ -654,10 +656,12 @@ export default function UserDashboardPage() {
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
{online.available && (
<button
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
)}
</div>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
@@ -1194,7 +1198,7 @@ export default function UserDashboardPage() {
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
{canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)}
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
}}>Print all tickets</button>
</div>
{canModifyActive && activeBill && activeBill.outstanding > 0 && online.reason(activeBill.outstanding) && (
<p className="text-xs text-gray-500">{online.reason(activeBill.outstanding)}</p>
)}
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
<div className="pt-3 border-t mt-1">
+4 -4
View File
@@ -96,7 +96,7 @@ export default function PrivacyPolicyPage() {
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>Online payments go through Yoco or PayFast; we never see your card details.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
@@ -135,7 +135,7 @@ function Section({
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
@@ -198,7 +198,7 @@ function Section({
],
sharing: [
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco (a South African payment processor), to process payments securely.",
"• Yoco and PayFast (South African payment processors), to process payments securely — your name and email may be passed to PayFast to fill in its payment page.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
@@ -236,7 +236,7 @@ function Section({
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
+2 -2
View File
@@ -91,7 +91,7 @@ export default function TermsOfUsePage() {
<li>Provide accurate information during registration and payments.</li>
<li>Keep your login details safe.</li>
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Understand that online payments go through our payment providers (Yoco or PayFast), and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
@@ -140,7 +140,7 @@ function Section({
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
"Online payments are processed securely by third-party payment processors (Yoco or PayFast).",
`${orgName} does not store or process credit card information.`,
"Payment confirmations and receipts are issued electronically.",
"All prices are listed in South African Rand (ZAR) unless stated otherwise.",
+121
View File
@@ -0,0 +1,121 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { CreditCard } from "lucide-react";
type ProviderForm = { action: string; fields: Record<string, string> };
type PayPage = {
id: string;
status: "open" | "paid";
amount: number;
description: string;
provider: string | null;
providers: { id: string; label: string }[];
cancelUrl: string | null;
redirectUrl: string | null;
form: ProviderForm | null;
};
/**
* The payer's page for an online checkout (/pay/<id>): choose how to pay when several payment
* providers are enabled, or — for a provider that takes a signed form (PayFast) — post that
* form. Payment links in emails, WhatsApp and invoice QR codes can point here.
*/
export default function PayPage() {
const params = useParams<{ id: string }>();
const id = params?.id;
const [page, setPage] = useState<PayPage | null>(null);
const [form, setForm] = useState<ProviderForm | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const go = useCallback((res: { redirectUrl: string | null; form: ProviderForm | null }) => {
if (res.form) setForm(res.form);
else if (res.redirectUrl) window.location.href = res.redirectUrl;
}, []);
useEffect(() => {
if (!id) return;
apiFetch<PayPage>(`/api/payments/pay/${encodeURIComponent(id)}`)
.then(p => {
setPage(p);
if (p.status === "open") go(p);
})
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this payment."));
}, [id, go]);
// Once a provider's form is known, the browser posts it to the provider.
useEffect(() => { if (form) formRef.current?.submit(); }, [form]);
const choose = async (provider: string) => {
if (!id) return;
setBusy(provider);
setError(null);
try {
go(await apiFetch<{ redirectUrl: string | null; form: ProviderForm | null }>(`/api/payments/pay/${encodeURIComponent(id)}`, { method: "POST", body: { provider } }));
} catch (e) {
setError(e instanceof ApiError ? e.message : "Couldn't start the payment. Please try again.");
setBusy(null);
}
};
const choosing = page?.status === "open" && !page.provider && !form;
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<CreditCard className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Pay online</h1>
</div>
{error && <div className="border rounded-xl p-4 mb-4 bg-red-50 text-red-800 text-sm">{error}</div>}
{!page && !error && <div className="text-sm text-gray-600">Loading…</div>}
{page && (
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<div>
{page.description && <div className="font-semibold text-gray-900">{page.description}</div>}
<div className="text-2xl font-semibold mt-1">R{page.amount.toFixed(2)}</div>
</div>
{page.status === "paid" ? (
<p className="text-sm text-green-800 bg-green-50 border border-green-200 rounded-lg p-3">This has been paid. Thank you!</p>
) : choosing ? (
page.providers.length === 0 ? (
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-200 rounded-lg p-3">Online payments aren&apos;t available right now. Please pay at the door or contact the organiser.</p>
) : (
<div className="space-y-2" role="group" aria-label="How would you like to pay?">
<p className="text-sm text-gray-600">How would you like to pay?</p>
{page.providers.map(p => (
<button key={p.id} type="button" disabled={!!busy} onClick={() => choose(p.id)}
className="w-full px-4 py-3 rounded-lg border border-brand-300 bg-white hover:bg-brand-50 text-left font-medium disabled:opacity-50">
{busy === p.id ? `Taking you to ${p.label}…` : `Pay with ${p.label}`}
</button>
))}
</div>
)
) : (
<p className="text-sm text-gray-600">Taking you to the payment page…</p>
)}
{form && (
<form ref={formRef} method="POST" action={form.action}>
{Object.entries(form.fields).map(([name, value]) => <input key={name} type="hidden" name={name} value={value} />)}
<noscript><button type="submit" className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium">Continue to payment</button></noscript>
<button type="submit" className="text-sm text-brand-700 underline">Not moving? Continue to payment</button>
</form>
)}
{page.status === "open" && page.cancelUrl && (
<a href={page.cancelUrl} className="block text-sm text-gray-500 hover:underline">Cancel</a>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
@@ -3,10 +3,11 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
import { choosesSessions, isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
@@ -80,7 +81,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
};
}
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
type WaitlistOffer = { eventOptionId: string; variantId: string | null; sessionId?: string | null; lineName?: string; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
type QtyMap = Record<string, number>;
@@ -161,8 +162,9 @@ function QtyControl({
);
}
// A "pick sessions" option: the sessions chosen per quantity key, and the event's schedule.
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void };
// A "pick sessions" event: the sessions chosen per quantity key, and the event's schedule.
// perSession: this ticket is charged per session (otherwise one price for the sessions ticked).
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void; perSession: boolean };
function OptionCard({
opt,
@@ -178,9 +180,12 @@ function OptionCard({
pick?: PickProps | null;
}) {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const perSession = pick ? " per session" : "";
const picker = (key: string, qty: number, unit: number) => (pick && qty > 0 ? (
<SessionPicker event={pick.event} option={opt} selected={pick.picks[key] || []} onChange={(ids) => pick.onPick(key, ids)} quantity={qty} unitPrice={unit} />
const perSession = pick?.perSession ? " per session" : "";
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const picker = (key: string, qty: number, unit: number, variant?: { id: string; name: string }) => (pick ? (
<SessionPicker event={pick.event} option={opt} selected={qty > 0 ? pick.picks[key] || [] : []} quantity={Math.max(1, qty)} unitPrice={pick.perSession ? unit : undefined}
onChange={(ids) => { pick.onPick(key, ids); if (qty === 0 && ids.length > 0) onQtyChange(key, 1); }}
waitlist={waitlist ? { ...waitlist, variantId: variant?.id ?? null, label: variant ? `${opt.name} (${variant.name})` : opt.name } : null} />
) : null);
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
@@ -246,7 +251,7 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, unitPrice)}
{picker(key, qty, unitPrice, variant)}
</div>
);
})}
@@ -371,6 +376,7 @@ function DonationPicker({
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth();
const online = useOnlinePayments();
const eventId = baseEvent.id;
// A waitlist claim link (?waitlist=<token>): the places held for this person are
@@ -397,6 +403,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
...priced,
eventOptions: (priced.eventOptions || []).map(o => {
if (o.id !== offer.eventOptionId) return o;
if (offer.sessionId) {
const left = o.sessionAvailability?.[offer.sessionId];
return { ...o, ...(left !== undefined ? { sessionAvailability: { ...o.sessionAvailability, [offer.sessionId]: left + offer.quantity } } : {}) };
}
return {
...o,
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
@@ -452,11 +462,17 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
setQuantities((q) => ({ ...q, [key]: val }));
};
// "Pick sessions" options: the sessions chosen for each quantity key. Whole-course
// tickets on such an event stop selling when the course starts.
// "Pick sessions" events: the sessions chosen for each quantity key. Every ticket
// shows the sessions; per-session tickets are charged once per session ticked.
const [picks, setPicks] = useState<Record<string, string[]>>({});
const wholeCourseClosed = event.attendanceMode === "pick" && !!event.wholeCourseClosesAt && Date.now() >= new Date(event.wholeCourseClosesAt).getTime();
const choosing = choosesSessions(event);
const pickFor = (opt: EventOption) => isPickOption(event, opt);
// A waitlist offer for one session: that session is ticked for them.
useEffect(() => {
if (!offer?.sessionId) return;
const key = offer.variantId ? `${offer.eventOptionId}:${offer.variantId}` : offer.eventOptionId;
setPicks(p => ({ ...p, [key]: [offer.sessionId as string] }));
}, [offer]);
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
const total = useMemo(() => {
@@ -488,12 +504,12 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
// a code, so its total wins.
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
// Optional donation on top of the tickets — paid in the same Yoco checkout and recorded
// Optional donation on top of the tickets — paid in the same online checkout and recorded
// as a separate donation payment. Only offered when there's something to pay online.
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
const [customDonation, setCustomDonation] = useState("");
const presets = useMemo(() => donationPresets(total), [total]);
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
const donation = !showDonation ? 0
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
@@ -505,7 +521,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
if (qty <= 0) return;
const [eventOptionId, variantId] = key.includes(":") ? key.split(":") : [key, undefined];
const opt = (event.eventOptions || []).find(o => o.id === eventOptionId);
const sessionIds = opt && pickFor(opt) ? picks[key] || [] : undefined;
const sessionIds = opt && choosing ? picks[key] || [] : undefined;
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
});
return items;
@@ -636,7 +652,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{offer && (
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.lineName || `${offer.optionName}${offer.variantName ? ` (${offer.variantName})` : ""}`} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
</div>
)}
{offerError && (
@@ -691,18 +707,15 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
{wholeCourseClosed && (
<p className="mb-3 text-sm text-gray-600 bg-gray-50 border rounded-lg px-3 py-2">The course has started, so whole-course tickets are no longer on sale — choose the sessions you&apos;d like to come to.</p>
)}
<div className="space-y-3">
{(event.eventOptions || []).filter(opt => !(wholeCourseClosed && !pickFor(opt))).map((opt) => (
{(event.eventOptions || []).map((opt) => (
<OptionCard
key={opt.id}
opt={opt}
quantities={quantities}
onQtyChange={handleQtyChange}
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
pick={pickFor(opt) ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })) } : null}
pick={choosing ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })), perSession: pickFor(opt) } : null}
/>
))}
</div>
+15 -7
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
@@ -21,7 +22,7 @@ function RegistrationSuccessContent() {
return isNaN(n) ? 0 : n;
}, [fallbackTotalParam]);
// Optional donation picked on the register form; added to the Yoco checkout as its own line.
// Optional donation picked on the register form; added to the online checkout as its own line.
const donationParam = search.get("donation");
const donation = React.useMemo(() => {
const n = donationParam ? Number(donationParam) : 0;
@@ -67,7 +68,7 @@ function RegistrationSuccessContent() {
}, [reg]);
React.useEffect(() => {
// If free registration, do not show Yoco and redirect to dashboard after a short delay
// If free registration, don't offer online payment and redirect to dashboard after a short delay
if (reg && totalDue === 0) {
// attempt ticket generation if status is paid (may fail if required forms aren't completed)
(async () => {
@@ -108,6 +109,11 @@ function RegistrationSuccessContent() {
}
};
const online = useOnlinePayments();
const owed = totalDue > 0 || (!reg && fallbackTotalDue > 0);
const payAmount = (reg ? totalDue : fallbackTotalDue) + donation;
const payOnline = owed && online.canPay(payAmount);
const goDashboard = () => {
router.push("/dashboard/user");
};
@@ -129,29 +135,31 @@ function RegistrationSuccessContent() {
<div className="font-medium">This event requires attendee details.</div>
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
) : payOnline ? (
<>
{donation > 0 && (
<p className="text-gray-700 mb-2">
Thank you for adding a donation of <strong>R{donation.toFixed(2)}</strong>. It&apos;s included when you pay now.
</p>
)}
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
<p className="text-gray-700 mb-6">Would you like to pay online now?</p>
</>
) : owed && online.reason(payAmount) ? (
<p className="text-gray-700 mb-6">{online.reason(payAmount)}</p>
) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4">
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
{payOnline && (
<button
onClick={goPay}
disabled={!registrationId || payLoading}
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
>{payLoading ? "Creating checkout..." : "Pay with Yoco"}</button>
>{payLoading ? "Creating checkout..." : "Pay online"}</button>
)}
<button
onClick={goDashboard}
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
>{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
>{payOnline ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
</div>
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
{!registrationId && (
+13 -5
View File
@@ -9,7 +9,7 @@ import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/co
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Event, PublicBundle, Registration } from "@/types";
import { isPickOption } from "@/lib/sessions";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -230,11 +230,19 @@ export default function SelfServicePage() {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && isPickOption(kioskFull, o) ? o : null;
};
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
const choiceOpt = (optionId: string) => {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && choosesSessions(kioskFull) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !kioskFull || !(quantities[key] > 0)) return null;
return <SessionPicker event={kioskFull} option={o} quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
const o = choiceOpt(optionId);
if (!o || !kioskFull) return null;
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const qty = quantities[key] || 0;
return <SessionPicker event={kioskFull} option={o} quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const total = useMemo(() => {
@@ -456,7 +464,7 @@ export default function SelfServicePage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}),
user,
// If the visitor declined to create an account, don't push an unsolicited
@@ -14,6 +14,7 @@ type Claim = {
eventStart: string;
optionName: string;
variantName: string | null;
lineName?: string;
quantity: number;
requested: number;
expiresAt: string;
@@ -53,7 +54,7 @@ export default function WaitlistClaimPage() {
{claim.status === "offered" ? (
<>
<p className="text-sm text-gray-700">
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.lineName || `${claim.optionName}${claim.variantName ? ` (${claim.variantName})` : ""}`} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
</p>
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
@@ -0,0 +1,165 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
interface PayFastConfig {
merchantId: string;
merchantKey: string;
passphraseMasked: string;
hasPassphrase: boolean;
mode: "sandbox" | "live";
notifyUrl: string;
notifyUrlProblem: string | null;
configured: boolean;
}
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const primaryBtn = "px-3 py-1.5 text-sm rounded-lg bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white font-medium";
/**
* PayFast settings (Site settings → Payments, while PayFast is enabled): merchant ID and key,
* the passphrase that signs payments (stored encrypted), and sandbox or live.
* onChanged: called after a save, so the Payments tab can refresh whether PayFast is set up.
*/
export function PayFastSettings({ active, onChanged }: { active: boolean; onChanged?: () => void }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<PayFastConfig | null>(null);
const [merchantId, setMerchantId] = useState("");
const [merchantKey, setMerchantKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const [mode, setMode] = useState<"sandbox" | "live">("sandbox");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const c = await apiFetch<PayFastConfig>("/api/payfast/config", { authToken: token });
setCfg(c);
setMerchantId(c.merchantId);
setMerchantKey(c.merchantKey);
setMode(c.mode);
setPassphrase("");
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load PayFast settings" });
}
}, [token, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setSaving(true);
try {
await apiFetch("/api/payfast/config", {
method: "PUT", authToken: token,
// An empty passphrase box keeps the saved one.
body: { merchantId, merchantKey, mode, ...(passphrase ? { passphrase } : {}) },
});
setMessage({ type: "ok", text: "PayFast settings saved." });
await load();
onChanged?.();
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "Could not save" });
} finally {
setSaving(false);
}
};
const [testing, setTesting] = useState(false);
// Checks the saved merchant ID and passphrase with PayFast (a signed API ping).
const test = async () => {
if (!token) return;
setTesting(true);
try {
const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token });
setMessage({ type: "ok", text: res.message });
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" });
} finally {
setTesting(false);
}
};
const removePassphrase = async () => {
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
try {
await apiFetch("/api/payfast/config", { method: "PUT", authToken: token, body: { passphrase: "" } });
setMessage({ type: "ok", text: "Passphrase removed." });
await load();
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "Could not remove it" });
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">PayFast payments</h2>
<p className="text-xs text-gray-500">Card, instant EFT and more through PayFast. Find these on your PayFast (or Sandbox) dashboard under Settings.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>{message.text}</div>
)}
{!cfg ? (
<p className="text-sm text-gray-400">Loading…</p>
) : (
<form onSubmit={save} className="border rounded-lg p-4 space-y-3">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-merchant-id">Merchant ID</label>
<input id="pf-merchant-id" className={inputCls} value={merchantId} onChange={e => setMerchantId(e.target.value)} inputMode="numeric" placeholder="From your PayFast dashboard" />
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-merchant-key">Merchant key</label>
<input id="pf-merchant-key" className={inputCls} value={merchantKey} onChange={e => setMerchantKey(e.target.value)} placeholder="From your PayFast dashboard" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1" htmlFor="pf-passphrase">Salt passphrase</label>
<input id="pf-passphrase" type="password" autoComplete="off" className={inputCls} value={passphrase} onChange={e => setPassphrase(e.target.value)}
placeholder={cfg.hasPassphrase ? `Saved (${cfg.passphraseMasked}) — type to replace` : "The passphrase set on your PayFast dashboard"} />
<p className="text-xs text-gray-500 mt-1">
Must match the one on your PayFast dashboard exactly, or payments are refused. Stored encrypted.
{cfg.hasPassphrase && <> <button type="button" className="underline" onClick={removePassphrase}>Remove</button></>}
</p>
</div>
<div>
<span className="block text-xs font-medium text-gray-700 mb-1">Mode</span>
<div className="flex gap-4 text-sm" role="radiogroup" aria-label="PayFast mode">
<label className="flex items-center gap-1.5"><input type="radio" name="pf-mode" checked={mode === "sandbox"} onChange={() => setMode("sandbox")} /> Sandbox (testing)</label>
<label className="flex items-center gap-1.5"><input type="radio" name="pf-mode" checked={mode === "live"} onChange={() => setMode("live")} /> Live</label>
</div>
{mode === "live" && <p className="text-xs text-amber-700 mt-1">Live takes real money. Use your live merchant ID, key and passphrase — the sandbox ones won&apos;t work.</p>}
</div>
<div className="text-xs text-gray-600">
Payment notifications go to <span className="font-mono break-all">{cfg.notifyUrl || "(not set)"}</span>.
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
</div>
<div className="flex flex-wrap gap-2">
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button>
{cfg.configured && (
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50" disabled={testing} onClick={test}>
{testing ? "Testing…" : "Test connection"}
</button>
)}
</div>
<p className="text-xs text-gray-500">Test connection checks the saved settings — save any changes first.</p>
</form>
)}
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Wallet } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { YocoTab } from "@/components/admin/YocoTab";
import { PayFastSettings } from "@/components/admin/PayFastSettings";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
/**
* Site settings → Payments: which online payment providers people can use, in what order
* (the first one is the default), and below that the settings of each enabled provider.
*/
export function PaymentsTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [providers, setProviders] = useState<ProviderSetting[] | null>(null);
const [minimum, setMinimum] = useState(15);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ providers: ProviderSetting[]; minimumAmount: number }>("/api/payments/providers/settings", { authToken: token });
setProviders(res.providers);
setMinimum(res.minimumAmount);
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load payment providers" });
}
}, [token, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const save = async (next: ProviderSetting[]) => {
if (!token) return;
setProviders(next);
setSaving(true);
try {
const res = await apiFetch<{ message: string; providers: ProviderSetting[] }>("/api/payments/providers/settings", {
method: "PUT", authToken: token, body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
});
setProviders(res.providers);
setMessage({ type: "ok", text: res.message });
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Could not save" });
load();
} finally {
setSaving(false);
}
};
const move = (i: number, by: number) => {
if (!providers) return;
const next = [...providers];
const [p] = next.splice(i, 1);
next.splice(i + by, 0, p);
save(next);
};
const toggle = (i: number) => providers && save(providers.map((p, j) => (j === i ? { ...p, enabled: !p.enabled } : p)));
const usable = (providers || []).filter(p => p.enabled && p.configured);
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Payments</h2>
<p className="text-xs text-gray-500">Which online payment providers people can use. The first one in the list is used by default.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{message.text}
</div>
)}
{!providers ? (
<p className="text-sm text-gray-400">Loading…</p>
) : (
<div className="border rounded-lg divide-y" role="list" aria-label="Payment providers">
{providers.map((p, i) => (
<div key={p.id} role="listitem" className="flex items-center gap-3 px-3 py-2.5">
<label className="flex items-center gap-2 flex-1 min-w-0">
<input type="checkbox" checked={p.enabled} disabled={saving} onChange={() => toggle(i)} aria-label={`Enable ${p.label}`} />
<span className="text-sm font-medium text-gray-900">{p.label}</span>
{i === 0 && p.enabled && <span className="text-[11px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">Default</span>}
{p.enabled && !p.configured && <span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800">Not set up</span>}
</label>
<div className="flex items-center gap-1">
<button type="button" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30" disabled={saving || i === 0} onClick={() => move(i, -1)} aria-label={`Move ${p.label} up`}><ArrowUp className="w-4 h-4" /></button>
<button type="button" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30" disabled={saving || i === providers.length - 1} onClick={() => move(i, 1)} aria-label={`Move ${p.label} down`}><ArrowDown className="w-4 h-4" /></button>
</div>
</div>
))}
</div>
)}
{providers && usable.length === 0 && (
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg p-2.5">
Online payments are off: no provider is both enabled and set up. People can still pay at the door.
</p>
)}
<p className="text-xs text-gray-500">
Online payments must be at least R{minimum}. Payments are recorded at the full amount paid; provider fees aren&apos;t deducted or added to the payer&apos;s total — deal with them at cashup.
</p>
</div>
{/* Each provider's own settings, only while it's enabled */}
{providers?.some(p => p.id === "yoco" && p.enabled) && (
<div className="border-t pt-5">
<YocoTab active={active} onChanged={load} />
</div>
)}
{providers?.some(p => p.id === "payfast" && p.enabled) && (
<div className="border-t pt-5">
<PayFastSettings active={active} onChanged={load} />
</div>
)}
</div>
);
}
+3 -1
View File
@@ -73,7 +73,8 @@ function Card({ icon: Icon, title, subtitle, children }: {
);
}
export function YocoTab({ active }: { active: boolean }) {
// onChanged: called after a save, so the Payments tab can refresh whether Yoco is set up.
export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: () => void }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<YocoConfig | null>(null);
const [hooks, setHooks] = useState<YocoWebhook[]>([]);
@@ -131,6 +132,7 @@ export function YocoTab({ active }: { active: boolean }) {
const text = await fn();
setMessage({ type: "ok", text });
await load();
onChanged?.();
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
} finally {
@@ -46,6 +46,7 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
eventId,
eventOptionId: line.eventOptionId,
...(line.variantId ? { variantId: line.variantId } : {}),
...(line.sessionId ? { sessionId: line.sessionId } : {}),
quantity: qty,
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
},
@@ -124,5 +125,5 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
}
function keyOf(l?: WaitlistLine) {
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
return l ? `${l.eventOptionId}::${l.variantId || ""}::${l.sessionId || ""}` : "";
}
@@ -3,10 +3,12 @@
import React from "react";
import type { Event } from "@/types";
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
// Tick boxes for the sessions of a "pick sessions" ticket. Full sessions (or ones with
// fewer places than `quantity`) can't be ticked.
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice }: {
// fewer places than `quantity`) can't be ticked; with `waitlist`, they get a "Join
// waitlist" button for that session instead.
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice, waitlist }: {
event: Pick<Event, "sessions">;
option: { id: string; sessionAvailability?: Record<string, number> };
selected: string[];
@@ -14,6 +16,7 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
quantity?: number;
staff?: boolean;
unitPrice?: number;
waitlist?: { eventId: string; guestsAllowed: boolean; variantId?: string | null; label: string } | null;
}) {
const sessions = bookableSessions(event, option, staff);
if (sessions.length === 0) return <p className="text-xs text-gray-500 mt-2">No sessions left to book.</p>;
@@ -29,13 +32,20 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
</div>
{sessions.map(s => {
const full = s.left !== null && s.left < Math.max(1, quantity);
const name = sessionLabel(s, event.sessions);
return (
<label key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{sessionLabel(s, event.sessions)}</span>
<span className="text-gray-500">{formatSessionWhen(s)}</span>
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
</label>
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<label className="flex items-center gap-2 flex-1 min-w-0">
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{name}</span>
<span className="text-gray-500">{formatSessionWhen(s)}</span>
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
</label>
{full && waitlist && !selected.includes(s.id) && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
lines={[{ eventOptionId: option.id, variantId: waitlist.variantId, sessionId: s.id, label: `${waitlist.label} — ${name}, ${formatSessionWhen(s)}` }]} />
)}
</div>
);
})}
</div>
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { useOnlinePayments } from "@/lib/onlinePayments";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -14,7 +15,7 @@ const rand = (n: number) => `R${n.toLocaleString("en-ZA", { minimumFractionDigit
/**
* "Sponsor a spot": progress towards the event's sponsorship goal, and a form to fund
* one or more spots (or any amount) for people who can't afford to come. Payment is
* through Yoco; the money lands in the event's sponsorship pool.
* online; the money lands in the event's sponsorship pool.
*/
export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTitle: string }) {
const { token, user } = useAuth();
@@ -30,6 +31,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [returned, setReturned] = useState<string | null>(null);
const online = useOnlinePayments();
useEffect(() => {
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
@@ -39,7 +41,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
} catch { /* no window */ }
}, [eventId]);
if (!data?.enabled) return null;
// Sponsoring is paid online, so it's offered only when online payment is possible.
if (!data?.enabled || (!online.available && !returned)) return null;
const price = data.spotPrice || null;
const loggedIn = !!(token && user);
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
@@ -0,0 +1,137 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type OpenCheckout = {
id: string;
provider: string;
providerLabel: string;
canCheck: boolean;
amount: number;
description: string;
type: string | null;
registrationId: string | null;
payer: string | null;
createdAt: string;
};
type CheckResult = { status: "paid" | "recorded" | "needs_review" | "not_paid" | "cant_check"; note?: string };
const RESULT_TEXT: Record<CheckResult["status"], string> = {
paid: "Already paid",
recorded: "Payment found and recorded",
needs_review: "Paid a different amount — see Unreconciled below",
not_paid: "Not paid (yet)",
cant_check: "Can't check — look in the provider's portal",
};
/**
* Online checkouts from the last 14 days with no payment yet (Payments → Reconcile). For when a
* provider's notification never arrived: "Check status" asks the provider and records any
* payment it received. A background job does the same every 15 minutes.
*/
export function WaitingForPayment({ token, onRecorded }: { token: string | null; onRecorded: () => void }) {
const [list, setList] = useState<OpenCheckout[] | null>(null);
const [results, setResults] = useState<Record<string, CheckResult>>({});
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ checkouts: OpenCheckout[] }>("/api/payments/checkouts/open", { authToken: token });
setList(res.checkouts);
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load waiting payments" });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const check = async (ids?: string[]) => {
if (!token) return;
setBusy(ids?.[0] || "all");
setMessage(null);
try {
const res = await apiFetch<{ results: Record<string, CheckResult>; recorded: number; message: string }>("/api/payments/checkouts/check", {
method: "POST", authToken: token, body: ids ? { ids } : {},
});
setResults(r => ({ ...r, ...res.results }));
setMessage({ ok: true, text: res.message });
if (res.recorded > 0) onRecorded();
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't check" });
} finally {
setBusy(null);
}
};
const checkable = (list || []).some(c => c.canCheck);
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="text-lg font-semibold">Waiting for payment</div>
<div className="flex gap-2">
{checkable && (
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50" disabled={!!busy} onClick={() => check()}>
{busy === "all" ? "Checking…" : "Check all"}
</button>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => { setResults({}); load(); }}>Refresh</button>
</div>
</div>
<p className="text-xs text-gray-500 mb-3">
Online payments started in the last 14 days that haven&apos;t been confirmed. If the provider&apos;s notification didn&apos;t arrive, check the status here — PayFast payments are also checked automatically every 15 minutes.
</p>
{message && <div className={`text-sm mb-3 p-2 rounded ${message.ok ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
{!list ? (
<div className="text-sm text-gray-500">Loading…</div>
) : list.length === 0 ? (
<div className="text-sm text-gray-500">Nothing waiting.</div>
) : (
<div className="overflow-x-auto w-full">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Started</th>
<th className="py-2 pr-3">Payer</th>
<th className="py-2 pr-3">For</th>
<th className="py-2 pr-3">Amount</th>
<th className="py-2 pr-3">Provider</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{list.map(c => {
const result = results[c.id];
return (
<tr key={c.id} className="border-b align-top">
<td className="py-2 pr-3 whitespace-nowrap">{new Date(c.createdAt).toLocaleString()}</td>
<td className="py-2 pr-3">{c.payer || "—"}</td>
<td className="py-2 pr-3">{c.description || (c.type === "donation" ? "Donation" : "—")}</td>
<td className="py-2 pr-3 whitespace-nowrap">R {c.amount.toFixed(2)}</td>
<td className="py-2 pr-3">{c.providerLabel}</td>
<td className="py-2">
{result ? (
<span className={result.status === "recorded" || result.status === "paid" ? "text-green-700" : result.status === "needs_review" ? "text-amber-800" : "text-gray-600"}>
{RESULT_TEXT[result.status]}
</span>
) : c.canCheck ? (
<button className="px-2 py-1 rounded border border-brand-300 text-brand-700 hover:bg-brand-50 disabled:opacity-50" disabled={!!busy} onClick={() => check([c.id])}>
{busy === c.id ? "Checking…" : "Check status"}
</button>
) : (
<span className="text-xs text-gray-500">Can&apos;t check automatically — look in the {c.providerLabel} portal, then use Reconcile</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { ListChecks } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
type Entry = {
id: string;
@@ -14,6 +15,7 @@ type Entry = {
event: { id: string; title: string; startDate: string };
eventOption: { name: string };
variant: { name: string } | null;
session: { label: string | null; startsAt: string } | null;
};
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
@@ -46,7 +48,7 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
<div>
<div className="font-medium">{e.event.title}</div>
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
<div className="text-xs text-gray-500">{e.quantity} × {entryLineName(e)}</div>
{e.status === "offered" ? (
<div className="text-xs text-green-700 mt-0.5">
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
@@ -17,7 +17,7 @@ export const dashboardUserDonateHelpContent: HelpContent = {
icon: HandHeart,
content: (
<div className="space-y-4">
<p>Pick an event to support and an amount (minimum R15), then you&apos;ll be redirected to a secure Yoco checkout to complete payment.</p>
<p>Pick an event to support and an amount (minimum R15), then you&apos;ll be taken to a secure online checkout to complete payment.</p>
<GuideItem icon={HandHeart} title="Not tied to a registration" tone="rose">
A donation is separate from paying for your own ticket — it&apos;s a direct contribution, and it isn&apos;t required to register for or attend an event.
</GuideItem>
+1 -1
View File
@@ -26,7 +26,7 @@ export const eventDetailHelpContent: HelpContent = {
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked. Some events have a waitlist: tap &quot;Join waitlist&quot; and you&apos;ll be emailed or WhatsApped a link to claim a place if one opens up (it&apos;s held for you for a limited time).
</GuideItem>
<GuideItem icon={HeartHandshake} title="Sponsor a spot" tone="rose">
Some events let you pay for a place for someone who can&apos;t afford it. Tap &quot;Sponsor a spot&quot;, choose one or more spots (or any amount), and pay securely with Yoco. You can give anonymously or add a name and a message for the organisers. You&apos;ll get a receipt, and who is helped is never shared.
Some events let you pay for a place for someone who can&apos;t afford it. Tap &quot;Sponsor a spot&quot;, choose one or more spots (or any amount), and pay securely online. You can give anonymously or add a name and a message for the organisers. You&apos;ll get a receipt, and who is helped is never shared.
</GuideItem>
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
Some events let you name each person you&apos;re booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you&apos;ll give each person&apos;s date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
+26 -2
View File
@@ -1,12 +1,12 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for Site Settings (/dashboard/admin/settings). */
export const siteSettingsHelpContent: HelpContent = {
title: "Site Settings help",
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
subtitle: "Configure your organisation, branding, email, WhatsApp, payments, and legal pages.",
quickLinks: [
{ label: "Back to Admin dashboard", href: "/dashboard/admin", icon: LayoutDashboard },
],
@@ -96,6 +96,30 @@ export const siteSettingsHelpContent: HelpContent = {
</div>
),
},
{
key: "payments",
label: "Payments",
icon: CreditCard,
content: (
<div className="space-y-4">
<p>Online payments: which providers people can pay with, and each provider&apos;s settings.</p>
<div className="space-y-4">
<GuideItem icon={ArrowUpDown} title="Providers and order" tone="brand">
Tick a provider to let people pay with it, and use the arrows to set the order. The first provider that&apos;s ticked and set up is the default. If none is, online payments are off and people pay at the door.
</GuideItem>
<GuideItem icon={KeyRound} title="Yoco" tone="green">
Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full.
</GuideItem>
<GuideItem icon={KeyRound} title="PayFast" tone="green">
Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly — save, then use Test connection to check. When more than one provider is ticked, payers choose how to pay.
</GuideItem>
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren&apos;t deducted or added to the payer&apos;s total — deal with them at cashup.
</GuideItem>
</div>
</div>
),
},
{
key: "jobs",
label: "Background jobs",
@@ -43,6 +43,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
<p>On events where people pick sessions, tick the sessions under a per-session ticket. For a new attendee, a place in today&apos;s session is selected for you when there&apos;s room. Sessions already booked on a registration can&apos;t be changed when editing it — book extra sessions as a new registration.</p>
</div>
),
},
@@ -38,7 +38,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Choose &quot;Multiple sessions&quot; for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label (&quot;Week 1 — …&quot;), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
</GuideItem>
<GuideItem icon={CalendarDays} title="Pick sessions" tone="violet">
For day passes or a drop-in class, set &quot;How people book&quot; to &quot;Pick sessions&quot;, give sessions an optional capacity, and in Items &amp; Pricing set a ticket to &quot;Pick sessions (price per session)&quot;. People tick the sessions they want; each one is its own ticket. Whole-course tickets can still be sold too, until the course starts.
For day passes or a drop-in class, set &quot;How people book&quot; to &quot;Pick sessions&quot;, give sessions an optional capacity, and in Items &amp; Pricing set a ticket to &quot;Pick sessions (price per session)&quot;. People tick the sessions they want; each one is its own ticket. A ticket left on &quot;All sessions&quot; also shows the sessions to tick, but it&apos;s one ticket at one price — it then only scans on the sessions ticked.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
@@ -25,10 +25,10 @@ export const supervisorPaymentsHelpContent: HelpContent = {
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from &quot;Sponsor a spot&quot; is marked as a sponsorship. Tick &quot;Only the sponsorship pool&quot; to assign just those, and you&apos;ll see the sponsor&apos;s message. The Donations breakdown report shows each event&apos;s pool: received, assigned and remaining.
</GuideItem>
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
Match unreconciled Yoco card transactions to a registration, log them as a donation, or ignore them.
Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider&apos;s dashboard first. &quot;Waiting for payment&quot; lists online checkouts with no payment yet: if someone paid but it hasn&apos;t shown up, press Check status (PayFast) — it&apos;s also checked automatically every 15 minutes. Yoco can&apos;t be checked automatically: find the payment in the Yoco portal.
</GuideItem>
<GuideItem icon={Link2} title="Payment Link" tone="violet">
Generate a Yoco checkout link for a user to pay online — copy it or send by email/WhatsApp. Generating a new link invalidates the previous one.
Generate an online payment link for a user — copy it or send by email/WhatsApp. It uses the default payment provider (Site settings → Payments). Online payments must be at least R15. Generating a new link invalidates the previous one.
</GuideItem>
</div>
</div>
@@ -19,13 +19,13 @@ export const supervisorWaitlistHelpContent: HelpContent = {
In the event wizard&apos;s Visibility step, tick &quot;Waitlist for sold-out tickets&quot;, set how long offered places are held (24 hours by default), and whether someone can be offered fewer places than they asked for.
</GuideItem>
<GuideItem icon={ListChecks} title="Joining" tone="green">
When a ticket is sold out, the event and registration pages show &quot;Join waitlist&quot;. People pick the ticket and how many, and can leave again from My Events.
When a ticket is sold out, the event and registration pages show &quot;Join waitlist&quot;. People pick the ticket and how many, and can leave again from My Events. On events where people pick sessions, the waitlist is per session: a full session shows &quot;Join waitlist&quot; in the session list, and the Ticket column shows which session someone is waiting for.
</GuideItem>
<GuideItem icon={Send} title="Offers" tone="violet">
When places free up — a cancellation, a smaller booking, or a higher stock limit — the next person in line is emailed or WhatsApped a claim link. The places are held for them, so nobody else can buy them in the meantime.
</GuideItem>
<GuideItem icon={Clock} title="Expiry" tone="amber">
If the offer isn&apos;t claimed in time, it expires and the places go to the next person. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
If the offer isn&apos;t claimed in time, it expires and the places go to the next person. An offer for a session never runs past the session&apos;s start. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
</GuideItem>
</div>
</div>
+2 -2
View File
@@ -198,13 +198,13 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
return fetchAllPages<Payment>("/api/payments", token, params);
}
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
method: "POST",
authToken: token,
body: {
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
export type OnlinePayments = {
/** The answer has arrived (until then, nothing offers online payment). */
ready: boolean;
/** At least one payment provider is switched on and set up. */
available: boolean;
/** Online payments must be at least this much (R15). */
minimum: number;
/** Whether `amount` can be paid online now. */
canPay: (amount: number) => boolean;
/** Why `amount` can't be paid online, for showing instead of the button; null when it can. */
reason: (amount: number) => string | null;
};
// One request per page load, shared by every component that asks.
let pending: Promise<ProvidersResponse | null> | null = null;
function load() {
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
return pending;
}
/**
* Whether people can pay online: only when a payment provider is enabled (Site settings →
* Payments) and the amount is at least the online minimum. "Pay online" buttons use this to
* stay hidden otherwise.
*/
export function useOnlinePayments(): OnlinePayments {
const [state, setState] = useState<{ ready: boolean; available: boolean; minimum: number }>({ ready: false, available: false, minimum: 15 });
useEffect(() => {
let cancelled = false;
load().then(res => {
if (!cancelled) setState({ ready: true, available: !!res && res.providers.length > 0, minimum: res?.minimumAmount ?? 15 });
});
return () => { cancelled = true; };
}, []);
const canPay = (amount: number) => state.ready && state.available && amount >= state.minimum;
const reason = (amount: number) => {
if (!state.ready || canPay(amount)) return null;
if (!state.available) return "Online payment isn't available at the moment. You can pay at the event, or contact the organiser.";
return `Online payments must be at least R${state.minimum}. You can pay this at the event.`;
};
return { ...state, canPay, reason };
}
+8
View File
@@ -174,6 +174,14 @@ export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> |
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
/**
* Whether people tick sessions when booking this event ("pick sessions" attendance).
* Every ticket shows the sessions; only per-session tickets are charged per session.
*/
export function choosesSessions(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick";
}
/** Whether an option is bought per session (only in "pick sessions" events). */
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
+10 -1
View File
@@ -1,7 +1,16 @@
// Pure helpers shared by server and client components. Must not be "use client",
// or server pages calling these get a client reference instead of the function.
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
// sessionId: one session of a "pick sessions" option (those are waited for per session).
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; label: string };
/** "Drop-in (Adult)" or "Drop-in — Class 2, Wed 4 Nov" for a waitlist entry. */
export function entryLineName(e: { eventOption: { name: string }; variant?: { name: string } | null; session?: { label?: string | null; startsAt: string } | null }): string {
const base = `${e.eventOption.name}${e.variant ? ` (${e.variant.name})` : ""}`;
if (!e.session) return base;
const when = new Date(e.session.startsAt).toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
return `${base} — ${e.session.label ? `${e.session.label}, ` : ""}${when}`;
}
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {

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