Add optional donation to registration; fix soldOutLines server crash

The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:27:47 +02:00
co-authored by Claude Opus 5.5
parent 27c870d46d
commit 286cf09dd8
10 changed files with 221 additions and 37 deletions
+23 -6
View File
@@ -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);
}
+35 -1
View File
@@ -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: () => {} };