Files
hope-events/backend/src/controllers/paymentController.js
T
joshuaandClaude Sonnet 5 252f80fadb Add ability to unassign a donation from a registration
Assigning a donation to a registration was one-way: the refund flow
could reverse the money but left the donation's "leg" payment in
place, permanently locking that portion of the donation as used even
though it had been refunded back out. Adds POST
/api/payments/unassign-donation, which deletes the leg, reverts the
registration's status/tickets the same way a refund downgrade already
does, and notifies the registrant. New "Assigned donations" list on
the supervisor Payments page surfaces existing legs with an Unassign
action, since no such list existed before.

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

1433 lines
53 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 } } } },
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 "<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 } }
}
},
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: `<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 } } } },
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
};