From 286cf09dd897d8b4a7f0c7f36fec3ed749a0dd08 Mon Sep 17 00:00:00 2001 From: joshua Date: Thu, 22 Oct 2026 08:27:47 +0200 Subject: [PATCH 1/2] Add optional donation to registration; fix soldOutLines server crash The register form now offers a "Donation amount" (suggested amounts, custom, or none) under the ticket options. The donation is added to the Yoco checkout as its own line and the webhook records it as a separate donation payment linked to the registration payment. Move soldOutLines() out of the "use client" JoinWaitlistButton module into lib/waitlist.ts so the server-rendered event page can call it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ backend/src/controllers/paymentController.js | 29 ++++- backend/src/controllers/webhookController.js | 36 +++++- frontend/src/app/events/[id]/page.tsx | 3 +- frontend/src/app/forms/page.tsx | 10 +- .../app/register/[eventId]/RegisterForm.tsx | 108 +++++++++++++++++- .../src/app/registration/success/page.tsx | 18 ++- .../components/events/JoinWaitlistButton.tsx | 21 +--- frontend/src/lib/api.ts | 3 +- frontend/src/lib/waitlist.ts | 22 ++++ 10 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/waitlist.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e738f8..d5ce09e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance. + +### Fixed + +- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it. + ## [1.29.1] - 2026-10-21 ### Changed diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index e0070c6..7399014 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -935,7 +935,9 @@ function checkoutLineItems(registration) { // Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount } // Does NOT check user authorization — callers are responsible for ensuring the user owns the registration. -async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) { +// 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: { @@ -961,7 +963,8 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ if (remainingAmount <= 0) throw new Error('This registration is already paid in full'); - const amountInCents = Math.round(remainingAmount * 100); + 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); @@ -974,11 +977,17 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ }); } // 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' }, + metadata: { + registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment', + ...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {}) + }, successUrl, cancelUrl, failureUrl, @@ -999,7 +1008,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ return { checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, - amount: remainingAmount, + amount: amountInCents / 100, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } }; } @@ -1009,7 +1018,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ // @access Private const createYocoCheckout = async (req, res) => { try { - const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body; + const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl } = req.body; const userId = req.user.id; // Validate required parameters for either registration payment or donation @@ -1096,7 +1105,15 @@ const createYocoCheckout = async (req, res) => { 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 createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl }); + // Optional donation added at registration ("Donation amount" on the register form) + let donation = 0; + if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') { + donation = Math.round(parseFloat(donationAmount) * 100) / 100; + if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); } + if (donation > 0 && donation < 15) { res.status(400); throw new Error('Minimum donation is R15'); } + } + + const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation }); return res.status(200).json(result); } diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index 9703569..4140492 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -314,11 +314,18 @@ const handlePaymentSucceeded = async (webhookData) => { } 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: amount / 100, // Convert cents to your currency unit + amount: Math.round((grossAmount - donationPart) * 100) / 100, method: method.type, // The type of payment from the webhook status: status === 'succeeded' ? 'completed' : status, externalId: yocoPaymentId, @@ -358,6 +365,24 @@ const handlePaymentSucceeded = async (webhookData) => { } }); + 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 = []; @@ -392,6 +417,7 @@ const handlePaymentSucceeded = async (webhookData) => { // 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; @@ -402,6 +428,14 @@ const handlePaymentSucceeded = async (webhookData) => { } 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: () => {} }; diff --git a/frontend/src/app/events/[id]/page.tsx b/frontend/src/app/events/[id]/page.tsx index 5132076..0f33a36 100644 --- a/frontend/src/app/events/[id]/page.tsx +++ b/frontend/src/app/events/[id]/page.tsx @@ -5,7 +5,8 @@ import { Footer } from "@/components/layout/Footer"; import ClientActions from "@/app/events/[id]/ClientActions"; import { ContactButton } from "@/components/events/ContactButton"; import { LocationMap } from "@/components/events/LocationMap"; -import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton"; +import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton"; +import { soldOutLines } from "@/lib/waitlist"; import { SponsorSpot } from "@/components/events/SponsorSpot"; import { FunnelTracker } from "@/components/events/FunnelTracker"; import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react"; diff --git a/frontend/src/app/forms/page.tsx b/frontend/src/app/forms/page.tsx index 646469b..412ef1d 100644 --- a/frontend/src/app/forms/page.tsx +++ b/frontend/src/app/forms/page.tsx @@ -18,6 +18,8 @@ function FormsContent() { const router = useRouter(); const { token } = useAuth(); const registrationId = search.get("registrationId"); + // Donation chosen on the register form — passed through so the success page can add it to the payment. + const donationParam = search.get("donation") ? `&donation=${encodeURIComponent(search.get("donation")!)}` : ""; // Set when a named attendee opens the link sent to them — show only their form. const onlyAttendeeId = search.get("attendeeId"); @@ -119,7 +121,7 @@ function FormsContent() { if (payload.length === 0) { // Nothing left to submit – still go to success page to continue the flow const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0); - router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`); + router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`); return; } await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, { @@ -129,7 +131,7 @@ function FormsContent() { }); // On success, redirect to the success page const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0); - router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`); + router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`); } catch (e) { setError(errorMessage(e) || 'Failed to submit forms'); } @@ -149,7 +151,7 @@ function FormsContent() { try { // Redirect to the success page const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0); - router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`); + router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`); } catch (e) { setError(errorMessage(e) || 'Failded to go to next step'); } @@ -163,7 +165,7 @@ function FormsContent() { try { // Redirect to the success page const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0); - router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`); + router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`); } catch (e) { setError(errorMessage(e) || 'Failded to skip'); } diff --git a/frontend/src/app/register/[eventId]/RegisterForm.tsx b/frontend/src/app/register/[eventId]/RegisterForm.tsx index f4a209d..4474c6f 100644 --- a/frontend/src/app/register/[eventId]/RegisterForm.tsx +++ b/frontend/src/app/register/[eventId]/RegisterForm.tsx @@ -257,6 +257,77 @@ function OptionCard({ ); } +// Online donations have the same R15 minimum as the donate page and the backend. +const MIN_DONATION = 15; + +// Suggested donations: 10/15/20/25% of the ticket total rounded up to the next R10 (at least R20), +// topped up with fixed amounts when the percentages collapse on a small total. +function donationPresets(total: number): number[] { + const out: number[] = []; + for (const pct of [0.10, 0.15, 0.20, 0.25]) { + const v = Math.max(20, Math.ceil((total * pct) / 10) * 10); + if (!out.includes(v)) out.push(v); + } + for (const v of [50, 100, 200]) { + if (out.length >= 3) break; + if (!out.includes(v) && v > out[out.length - 1]) out.push(v); + } + return out; +} + +type DonationChoice = { kind: "none" } | { kind: "preset"; index: number } | { kind: "custom" }; + +function DonationPicker({ + presets, + choice, + onChoiceChange, + customAmount, + onCustomAmountChange, +}: { + presets: number[]; + choice: DonationChoice; + onChoiceChange: (c: DonationChoice) => void; + customAmount: string; + onCustomAmountChange: (v: string) => void; +}) { + const btn = (active: boolean) => + `px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${active ? "bg-brand-600 border-brand-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`; + return ( +
+
Donation amount
+

