diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e738f8..df05d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.30.0] - 2026-10-22 + +### Added + +- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance. + +### Fixed + +- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it. + ## [1.29.1] - 2026-10-21 ### Changed diff --git a/backend/package-lock.json b/backend/package-lock.json index 9fc442b..4459179 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "event-management-backend", - "version": "1.29.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "event-management-backend", - "version": "1.29.1", + "version": "1.30.0", "hasInstallScript": true, "dependencies": { "@prisma/adapter-pg": "^7.10.1", diff --git a/backend/package.json b/backend/package.json index 8f95383..70bb7fd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "event-management-backend", - "version": "1.29.1", + "version": "1.30.0", "description": "Event Management System Backend", "main": "src/index.js", "scripts": { 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/package-lock.json b/frontend/package-lock.json index 71231ae..0c81249 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hope-events-frontend", - "version": "1.29.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hope-events-frontend", - "version": "1.29.1", + "version": "1.30.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@hookform/resolvers": "^5.2.1", diff --git a/frontend/package.json b/frontend/package.json index 1f301c6..861b2ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hope-events-frontend", - "version": "1.29.1", + "version": "1.30.0", "private": true, "scripts": { "dev": "next dev --turbopack", 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 ( +
Would you like to add a donation to support this event? It's added to your payment.
+