- 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>
1452 lines
54 KiB
JavaScript
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,
|
|
}; |