const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing'); const axios = require('axios'); const { emailTickets } = require('./ticketController'); const { safeErrorMessage } = require('../utils/errorUtils'); const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils'); // @desc Create a new payment // @route POST /api/payments // @access Private/Supervisor const createPayment = async (req, res) => { try { const { amount, method, registrationId, eventId, isDonation, paidAt, userId: bodyUserId } = req.body; let userId = req.user.id; // Allow admins/supervisors to create payments for other users if (bodyUserId) { if (req.user.role !== 'admin' && req.user.role !== 'supervisor') { res.status(403); throw new Error('Not authorized to create payments for other users'); } // Ensure target user exists const targetUser = await prisma.user.findUnique({ where: { id: bodyUserId } }); if (!targetUser) { res.status(404); throw new Error('Target user not found'); } userId = bodyUserId; } // Optional backdate support for supervisors/admins let paidAtDate = null; if (paidAt) { const d = new Date(paidAt); if (!isNaN(d.getTime())) { // Disallow future-dated payments; clamp to now const now = new Date(); paidAtDate = d > now ? now : d; } } // Validate payment data if (!amount || amount <= 0) { res.status(400); throw new Error('Invalid payment amount'); } if (!method) { res.status(400); throw new Error('Payment method is required'); } // Validate required parameters based on isDonation flag if (isDonation && !eventId) { res.status(400); throw new Error('Event ID is required for donations'); } if (!isDonation && !registrationId) { res.status(400); throw new Error('Registration ID is required for non-donation payments'); } let registrationEventId = null; // Full registration object reused for both the auth check and the payment calculation below let registration = null; // If it's a registration payment, fetch everything needed in one query (avoids a duplicate fetch later) if (registrationId) { registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, user: { select: { id: true } } } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } registrationEventId = registration.eventId; if (registration.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') { res.status(403); throw new Error('Not authorized to make payment for this registration'); } await assertEventOpen(registrationEventId, res); } // If it's an event donation, check if event exists if (eventId && !registrationId) { 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.'); } } // Create payment(s) with support for overpayments becoming donations let payment; let donationSplit = null; // If this is a registration payment, we need to cap the applied amount and split any excess as a donation if (!isDonation && registrationId) { // Silently refresh early-bird prices so totalDue reflects any expired or exhausted tiers. // For manual (supervisor) payments we don't block — we just ensure the totalDue is accurate. try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('Price refresh failed for manual payment:', e?.message); } // Re-fetch after refresh so priceSnapshot values are current registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, user: { select: { id: true } } } }); // Calculate remaining amount to be paid (at payment time) const totalDue = computeRegistrationTotalDue(registration, paidAtDate || new Date()); const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); const remainingAmount = Math.max(totalDue - totalPaid, 0); const requestedAmount = parseFloat(amount); const applyAmount = Math.min(requestedAmount, remainingAmount); const excess = Math.max(requestedAmount - applyAmount, 0); // If there's nothing remaining, treat the whole payment as a donation to the event if (applyAmount <= 0) { payment = await prisma.payment.create({ data: { id: uuidv4(), amount: requestedAmount, method, userId, recordedById: req.user.id, registrationId: null, eventId: registration.eventId, isDonation: true, createdAt: paidAtDate || undefined }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, event: true } }); } else { // Create the main registration payment for the capped amount payment = await prisma.payment.create({ data: { id: uuidv4(), amount: applyAmount, method, userId, recordedById: req.user.id, registrationId, eventId: registrationEventId || eventId || null, isDonation: false, createdAt: paidAtDate || undefined }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true } }, event: (registrationEventId || eventId) ? true : undefined } }); // If there is an excess, create a donation payment linked to the original payment if (excess > 0) { donationSplit = await prisma.payment.create({ data: { id: uuidv4(), amount: excess, method, userId, recordedById: req.user.id, registrationId: null, eventId: registration.eventId, isDonation: true, originalPaymentId: payment.id, createdAt: paidAtDate || undefined } }); } } } else { // Original behavior for donations or non-registration payments payment = await prisma.payment.create({ data: { id: uuidv4(), amount: parseFloat(amount), method, userId, recordedById: req.user.id, registrationId: registrationId || null, eventId: registrationEventId || eventId || null, isDonation: isDonation || false, createdAt: paidAtDate || undefined }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: registrationId ? { include: { event: true } } : undefined, event: (registrationEventId || eventId) ? true : undefined } }); } // If it's a registration payment, update registration status let updatedRegistration = null; let generatedTickets = []; if (registrationId) { const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); // Calculate total amount paid const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); // Calculate total amount due at this time (respect early-bird tiers) const totalDue = computeRegistrationTotalDue(registration, paidAtDate || 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'; } updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } }); // Generate tickets when registration is fully paid (sync — populates generatedTickets for response) if (newStatus === 'paid') { try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); } } } // Fire-and-forget: payment email first, then tickets (guarantees order) const { sendPaymentEmails } = require('../utils/notifications'); const _cpPaymentId = payment?.id; const _cpDonationId = donationSplit?.id; const _cpShouldEmailTickets = generatedTickets.length > 0; const _cpUserId = registration?.userId; const _cpRegId = registrationId; (async () => { if (_cpPaymentId) { try { await sendPaymentEmails(_cpPaymentId); } catch (e) { console.error('Failed to send payment emails:', e); } } if (_cpDonationId) { try { await sendPaymentEmails(_cpDonationId); } catch (e) { console.error('Failed to send payment emails:', e); } } if (_cpShouldEmailTickets && _cpUserId) { const mockReq = { user: { id: _cpUserId }, body: { registrationId: _cpRegId } }; const mockRes = { status: () => mockRes, json: () => {} }; try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets:', e); } } })(); // If registration was updated, include the updated registration in the response if (updatedRegistration) { // Create a new payment object with the updated registration const paymentWithUpdatedRegistration = { ...payment, registration: { ...payment.registration, ...updatedRegistration }, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined, donationSplit: donationSplit || undefined }; res.status(201).json(paymentWithUpdatedRegistration); } else { res.status(201).json({ ...payment, donationSplit: donationSplit || undefined }); } } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get all payments // @route GET /api/payments // @access Private/Admin const getPayments = async (req, res) => { try { const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100)); const skip = (page - 1) * limit; const include = { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true, user: { select: { name: true, email: true, phoneNumber: true } } } }, event: true }; const [payments, total] = await prisma.$transaction([ prisma.payment.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }), prisma.payment.count() ]); res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // Payment.method is free-text — online checkouts get tagged with whatever wallet type the // gateway reports (apple_pay, google_pay, ...), not just the manual-entry methods below. // For the user-facing dashboard: card-network wallets count as "card" (same settlement, no // separate float); anything else unrecognized falls into "other". const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher']; const CARD_ALIASES = ['apple_pay', 'google_pay']; const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES]; // Refund methods are recorded as "-refund" (e.g. "card-refund") so a refund nets // against the same bucket its original payment counted under. Strip that suffix before // bucketing so a card refund still displays/filters as "card", not "other". function stripRefundSuffix(method) { const m = String(method || '').toLowerCase(); return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m; } function normalizeUserMethod(method) { const m = stripRefundSuffix(method); if (USER_FACING_METHODS.includes(m)) return m; if (CARD_ALIASES.includes(m)) return 'card'; return 'other'; } // @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters) // @route GET /api/payments/mypayments // @access Private const getUserPayments = async (req, res) => { try { const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25)); const skip = (page - 1) * limit; // Users should see payments they made and payments tied to their registrations, // excluding donations. const where = { isDonation: false, OR: [ { userId: req.user.id }, { registration: { userId: req.user.id } } ] }; if (req.query.method) { const requested = String(req.query.method).toLowerCase(); if (requested === 'card') { // "Card" also covers card-network wallet types (apple_pay, google_pay) and card // refunds — same settlement as a card payment, no separate float to reconcile. where.AND = [{ OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } })) }]; } else if (requested === 'other') { // "Other" covers every method (and its refund variant) that isn't one of the // recognized buckets above. where.NOT = { OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } })) }; } else if (USER_FACING_METHODS.includes(requested)) { where.AND = [{ OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } })) }]; } } if (req.query.kind === 'refund') { where.amount = { lt: 0 }; } else if (req.query.kind === 'payment') { where.amount = { gte: 0 }; } if (req.query.startDate || req.query.endDate) { where.createdAt = { ...(req.query.startDate && { gte: new Date(req.query.startDate) }), ...(req.query.endDate && { lte: new Date(req.query.endDate) }) }; } const [payments, total] = await prisma.$transaction([ prisma.payment.findMany({ where, include: { registration: { include: { event: true } }, event: true }, orderBy: { createdAt: 'desc' }, skip, take: limit }), prisma.payment.count({ where }) ]); // Normalize the displayed method so the dashboard never shows a raw gateway string. const normalizedPayments = payments.map(p => ({ ...p, method: normalizeUserMethod(p.method) })); res.json({ data: normalizedPayments, total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get payment by ID // @route GET /api/payments/:id // @access Private const getPaymentById = async (req, res) => { try { const payment = await prisma.payment.findUnique({ where: { id: req.params.id }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true } }, event: true } }); if (!payment) { res.status(404); throw new Error('Payment not found'); } // Check if user is authorized to view this payment if (payment.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { res.status(403); throw new Error('Not authorized to view this payment'); } res.json(payment); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get payments by registration // @route GET /api/payments/registration/:registrationId // @access Private const getPaymentsByRegistration = async (req, res) => { try { const registration = await prisma.registration.findUnique({ where: { id: req.params.registrationId } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } // Check if user is authorized to view these payments if (registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { res.status(403); throw new Error('Not authorized to view these payments'); } const payments = await prisma.payment.findMany({ where: { registrationId: req.params.registrationId }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } } } }); res.json(payments); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Get payments by event // @route GET /api/payments/event/:eventId // @access Private/Admin const getPaymentsByEvent = async (req, res) => { try { const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000)); const payments = await prisma.payment.findMany({ where: { OR: [ { eventId: req.params.eventId }, { registration: { eventId: req.params.eventId } } ] }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { user: { select: { id: true, name: true, email: true } } } } }, orderBy: { createdAt: 'desc' }, take: limit }); res.json(payments); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Assign a donation to a registration // @route PUT /api/payments/assign-donation // @access Private/Supervisor const assignDonationToRegistration = async (req, res) => { try { const { paymentId, registrationId, amount } = req.body; // Validate required parameters if (!paymentId || !registrationId) { res.status(400); throw new Error('Payment ID and Registration ID are required'); } // Find the payment const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); if (!payment) { res.status(404); throw new Error('Payment not found'); } // Check if payment is a donation if (!payment.isDonation) { res.status(400); throw new Error('Only donations can be assigned to registrations'); } // Donations are never mutated once created — their remaining balance is the original // amount minus every leg (a Payment row with isDonation:false and originalPaymentId // pointing back at this donation) already allocated from it. A refund of the donation // itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces // the remaining balance (money that's left the building) instead of increasing it (which a // raw signed sum would do, since subtracting a negative adds). const existingLegs = await prisma.payment.findMany({ where: { originalPaymentId: payment.id, isDonation: false } }); const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0); const remainingDonation = payment.amount - alreadyUsed; if (remainingDonation <= 0.000001) { res.status(400); throw new Error('This donation has already been fully allocated'); } if (payment.eventId) { const donationEvent = await prisma.event.findUnique({ where: { id: payment.eventId }, select: { cashupStatus: true } }); if (donationEvent && donationEvent.cashupStatus === 'closed') { res.status(400); throw new Error('Event is closed; donations can no longer be allocated'); } } // Find the registration const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } // The check above only covers the donation's own event — also block allocating into a // registration whose event is closed, even if the donation itself came from an open one. await assertEventOpen(registration.eventId, res); // Calculate total amount due for the registration const totalDue = computeRegistrationTotalDue(registration, new Date()); // Calculate total amount already paid const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0); // Calculate remaining amount to be paid const remainingAmount = totalDue - totalPaid; // If registration is already paid in full if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); } // How much of the donation to apply — defaults to today's behaviour (as much as the // donation's remaining balance covers, capped at what's owed) but staff can specify a // smaller amount and deliberately leave the registrant owing a balance. let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount); if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) { res.status(400); throw new Error('Allocation amount must be greater than zero'); } if (allocateAmount > remainingDonation) { res.status(400); throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`); } if (allocateAmount > remainingAmount) { res.status(400); throw new Error(`Cannot allocate more than the outstanding balance of R${remainingAmount.toFixed(2)}`); } let updatedRegistration; let generatedTickets = []; let originalPaymentId = payment.id; // Create an immutable leg referencing the donation — the donation row itself is never // touched, so its original amount and history stay intact and it can be assigned again // later if this leg doesn't use it up. const leg = await prisma.payment.create({ data: { id: uuidv4(), amount: allocateAmount, method: payment.method, userId: payment.userId, recordedById: req.user.id, registrationId, eventId: registration.eventId, isDonation: false, originalPaymentId: payment.id, } }); if (allocateAmount >= remainingAmount) { // Fully covers what's owed updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } }); // Generate tickets (sync — populates generatedTickets for response) try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); } } else { updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'partial_paid', updatedAt: new Date() } }); } // Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails), // then tickets (guarantees order). const { sendDonationAssignmentEmails } = require('../utils/notifications'); const _adPaymentId = leg?.id; const _adShouldEmailTickets = generatedTickets.length > 0; const _adUserId = registration?.userId; const _adRegId = registrationId; (async () => { if (_adPaymentId) { try { await sendDonationAssignmentEmails(_adPaymentId); } catch (e) { console.error('Failed to send emails after assigning donation:', e); } } if (_adShouldEmailTickets && _adUserId) { const mockReq = { user: { id: _adUserId }, body: { registrationId: _adRegId } }; const mockRes = { status: () => mockRes, json: () => {} }; try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after assigning donation:', e); } } })(); // Prepare response const result = { message: 'Donation successfully assigned to registration', originalPaymentId, updatedRegistration, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined, leg, donationRemaining: remainingDonation - allocateAmount }; res.status(200).json(result); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts // the registration's status/tickets. The donation itself (never touched by assign) // is unaffected, so its remaining balance simply goes back up. // @route POST /api/payments/unassign-donation // @access Private/Supervisor const unassignDonationFromRegistration = async (req, res) => { try { const { legId } = req.body; if (!legId) { res.status(400); throw new Error('legId is required'); } const leg = await prisma.payment.findUnique({ where: { id: legId } }); if (!leg) { res.status(404); throw new Error('Payment not found'); } // Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive, // non-donation payment that references a donation via originalPaymentId. This also rejects // refund rows, which set originalPaymentId too but always with a negative amount. if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) { res.status(400); throw new Error('This payment is not a donation-assignment leg'); } if (!leg.registrationId) { res.status(400); throw new Error('This leg is not linked to a registration'); } const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } }); if (!donation || !donation.isDonation) { res.status(400); throw new Error('The original donation for this leg could not be found'); } await assertRegistrationEventOpen(leg.registrationId, res); const registration = await prisma.registration.findUnique({ where: { id: leg.registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } const originalStatus = registration.status; const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0); const totalDue = computeRegistrationTotalDue(registration, new Date()); const totalPaidAfter = totalPaidExisting - leg.amount; const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue); if (willDowngradeFromPaid) { // Same guard createRefund uses: block if any ticket on the registration has been scanned. const regTickets = await prisma.ticket.findMany({ where: { registrationOption: { registrationId: leg.registrationId } }, include: { usages: true } }); const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0)); if (hasUsed) { res.status(400); throw new Error('Cannot unassign this donation because a ticket has already been used'); } } await prisma.payment.delete({ where: { id: legId } }); // Recompute status the same way createRefund does after removing money from a registration. const updatedRegistration = await prisma.registration.findUnique({ where: { id: leg.registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); let finalRegistration = updatedRegistration; if (updatedRegistration) { const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0); const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date()); let newStatus; if (totalPaid >= totalDueNow) newStatus = 'paid'; else if (totalPaid > 0) newStatus = 'partial_paid'; else newStatus = 'pending'; finalRegistration = await prisma.registration.update({ where: { id: leg.registrationId }, data: { status: newStatus, updatedAt: new Date() } }); // Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade // clears every unused ticket on the registration, not just the ones this leg funded. if (originalStatus === 'paid' && newStatus !== 'paid') { await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: leg.registrationId }, isUsed: false } }); } } // Fire-and-forget notification const { sendDonationUnassignmentEmails } = require('../utils/notifications'); const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId }; (async () => { try { await sendDonationUnassignmentEmails(_udLeg); } catch (e) { console.error('Failed to send emails after unassigning donation:', e); } })(); return res.status(200).json({ message: 'Donation unassigned', updatedRegistration: finalRegistration, donationId: donation.id }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount } // Does NOT check user authorization — callers are responsible for ensuring the user owns the registration. async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) { const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, } }, payments: true, user: { select: { id: true, name: true, email: true } }, event: true } }); if (!registration) throw new Error('Registration not found'); const totalDue = computeRegistrationTotalDue(registration, new Date()); const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); const remainingAmount = totalDue - totalPaid; if (remainingAmount <= 0) throw new Error('This registration is already paid in full'); const amountInCents = Math.round(remainingAmount * 100); const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`; const now = new Date(); const lastPaymentAt = registration.payments.length > 0 ? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))) : null; const lineItems = registration.registrationOptions.map(option => { const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : ''; // Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined) ? option.priceSnapshot : require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now); return { displayName: `${option.eventOption.name}${variantLabel}`, quantity: option.quantity, pricingDetails: { price: Math.round(unitPrice * 100) } }; }); // Show prior payments as a deduction line if (totalPaid > 0) { lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } }); } // Paying full remaining — no "Partial Payment" line needed const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems }; const response = await axios.post( 'https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } } ); await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } }); return { checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: remainingAmount, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } }; } // @desc Create a Yoco checkout for a registration // @route POST /api/payments/yoco-checkout // @access Private const createYocoCheckout = async (req, res) => { try { const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = 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 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') { res.status(403); throw new Error('Not authorized to create checkout for this registration'); } // If event is in the past, block self-service payment (mirrors the registration-edit block) if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) { res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration'); } 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. const { changed: priceChanged } = await refreshPricingForRegistration(registrationId); if (priceChanged) { // Re-fetch with fresh priceSnapshot values const freshReg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); const newTotal = computeRegistrationTotalDue(freshReg, new Date()); const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0); return res.status(200).json({ priceUpdated: true, newTotal: Math.max(0, newTotal - totalPaid), message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }); } // If a specific partial amount was passed, fall through to the old inline path 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, } }, payments: 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 now = new Date(); const lastPaymentAt = registration.payments.length > 0 ? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime()))) : null; const lineItems = registration.registrationOptions.map(option => { const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : ''; const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined) ? option.priceSnapshot : require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now); return { displayName: `${option.eventOption.name}${variantLabel}`, quantity: option.quantity, pricingDetails: { price: Math.round(unitPrice * 100) } }; }); // Previous payments deduction if (totalPaid > 0) { lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } }); } // Partial payment deduction — amount being deferred to a future payment if (charge < remainingAmount) { lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } }); } const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems }; const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } }); await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } }); return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } }); } const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl }); return res.status(200).json(result); } // Branch 2: Donation to an event if (eventId) { // Validate event const event = await prisma.event.findUnique({ where: { id: eventId } }); if (!event) { res.status(404); throw new Error('Event not found'); } if (!event.isActive) { res.status(400); throw new Error('Cannot make donation to inactive event'); } if (event.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed and no longer accepting donations.'); } const amt = parseFloat(amount); if (!(amt > 0)) { res.status(400); throw new Error('Valid amount is required for donation'); } if (amt < 15) { res.status(400); throw new Error('Minimum donation is R15'); } const amountInCents = Math.round(amt * 100); const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`; const metadata = { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true }; const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata, successUrl, cancelUrl, failureUrl, lineItems: [ { displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } } ] }; const response = await axios.post( 'https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': idempotencyKey } } ); return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: amt, event: { id: event.id, title: event.title } }); } } catch (error) { console.error('Yoco checkout error:', error.response?.data || error.message); res.status(error.response?.status || 400).json({ message: error.response?.data?.message || error.message }); } }; // @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp // @route POST /api/payments/yoco-checkout/send // @access Private/Supervisor const sendPaymentLink = async (req, res) => { try { const { registrationId, redirectUrl, channel } = req.body; if (!registrationId || !redirectUrl || !['email', 'whatsapp'].includes(channel)) { res.status(400); throw new Error('registrationId, redirectUrl and a valid channel are required'); } const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: true, event: true } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } const { user, event } = registration; const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`; // Validation is synchronous (fast, no network); the actual send is backgrounded since // SMTP/WAWP round trips shouldn't block this request. if (channel === 'email') { if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); } const { sendMail } = require('../utils/email'); sendMail({ to: user.email, subject: `Payment link — ${event?.title || 'Registration'}`, text: message, html: `

