Files
hope-events/backend/src/controllers/paymentController.js
T
joshuaandClaude Sonnet 5 bc64069021 Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown,
  early-bird discount, balance due, Yoco pay-now link/QR) whenever a
  balance is outstanding; payment/donation confirmations attach a
  payment receipt PDF. Sent as an email attachment and, over WhatsApp,
  as the PDF itself with the existing message as its caption.
- Users can also (re)send either document on demand: an "Invoice"
  button on the registration detail popup, and a "Receipt" button next
  to each payment there and on the Payment history page, each opening
  an Email/WhatsApp choice popup, via two new endpoints restricted to
  the registration/payment's own owner.
- Fix: editing an event option's early-bird tiers deleted and
  recreated every tier for that option with brand-new ids, silently
  severing the appliedTierId link on all historical purchases (losing
  early-bird attribution and undercounting stock-limit usage) even for
  tiers the admin didn't touch. Tiers are now upserted by id.
- Update the "My Events" help content and the API docs index for the
  new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:26:49 +02:00

1452 lines
54 KiB
JavaScript

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 } }, tranches: 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 } }, tranches: 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 } },
tranches: 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 "<method>-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 } },
tranches: 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 } }, tranches: 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 } }, tranches: 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,
tranches: 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 } }, tranches: 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,
tranches: 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: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>`
}).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 } }, tranches: 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 } }, tranches: 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) });
}
}
// @desc Re-send the receipt PDF for one of the caller's own payments
// @route POST /api/payments/:id/send-receipt
// @access Private
const sendReceipt = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendReceiptToUser } = require('../utils/notifications');
await sendReceiptToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = {
createPayment,
getPayments,
getUserPayments,
getPaymentById,
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
createRefund,
getPaymentStats,
sendReceipt,
};