Files
hope-events/backend/src/controllers/paymentController.js
T
joshuaandClaude Sonnet 5 12e5dfc643 Fold apple_pay/google_pay into the card bucket, keep everything else under other
Per feedback: card-network wallet payments (Apple Pay, Google Pay) should
report and filter as "card" on the user payment history page rather than
"other", since they settle the same way as a card payment. Any other
gateway-reported method still falls under "other".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:27:52 +02:00

1268 lines
46 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 } = 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,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
createdAt: paidAtDate || undefined
},
include: {
user: { 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,
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
createdAt: paidAtDate || undefined
},
include: {
user: { 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,
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,
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
createdAt: paidAtDate || undefined
},
include: {
user: { 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 } },
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];
function normalizeUserMethod(method) {
const m = String(method || '').toLowerCase();
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) — same
// settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, 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
}
},
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
}
}
}
});
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 } },
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');
}
// Check if payment is already assigned to a registration
if (payment.registrationId) {
res.status(400);
throw new Error('This payment is already assigned to a registration');
}
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 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(payment.amount, remainingAmount);
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
res.status(400);
throw new Error('Allocation amount must be greater than zero');
}
if (allocateAmount > payment.amount) {
res.status(400);
throw new Error('Cannot allocate more than the donation amount');
}
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;
let splitPayment = null;
// Update the payment to be associated with the registration and adjust amount
await prisma.payment.update({
where: { id: payment.id },
data: {
registrationId,
amount: allocateAmount,
isDonation: false
}
});
// If less than the full donation was allocated, the remainder stays as an unassigned
// donation (same donor, no notification — it's a bookkeeping split, not a new gift).
if (allocateAmount < payment.amount) {
const leftoverAmount = payment.amount - allocateAmount;
splitPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: leftoverAmount,
method: payment.method,
userId: payment.userId,
eventId: payment.eventId,
isDonation: true,
externalId: payment.externalId ? `${payment.externalId}-split` : null,
status: payment.status,
originalPaymentId: payment.id,
createdAt: payment.createdAt
}
});
}
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). The split/leftover payment is never notified.
const { sendDonationAssignmentEmails } = require('../utils/notifications');
const _adPaymentId = payment?.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,
splitPayment: splitPayment ? {
...splitPayment,
originalPaymentId: payment.id
} : null
};
res.status(200).json(result);
} 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}`;
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');
await 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>`
});
} 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');
await waTextAny(user, message);
}
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,
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 } },
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);
const [totalToday, totalWeek, totalMonth] = await Promise.all([
prisma.payment.aggregate({
_sum: {
amount: true
},
where: {
createdAt: {
gte: startOfDay
}
}
}),
prisma.payment.aggregate({
_sum: {
amount: true
},
where: {
createdAt: {
gte: lastWeek
}
}
}),
prisma.payment.aggregate({
_sum: {
amount: true
},
where: {
createdAt: {
gte: lastMonth
}
}
})
]);
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,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
createRefund,
getPaymentStats
};