Hi ${user.name || ''},

Here's your payment link for ${event?.title || 'your registration'}:

${redirectUrl}

` }).catch(e => console.error('Failed to send payment link email:', e)); } else { const { isValidZAPhone } = require('../utils/whatsapp'); if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); } const { waTextAny } = require('../utils/notify'); waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e)); } return res.status(200).json({ sent: true, channel }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; // @desc Create a refund (records as a negative payment) // @route POST /api/payments/refund // @access Private/Supervisor const createRefund = async (req, res) => { try { const { userId, amount, method, paymentId, registrationId, reason } = req.body; // Validate role via middleware, but double-check if (req.user.role !== 'admin' && req.user.role !== 'supervisor') { res.status(403); throw new Error('Not authorized to create refunds'); } // Input validation const amt = parseFloat(amount); if (!(amt > 0)) { res.status(400); throw new Error('Refund amount must be greater than 0'); } if (!userId) { res.status(400); throw new Error('User is required for refund'); } if (!paymentId && !registrationId) { res.status(400); throw new Error('Either paymentId or registrationId must be provided'); } let linkRegistrationId = null; let linkEventId = null; let originalPayment = null; // If refunding a specific payment, fetch it and infer links if (paymentId) { originalPayment = await prisma.payment.findUnique({ where: { id: paymentId } }); if (!originalPayment) { res.status(404); throw new Error('Original payment not found'); } // Optional: Ensure same user unless explicit linkRegistrationId = originalPayment.registrationId || null; linkEventId = originalPayment.eventId || null; } // If registrationId directly provided, ensure it exists if (!linkRegistrationId && registrationId) { const reg = await prisma.registration.findUnique({ where: { id: registrationId } }); if (!reg) { res.status(404); throw new Error('Registration not found'); } linkRegistrationId = reg.id; linkEventId = reg.eventId; } if (linkEventId) { await assertEventOpen(linkEventId, res); } // Pre-check: If linked to a registration, determine if this refund would downgrade from paid and handle tickets let willDowngradeFromPaid = false; let originalStatus = null; if (linkRegistrationId) { const registration = await prisma.registration.findUnique({ where: { id: linkRegistrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true, // Load tickets to check usage if needed _count: true } }); if (registration) { originalStatus = registration.status; const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0); const totalDue = computeRegistrationTotalDue(registration, new Date()); const totalPaidAfter = totalPaidExisting - Math.abs(amt); willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue); if (willDowngradeFromPaid) { // Check for used tickets; if any are used, block the refund const regTickets = await prisma.ticket.findMany({ where: { registrationOption: { registrationId: linkRegistrationId } }, include: { usages: true } }); const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0)); if (hasUsed) { res.status(400); throw new Error('Cannot process refund because the ticket has already been used'); } } } } // Create the negative payment row const negativePayment = await prisma.payment.create({ data: { id: uuidv4(), amount: -Math.abs(amt), method: method || 'refund', userId, recordedById: req.user.id, registrationId: linkRegistrationId, eventId: linkEventId, isDonation: false, originalPaymentId: originalPayment ? originalPayment.id : null, status: reason ? String(reason).slice(0, 255) : null }, include: { user: { select: { id: true, name: true, email: true } }, recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true } }, event: true } }); // If tied to a registration, recalc and update status if (linkRegistrationId) { const registration = await prisma.registration.findUnique({ where: { id: linkRegistrationId }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, payments: true } }); if (registration) { const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0); const totalDue = computeRegistrationTotalDue(registration, new Date()); let newStatus; if (totalPaid >= totalDue) newStatus = 'paid'; else if (totalPaid > 0) newStatus = 'partial_paid'; else newStatus = 'pending'; await prisma.registration.update({ where: { id: linkRegistrationId }, data: { status: newStatus, updatedAt: new Date() } }); // If we downgraded from paid to not-paid, delete any unused tickets if (originalStatus === 'paid' && newStatus !== 'paid') { await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: linkRegistrationId }, isUsed: false } }); } } } // Fire-and-forget refund notification email const { sendRefundEmail } = require('../utils/notifications'); sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e)); return res.status(201).json(negativePayment); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } }; const getPaymentStats = async (req, res) => { try{ const startOfDay = new Date(new Date().setHours(0, 0, 0, 0)); const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000); const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000); // Exclude donation-application legs — a leg re-labels part of an already-counted donation // as applied to a registration, it isn't new money. Summing both would double-count it. const excludeDonationLegs = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } }; const [totalToday, totalWeek, totalMonth] = await Promise.all([ prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...excludeDonationLegs } }), prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...excludeDonationLegs } }), prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...excludeDonationLegs } }) ]); res.status(200).json({ totalToday: totalToday._sum.amount || 0, totalWeek: totalWeek._sum.amount || 0, totalMonth: totalMonth._sum.amount || 0, }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } } module.exports = { createPayment, getPayments, getUserPayments, getPaymentById, getPaymentsByRegistration, getPaymentsByEvent, assignDonationToRegistration, unassignDonationFromRegistration, createYocoCheckout, createRegistrationCheckoutInternal, sendPaymentLink, createRefund, getPaymentStats };