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:
@@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
|
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||||
|
|
||||||
// @desc Create a new payment
|
// @desc Create a new payment
|
||||||
// @route POST /api/payments
|
// @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 }
|
// 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.
|
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||||
@@ -1301,6 +1424,7 @@ module.exports = {
|
|||||||
getPaymentsByRegistration,
|
getPaymentsByRegistration,
|
||||||
getPaymentsByEvent,
|
getPaymentsByEvent,
|
||||||
assignDonationToRegistration,
|
assignDonationToRegistration,
|
||||||
|
unassignDonationFromRegistration,
|
||||||
createYocoCheckout,
|
createYocoCheckout,
|
||||||
createRegistrationCheckoutInternal,
|
createRegistrationCheckoutInternal,
|
||||||
sendPaymentLink,
|
sendPaymentLink,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
getPaymentsByRegistration,
|
getPaymentsByRegistration,
|
||||||
getPaymentsByEvent,
|
getPaymentsByEvent,
|
||||||
assignDonationToRegistration,
|
assignDonationToRegistration,
|
||||||
|
unassignDonationFromRegistration,
|
||||||
createYocoCheckout,
|
createYocoCheckout,
|
||||||
sendPaymentLink,
|
sendPaymentLink,
|
||||||
createRefund,
|
createRefund,
|
||||||
@@ -27,6 +28,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
|||||||
router.get('/', protect, supervisor, getPayments);
|
router.get('/', protect, supervisor, getPayments);
|
||||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||||
|
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
|
||||||
router.post('/refund', protect, supervisor, createRefund);
|
router.post('/refund', protect, supervisor, createRefund);
|
||||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||||
|
|
||||||
|
|||||||
@@ -639,6 +639,83 @@ function buildPaymentAdminNotice(payment) {
|
|||||||
return { to, subject, text, html: emailWrapper(body) };
|
return { to, subject, text, html: emailWrapper(body) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Donation unassignment ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Sent when staff reverse a previous donation-assignment. Distinct from
|
||||||
|
// buildDonationAppliedToRegistrant: the leg payment no longer exists by the time this runs
|
||||||
|
// (it's hard-deleted before the notification fires), so callers pass a synthetic payment-shaped
|
||||||
|
// object — { amount, createdAt, registration } — built from the leg's captured values plus a
|
||||||
|
// freshly re-fetched registration so the balance table reflects the post-removal total.
|
||||||
|
// Kept anonymous (no donor name), same reasoning as the "applied" email.
|
||||||
|
|
||||||
|
function buildDonationUnassignedFromRegistrant(payment) {
|
||||||
|
const org = getOrg();
|
||||||
|
const reg = payment.registration;
|
||||||
|
const eventTitle = reg?.event?.title || 'the event';
|
||||||
|
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||||
|
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||||
|
const balance = Math.max(totalDue - totalPaid, 0);
|
||||||
|
const isUserActive = reg?.user?.isActive;
|
||||||
|
const subject = `A donation was removed from your registration – ${eventTitle}`;
|
||||||
|
const preheader = `A donation of ${fmtAmount(payment.amount)} was removed from your registration for ${eventTitle}.`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was removed from your registration</p>
|
||||||
|
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your balance has changed</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
|
||||||
|
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||||
|
A donation of <strong>${fmtAmount(payment.amount)}</strong> previously applied to your registration for <strong>${eventTitle}</strong> has been removed by our team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} removed</strong><br/>
|
||||||
|
<span style="font-size:13px">Date: ${fmtDate(new Date())}</span>`,
|
||||||
|
'warning')}
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||||
|
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||||
|
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${balance > 0
|
||||||
|
? callout(`<strong>A balance is now owing.</strong> Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning')
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
${accountCta(isUserActive, org.url)}`;
|
||||||
|
|
||||||
|
const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`;
|
||||||
|
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal admin notice for a donation-unassignment — same table layout as
|
||||||
|
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
|
||||||
|
// since legs copy userId from the original donation, not the registrant), with copy adjusted
|
||||||
|
// for a removal rather than a new payment.
|
||||||
|
function buildDonationUnassignmentAdminNotice(payment) {
|
||||||
|
const notice = buildPaymentAdminNotice(payment);
|
||||||
|
const eventTitle = payment.registration?.event?.title || 'Event';
|
||||||
|
const payerName = payment.user?.name || '—';
|
||||||
|
const subject = `Donation unassigned: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
|
||||||
|
return {
|
||||||
|
...notice,
|
||||||
|
subject,
|
||||||
|
html: notice.html
|
||||||
|
.replace('Payment recorded', 'Donation unassigned')
|
||||||
|
.replace('Internal notification', 'Internal notification — donation removed from registration'),
|
||||||
|
text: notice.text.replace('Payment recorded', 'Donation unassigned'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Refund email ─────────────────────────────────────────────────────────────
|
// ─── Refund email ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRefundEmail(payment) {
|
function buildRefundEmail(payment) {
|
||||||
@@ -990,6 +1067,65 @@ async function sendDonationAssignmentEmails(paymentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sent when staff reverse a previous donation-assignment (the leg payment is hard-deleted
|
||||||
|
// before this runs, so it can't be re-fetched by id like loadPaymentFull does elsewhere —
|
||||||
|
// callers pass a snapshot of the leg's fields captured just before deletion instead).
|
||||||
|
// Notifies the registrant (balance likely increased) and logs an internal admin notice showing
|
||||||
|
// the donor as "Payer", same as the original assignment notice did.
|
||||||
|
async function sendDonationUnassignmentEmails(leg) {
|
||||||
|
try {
|
||||||
|
const [registration, donor] = await Promise.all([
|
||||||
|
prisma.registration.findUnique({
|
||||||
|
where: { id: leg.registrationId },
|
||||||
|
include: {
|
||||||
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||||
|
payments: true,
|
||||||
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||||
|
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
leg.userId ? prisma.user.findUnique({ where: { id: leg.userId }, select: { id: true, name: true, email: true } }) : null,
|
||||||
|
]);
|
||||||
|
if (!registration) return;
|
||||||
|
|
||||||
|
// Synthetic payment-shaped object matching what buildDonationUnassignedFromRegistrant and
|
||||||
|
// buildPaymentAdminNotice (via buildDonationUnassignmentAdminNotice) expect.
|
||||||
|
const pseudoPayment = {
|
||||||
|
id: leg.id,
|
||||||
|
amount: leg.amount,
|
||||||
|
createdAt: leg.createdAt,
|
||||||
|
method: leg.method,
|
||||||
|
externalId: leg.externalId,
|
||||||
|
registrationId: leg.registrationId,
|
||||||
|
registration,
|
||||||
|
user: donor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = registration.user;
|
||||||
|
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||||
|
const { buildWADonationUnassignedFromRegistrant } = require('./waMessages');
|
||||||
|
|
||||||
|
const sends = [];
|
||||||
|
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||||
|
if (hasValidEmail) {
|
||||||
|
const msg = buildDonationUnassignedFromRegistrant(pseudoPayment);
|
||||||
|
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||||
|
}
|
||||||
|
if (hasValidEmail) {
|
||||||
|
sends.push(waText(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
|
||||||
|
} else {
|
||||||
|
sends.push(waTextAny(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
|
||||||
|
}
|
||||||
|
const adminMsg = buildDonationUnassignmentAdminNotice(pseudoPayment);
|
||||||
|
if (adminMsg.to && adminMsg.to.length) {
|
||||||
|
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||||
|
}
|
||||||
|
await Promise.all(sends);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send donation-unassignment emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sendDailyEventSummaries(now = new Date()) {
|
async function sendDailyEventSummaries(now = new Date()) {
|
||||||
try {
|
try {
|
||||||
const today = new Date(now);
|
const today = new Date(now);
|
||||||
@@ -1045,6 +1181,7 @@ module.exports = {
|
|||||||
sendRefundEmail,
|
sendRefundEmail,
|
||||||
sendSelfServiceRegistrationEmails,
|
sendSelfServiceRegistrationEmails,
|
||||||
sendDonationAssignmentEmails,
|
sendDonationAssignmentEmails,
|
||||||
|
sendDonationUnassignmentEmails,
|
||||||
sendCheckInEmails,
|
sendCheckInEmails,
|
||||||
buildCheckInConfirmation,
|
buildCheckInConfirmation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,11 +266,44 @@ function buildWADonationAppliedToRegistrant(payment) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
|
||||||
|
// the leg's original creation time, not "now"; use the current date for the balance display.
|
||||||
|
function buildWADonationUnassignedFromRegistrant(payment) {
|
||||||
|
const org = getOrg();
|
||||||
|
const reg = payment.registration;
|
||||||
|
const eventTitle = reg?.event?.title || 'the event';
|
||||||
|
const name = reg?.user?.name || 'there';
|
||||||
|
const amount = fmtAmount(payment.amount);
|
||||||
|
|
||||||
|
let balLine = '';
|
||||||
|
if (reg) {
|
||||||
|
const { computeRegistrationTotalDue } = require('./pricing');
|
||||||
|
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||||
|
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||||
|
const balance = Math.max(totalDue - totalPaid, 0);
|
||||||
|
balLine = balance > 0
|
||||||
|
? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`⚠️ *Donation Removed*`,
|
||||||
|
'',
|
||||||
|
`Hi ${name},`,
|
||||||
|
'',
|
||||||
|
`A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
|
||||||
|
balLine,
|
||||||
|
'',
|
||||||
|
`_${org.name}_ | ${org.url}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildWARegistration,
|
buildWARegistration,
|
||||||
buildWAPayment,
|
buildWAPayment,
|
||||||
buildWARefund,
|
buildWARefund,
|
||||||
buildWADonationAppliedToRegistrant,
|
buildWADonationAppliedToRegistrant,
|
||||||
|
buildWADonationUnassignedFromRegistrant,
|
||||||
buildWALogin,
|
buildWALogin,
|
||||||
buildWAWelcome,
|
buildWAWelcome,
|
||||||
buildWAAccountClosed,
|
buildWAAccountClosed,
|
||||||
|
|||||||
@@ -771,6 +771,10 @@ function PaymentsContent() {
|
|||||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
|
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
|
||||||
/>
|
/>
|
||||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||||
|
<AssignedDonationsList
|
||||||
|
payments={payments}
|
||||||
|
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1009,6 +1013,92 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lists every donation-assignment leg (isDonationLeg) across all donations, with an Unassign
|
||||||
|
// action per row. Legs aren't shown anywhere else in the UI — "Recent payments" and the "Today"
|
||||||
|
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
|
||||||
|
// so this is the only place staff can see and reverse an assignment.
|
||||||
|
type AssignedDonationsListProps = {
|
||||||
|
payments: any[];
|
||||||
|
onDone: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [unassigningId, setUnassigningId] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useDismissingState<string | null>(null);
|
||||||
|
|
||||||
|
const legs = useMemo(() => {
|
||||||
|
return payments
|
||||||
|
.filter(isDonationLeg)
|
||||||
|
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}, [payments]);
|
||||||
|
|
||||||
|
const donationById = useMemo(() => {
|
||||||
|
const m = new Map<string, any>();
|
||||||
|
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
|
||||||
|
return m;
|
||||||
|
}, [payments]);
|
||||||
|
|
||||||
|
const unassign = async (leg: any) => {
|
||||||
|
if (!token) return;
|
||||||
|
setErr(null);
|
||||||
|
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
setUnassigningId(leg.id);
|
||||||
|
await apiFetch('/api/payments/unassign-donation', {
|
||||||
|
method: 'POST',
|
||||||
|
authToken: token,
|
||||||
|
body: { legId: leg.id }
|
||||||
|
});
|
||||||
|
await onDone();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Failed to unassign donation');
|
||||||
|
} finally {
|
||||||
|
setUnassigningId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||||
|
<div className="text-lg font-semibold mb-3">Assigned donations</div>
|
||||||
|
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
|
||||||
|
{legs.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
|
||||||
|
{legs.map((leg: any) => {
|
||||||
|
const donation = donationById.get(leg.originalPaymentId);
|
||||||
|
return (
|
||||||
|
<li key={leg.id} className="border rounded p-2">
|
||||||
|
<div className="flex justify-between items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">R {(leg.amount || 0).toFixed(2)} — {leg.registration?.user?.name || leg.registration?.userId || 'Registrant'}</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
Registration: #{String(leg.registrationId).slice(0,8)}{leg.registration?.event?.title ? ` — ${leg.registration.event.title}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
From donation by {donation?.user?.name || donation?.userId || 'Donor'} — #{String(leg.originalPaymentId).slice(0,8)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">{new Date(leg.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={unassigningId === leg.id}
|
||||||
|
onClick={() => unassign(leg)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{unassigningId === leg.id ? 'Unassigning…' : 'Unassign'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type DonationAssignSectionProps = {
|
type DonationAssignSectionProps = {
|
||||||
payments: any[];
|
payments: any[];
|
||||||
allUsers: any[];
|
allUsers: any[];
|
||||||
|
|||||||
Reference in New Issue
Block a user