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:
@@ -639,6 +639,83 @@ function buildPaymentAdminNotice(payment) {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
@@ -1045,6 +1181,7 @@ module.exports = {
|
||||
sendRefundEmail,
|
||||
sendSelfServiceRegistrationEmails,
|
||||
sendDonationAssignmentEmails,
|
||||
sendDonationUnassignmentEmails,
|
||||
sendCheckInEmails,
|
||||
buildCheckInConfirmation,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user