Would you like to add a donation to support this event? It's added to your payment.

+
+ {presets.map((amt, i) => ( + + ))} + + +
+ {choice.kind === "custom" && ( +
+ onCustomAmountChange(e.target.value)} + className="w-full sm:w-60 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +
+ )} +
+ ); +} + export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) { const { token, user } = useAuth(); const eventId = baseEvent.id; @@ -367,6 +438,17 @@ 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 + // as a separate donation payment. Only offered when there's something to pay online. + const [donationChoice, setDonationChoice] = useState({ kind: "none" }); + const [customDonation, setCustomDonation] = useState(""); + const presets = useMemo(() => donationPresets(total), [total]); + const showDonation = isLoggedIn && payable > 0 && !eventSoldOut; + const donation = !showDonation ? 0 + : donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0) + : donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100) + : 0; + function buildItems() { const items: { eventOptionId: string; quantity: number; variantId?: string }[] = []; Object.entries(quantities).forEach(([key, qty]) => { @@ -391,6 +473,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate); if (missing) { setError(missing); return; } } + if (showDonation && donationChoice.kind === "custom" && donation > 0 && donation < MIN_DONATION) { + setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`); + return; + } + const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : ""; // Claim link without logging in: the link identifies the person. if (waitlistToken && !isLoggedIn) { @@ -435,8 +522,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0); const due = res.totalDueComputed ?? payable; const next = hasForm - ? `/forms?registrationId=${encodeURIComponent(res.id)}` - : `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`; + ? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}` + : `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`; if (res.couponError) { // The code ran out between checking and registering — the registration is // saved at full price, so say so before moving on. @@ -573,6 +660,18 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven /> )} + {showDonation && ( +
+ +
+ )} + {(() => { // Collect all upcoming early-bird tiers across all options const now = new Date(); @@ -623,7 +722,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven {coupon && coupon.discount > 0 && (
Code {coupon.code} −R{coupon.discount.toFixed(2)}
)} -
Total: R{payable.toFixed(2)}
+ {donation > 0 && ( +
Donation +R{donation.toFixed(2)}
+ )} +
Total: R{(payable + donation).toFixed(2)}
{eventSoldOut ? (