- PayFast (custom integration): signed form (PHP-style encoding, checked against a real sandbox), ITN checks (signature over all posted fields, PayFast host, VALID from /eng/query/validate, amount vs checkout). - ProviderCheckout records every checkout (amount, metadata, request); webhooks match payments to it and hold a different amount for review (ProviderTransaction.note). - /pay/<id>: payers choose a provider when several are on, and the browser posts PayFast's form from there, so payment links work for every provider. - Site settings → Payments: PayFast card (merchant ID/key, encrypted passphrase, sandbox/live) while PayFast is enabled. - Payer-facing wording no longer names Yoco; legal pages name both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1327 lines
48 KiB
JavaScript
1327 lines
48 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
|
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
|
const { computeDonationRemaining } = require('../utils/donationUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const payments = require('../payments');
|
|
const { createRegistrationCheckout } = require('../payments/checkouts');
|
|
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');
|
|
}
|
|
|
|
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
|
|
// rather than inflate the remaining balance.
|
|
const existingLegs = await prisma.payment.findMany({
|
|
where: { originalPaymentId: payment.id, isDonation: false }
|
|
});
|
|
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
|
|
|
|
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
|
|
};
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'donation_assigned',
|
|
targetType: 'Registration',
|
|
targetId: registrationId,
|
|
metadata: { paymentId, legId: leg.id, allocateAmount },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
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); }
|
|
})();
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'donation_unassigned',
|
|
targetType: 'Registration',
|
|
targetId: leg.registrationId,
|
|
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
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) });
|
|
}
|
|
};
|
|
|
|
// @desc Start an online checkout: a registration's balance (all of it, or part of it, plus
|
|
// an optional donation) or a donation to an event. `provider` picks the payment
|
|
// provider; by default the first one enabled in Site settings → Payments.
|
|
// @route POST /api/payments/checkout (also /api/payments/yoco-checkout)
|
|
// @access Private
|
|
const createCheckout = async (req, res) => {
|
|
try {
|
|
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl, provider } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
if (!registrationId && !eventId) {
|
|
res.status(400);
|
|
throw new Error('Either registrationId or eventId is required');
|
|
}
|
|
|
|
// A registration's balance
|
|
if (registrationId) {
|
|
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 the balance is worked out so the checkout charges the right price.
|
|
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
|
if (priceChanged) {
|
|
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.'
|
|
});
|
|
}
|
|
|
|
// Part of the balance (supervisor payments page)
|
|
if (amount && !isNaN(parseFloat(amount))) {
|
|
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
|
return res.status(200).json(result);
|
|
}
|
|
|
|
// Optional donation added at registration ("Donation amount" on the register form)
|
|
let donation = 0;
|
|
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
|
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
|
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
|
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
|
}
|
|
|
|
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
|
return res.status(200).json(result);
|
|
}
|
|
|
|
// A donation to an 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 < payments.MIN_ONLINE_AMOUNT) {
|
|
res.status(400);
|
|
throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
|
}
|
|
|
|
const amountInCents = Math.round(amt * 100);
|
|
const checkout = await payments.createCheckout(provider, {
|
|
amountCents: amountInCents,
|
|
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
|
|
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
|
|
description: `Donation to ${event.title}`,
|
|
payer: { name: req.user.name || '', email: req.user.email || '' },
|
|
successUrl, cancelUrl, failureUrl,
|
|
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
checkoutId: checkout.checkoutId,
|
|
redirectUrl: checkout.redirectUrl,
|
|
provider: checkout.provider,
|
|
amount: amt,
|
|
event: { id: event.id, title: event.title }
|
|
});
|
|
} catch (error) {
|
|
console.error('Checkout error:', error.message);
|
|
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Deliver a previously-generated payment link to the registrant via email or WhatsApp
|
|
// @route POST /api/payments/checkout/send (also /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));
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'refund_created',
|
|
targetType: 'Payment',
|
|
targetId: negativePayment.id,
|
|
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
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,
|
|
createCheckout,
|
|
sendPaymentLink,
|
|
createRefund,
|
|
getPaymentStats,
|
|
sendReceipt,
|
|
}; |