Add ability to unassign a donation from a registration
Assigning a donation to a registration was one-way: the refund flow could reverse the money but left the donation's "leg" payment in place, permanently locking that portion of the donation as used even though it had been refunded back out. Adds POST /api/payments/unassign-donation, which deletes the leg, reverts the registration's status/tickets the same way a refund downgrade already does, and notifies the registrant. New "Assigned donations" list on the supervisor Payments page surfaces existing legs with an Unassign action, since no such list existed before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
|
||||
const axios = require('axios');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Create a new payment
|
||||
// @route POST /api/payments
|
||||
@@ -770,6 +770,129 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts
|
||||
// the registration's status/tickets. The donation itself (never touched by assign)
|
||||
// is unaffected, so its remaining balance simply goes back up.
|
||||
// @route POST /api/payments/unassign-donation
|
||||
// @access Private/Supervisor
|
||||
const unassignDonationFromRegistration = async (req, res) => {
|
||||
try {
|
||||
const { legId } = req.body;
|
||||
if (!legId) {
|
||||
res.status(400);
|
||||
throw new Error('legId is required');
|
||||
}
|
||||
|
||||
const leg = await prisma.payment.findUnique({ where: { id: legId } });
|
||||
if (!leg) {
|
||||
res.status(404);
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
// Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive,
|
||||
// non-donation payment that references a donation via originalPaymentId. This also rejects
|
||||
// refund rows, which set originalPaymentId too but always with a negative amount.
|
||||
if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) {
|
||||
res.status(400);
|
||||
throw new Error('This payment is not a donation-assignment leg');
|
||||
}
|
||||
if (!leg.registrationId) {
|
||||
res.status(400);
|
||||
throw new Error('This leg is not linked to a registration');
|
||||
}
|
||||
|
||||
const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } });
|
||||
if (!donation || !donation.isDonation) {
|
||||
res.status(400);
|
||||
throw new Error('The original donation for this leg could not be found');
|
||||
}
|
||||
|
||||
await assertRegistrationEventOpen(leg.registrationId, res);
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
|
||||
const originalStatus = registration.status;
|
||||
const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const totalPaidAfter = totalPaidExisting - leg.amount;
|
||||
const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue);
|
||||
|
||||
if (willDowngradeFromPaid) {
|
||||
// Same guard createRefund uses: block if any ticket on the registration has been scanned.
|
||||
const regTickets = await prisma.ticket.findMany({
|
||||
where: { registrationOption: { registrationId: leg.registrationId } },
|
||||
include: { usages: true }
|
||||
});
|
||||
const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
|
||||
if (hasUsed) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot unassign this donation because a ticket has already been used');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.payment.delete({ where: { id: legId } });
|
||||
|
||||
// Recompute status the same way createRefund does after removing money from a registration.
|
||||
const updatedRegistration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
let finalRegistration = updatedRegistration;
|
||||
if (updatedRegistration) {
|
||||
const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date());
|
||||
let newStatus;
|
||||
if (totalPaid >= totalDueNow) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
else newStatus = 'pending';
|
||||
finalRegistration = await prisma.registration.update({
|
||||
where: { id: leg.registrationId },
|
||||
data: { status: newStatus, updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade
|
||||
// clears every unused ticket on the registration, not just the ones this leg funded.
|
||||
if (originalStatus === 'paid' && newStatus !== 'paid') {
|
||||
await prisma.ticket.deleteMany({
|
||||
where: {
|
||||
registrationOption: { registrationId: leg.registrationId },
|
||||
isUsed: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget notification
|
||||
const { sendDonationUnassignmentEmails } = require('../utils/notifications');
|
||||
const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId };
|
||||
(async () => {
|
||||
try { await sendDonationUnassignmentEmails(_udLeg); }
|
||||
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
|
||||
})();
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Donation unassigned',
|
||||
updatedRegistration: finalRegistration,
|
||||
donationId: donation.id
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
|
||||
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||
@@ -1301,6 +1424,7 @@ module.exports = {
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
|
||||
Reference in New Issue
Block a user