Files
hope-events/backend/src/utils/notifications.js
T
joshuaandClaude Sonnet 5 5a9137e252 Skip daily summary emails for contact-only events
Contact-only events have no registration flow, so their daily
summary (registration/payment stats) was always empty — exclude
them from the query instead of sending a pointless email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:53 +02:00

1429 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
// ─── Formatting helpers ───────────────────────────────────────────────────────
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
function fmtAmount(amt) {
const n = Number(amt || 0);
return `R${n.toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return String(d); }
}
function fmtDateShort(d) {
try { return new Date(d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } catch { return String(d); }
}
const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
}
function getRegistrationsInbox() {
// Supports comma-separated list; return the first address for single-recipient use
const raw = getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
return raw.split(',')[0].trim() || '';
}
function getRegistrationsInboxAll() {
// Returns the full comma-separated string for multi-recipient sends
return getSettingSync('reg_notification_emails', process.env.REGISTRATIONS_EMAIL || '');
}
/**
* Resolves who should receive registration/payment/daily-summary notices for an event:
* the event's configured `notifyRecipients`, or the event creator when none are set.
*/
function getEventNotifyEmails(event) {
const recipients = Array.isArray(event?.notifyRecipients) ? event.notifyRecipients : [];
const emails = recipients.map(u => u?.email).filter(Boolean);
if (emails.length) return emails;
return event?.createdBy?.email ? [event.createdBy.email] : [];
}
/**
* Builds the deduplicated admin "to" list for an event notification: the global
* registrations inbox plus that event's notify recipients (or its creator as fallback).
*/
function buildEventNotifyRecipientList(event) {
const inbox = getRegistrationsInbox();
const seen = new Set();
const to = [];
if (inbox) { seen.add(inbox); to.push(inbox); }
for (const email of getEventNotifyEmails(event)) {
if (!seen.has(email)) { seen.add(email); to.push(email); }
}
return to;
}
// ─── Data loaders ─────────────────────────────────────────────────────────────
async function loadRegistrationFull(registrationId) {
return prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
}
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: 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 } } } },
},
},
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
}
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table. */
function selectionsTable(registrationOptions) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
const lineTotal = computeOptionLineTotal(ro, null, new Date());
return `<tr>
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(lineTotal)}</td>
</tr>`;
});
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
<th style="padding:10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:center;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Qty</th>
<th style="padding:10px 0 10px 16px;font-size:12px;font-weight:700;color:#64748b;text-align:right;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Amount</th>
</tr>
${rows.join('')}
</table>`;
}
/** Renders a financial summary line. */
function financialSummary(totalDue, totalPaid, balance) {
const ff2 = ff;
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
<tr>
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Total due</td>
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="padding:6px 0;font-size:13px;color:#64748b;font-family:${ff2}">Amount paid</td>
<td style="padding:6px 0;font-size:13px;color:#374151;text-align:right;font-weight:500;font-family:${ff2}">${fmtAmount(totalPaid)}</td>
</tr>
<tr style="border-top:2px solid #e2e8f0">
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};font-family:${ff2}">${balance <= 0 ? 'Fully paid' : 'Balance due'}</td>
<td style="padding:10px 0 6px 0;font-size:15px;font-weight:800;color:#${balance <= 0 ? '059669' : '1e293b'};text-align:right;font-family:${ff2}">${fmtAmount(balance)}</td>
</tr>
</table>`;
}
/**
* Renders the account CTA section at the bottom of user-facing registration emails.
* isActive: true → Login prompt; false → Create account prompt.
*/
function accountCta(isActive, siteUrl) {
if (isActive) {
return `${divider()}
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Manage your registration online</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
Log in to your account to view your registration, make payments, and download your tickets.
</p>
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`;
}
return `${divider()}
<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 6px 0;font-family:${ff}">Create your account</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
</p>
${ctaButton('Create your account', siteUrl + '/register', { bg: '#0f172a' })}`;
}
/**
* Renders the payment options section for registration emails.
* @param {{ balance, yocoLink, source, siteUrl, formRequired }}
* source: 'admin' (manual/self-service/at-door) | 'user' (self-register via website)
*/
function paymentSection({ balance, yocoLink, source, siteUrl, formRequired, isUserActive }) {
if (balance <= 0 && !formRequired) {
return callout(
`<strong style="font-size:15px">🎟️ You\'re all set!</strong><br/>
<span style="font-size:13px">No payment required. Your tickets have been sent in a separate email.</span>`,
'success'
);
}
if (balance <= 0 && formRequired) {
return callout(
`<strong>One more step — attendee form required</strong><br/>
<span style="font-size:13px">This event requires an attendee information form before tickets can be issued. The form was presented during registration. If you haven't submitted it yet, please contact us.</span>`,
'warning'
);
}
// Balance due — build payment options
const isAdmin = source === 'admin';
let optNum = 1;
const options = [];
if (isAdmin && yocoLink) {
options.push(paymentOption(
optNum++,
'Pay online now (quickest)',
`<a href="${yocoLink}" style="color:#2563eb;font-weight:600;text-decoration:underline">${yocoLink}</a><br/>
<span style="color:#94a3b8;font-style:italic">Already paid? You can safely ignore this option.</span>`
));
}
options.push(paymentOption(
optNum++,
`Pay via our website`,
`Visit <a href="${siteUrl}" style="color:#2563eb">${siteUrl}</a> to pay online.${
isUserActive === false
? `<br/><span style="color:#64748b">You'll need to create a free account to pay online.</span>`
: isUserActive === true
? `<br/><span style="color:#64748b">Log in to access your registration and pay.</span>`
: ''
}`
));
options.push(paymentOption(
optNum++,
'Pay at the door',
'Cash and card accepted at the event entrance. No need to pre-pay — your registration is already confirmed.'
));
return `<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 8px 0;font-family:${ff}">How to pay</p>
<p style="font-size:13px;color:#64748b;margin:0 0 16px 0;font-family:${ff}">Choose any of the following payment methods:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">${options.join('')}</table>
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">Your tickets will be emailed once your payment is confirmed.</p>`;
}
// ─── Registration confirmation (user self-registered via website) ──────────────
function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
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 heading = isNew ? 'Registration confirmed!' : 'Registration updated';
const subtext = isNew
? `Thank you for registering for <strong>${eventTitle}</strong>.`
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
const subject = isNew ? `Registration confirmed ${eventTitle}` : `Registration updated ${eventTitle}`;
const preheader = isNew
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
: `Your registration for ${eventTitle} has been updated.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</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}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Registration confirmation (admin / self-service / at-door) ───────────────
function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, formRequired = false, isNew = true } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
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 heading = isNew ? 'Registration confirmed!' : 'Registration updated';
const subtext = isNew
? `You have been registered for <strong>${eventTitle}</strong>.`
: `Your registration for <strong>${eventTitle}</strong> has been updated.`;
const subject = isNew ? `Registration confirmed ${eventTitle}` : `Registration updated ${eventTitle}`;
const preheader = isNew
? `You\'re registered for ${eventTitle}${eventDate ? ' on ' + eventDate : ''}!`
: `Your registration for ${eventTitle} has been updated.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">${heading}</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">${isNew ? 'Your spot is reserved' : 'Changes saved'}</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}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired
? 'An attendee form is required before your tickets are issued. Please complete it at the registration desk.'
: 'No payment required — your tickets have been sent in a separate email.';
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You have been registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${payText}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Admin notification (internal) ───────────────────────────────────────────
function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || '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 verb = isUpdated ? 'updated' : (isNew ? 'created' : 'modified');
const subject = isUpdated
? `Registration updated: ${eventTitle}${reg.user?.name || 'Unknown'}`
: `New registration: ${eventTitle}${reg.user?.name || 'Unknown'}`;
const itemRows = (reg.registrationOptions || []).map(ro =>
`<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}</td>
</tr>`).join('');
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Registration ${verb}</p>
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">${org.name} — internal notification</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Registrant details</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Name</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.name || '—'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Email</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user?.email || '—'}</td>
</tr>
${reg.user?.phoneNumber ? `<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Phone</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.user.phoneNumber}</td>
</tr>` : ''}
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Status</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${reg.status || 'pending'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Reg ID</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${reg.id}</td>
</tr>
</table>
${itemRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:0 0 8px 0;font-family:${ff}">Items</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 16px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Option</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:center;font-family:${ff}">Qty</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
</tr>${itemRows}
</table>` : ''}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:16px 0 0 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:4px 0;font-family:${ff}">Paid</td>
<td style="font-size:13px;color:#374151;text-align:right;font-weight:500;padding:4px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">Balance</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:8px 0 0 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>`;
const to = buildEventNotifyRecipientList(reg.event);
const text = `Registration ${verb}\n\nEvent: ${eventTitle}\nName: ${reg.user?.name || '—'}\nEmail: ${reg.user?.email || '—'}\nStatus: ${reg.status || 'pending'}\nTotal due: ${fmtAmount(totalDue)} | Paid: ${fmtAmount(totalPaid)} | Balance: ${fmtAmount(balance)}\nReg ID: ${reg.id}`;
return { to, subject, text, html: emailWrapper(body) };
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
function buildPaymentReceipt(payment) {
const org = getOrg();
const isReg = !!payment.registrationId && payment.registration;
const eventTitle = isReg
? (payment.registration?.event?.title || 'the event')
: (payment.event?.title || 'the event');
if (isReg) {
const reg = payment.registration;
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || 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 = `Payment received ${eventTitle}`;
const preheader = `We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.`;
const historyRows = (reg.payments || [])
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map(p => `<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtDate(p.createdAt)}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${p.method || '—'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-weight:600;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
</tr>`).join('');
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Payment received</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Thank you — we've got your payment</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
We received your payment of <strong>${fmtAmount(payment.amount)}</strong> for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} received</strong><br/>
<span style="font-size:13px">Payment method: ${payment.method || '—'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<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>
${historyRows ? `<p style="font-size:14px;font-weight:700;color:#0f172a;margin:24px 0 8px 0;font-family:${ff}">Payment history</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Date</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:left;font-family:${ff}">Method</th>
<th style="padding:10px 12px;font-size:12px;color:#64748b;text-align:right;font-family:${ff}">Amount</th>
</tr>${historyRows}
</table>` : ''}
${balance <= 0
? callout('<strong>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `Payment received\n\nHi ${payment.user?.name || 'there'},\n\nWe received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\nThank you!\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// Donation receipt
const subject = `Donation received ${eventTitle}`;
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your generosity makes a difference</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
<span style="font-size:13px">Method: ${payment.method || '—'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<p style="margin:24px 0 0 0;font-size:13px;color:#94a3b8;font-family:${ff}">We appreciate your generous support. Thank you!</p>`;
const text = `Thank you for your donation!\n\nHi ${payment.user?.name || 'there'},\n\nWe received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.\n\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\n\nThank you!\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! ✅</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
// someone else's donation to their registration — they didn't pay anything themselves,
// so "payment received" wording would be wrong. Kept anonymous (no donor name) by design.
function buildDonationAppliedToRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || 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 applied to your registration ${eventTitle}`;
const preheader = `A donation of ${fmtAmount(payment.amount)} was applied to 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 applied to your registration</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Good news about your balance</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> was applied to your registration for <strong>${eventTitle}</strong> by our team.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} applied</strong><br/>
<span style="font-size:13px">Date: ${fmtDate(payment.createdAt)}</span>`,
'success')}
<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>You\'re fully paid!</strong> Your tickets have been emailed to you separately.', 'success')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `A donation was applied to your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} 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 }) };
}
// ─── Payment admin notification ───────────────────────────────────────────────
function buildPaymentAdminNotice(payment) {
const isReg = !!payment.registrationId && payment.registration;
const eventTitle = isReg ? (payment.registration?.event?.title || 'Event') : (payment.event?.title || 'Event');
const payerName = payment.user?.name || '—';
const payerEmail = payment.user?.email || '—';
const subject = `Payment recorded: ${fmtAmount(payment.amount)}${payerName} (${eventTitle})`;
const type = isReg ? 'Registration payment' : 'Donation';
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0">Payment recorded</p>
<p style="font-size:13px;color:#64748b;margin:0 0 28px 0">Internal notification</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 24px 0">
<tr style="background:#f8fafc">
<td colspan="2" style="padding:12px 16px;font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Payment details</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;width:30%;font-family:${ff};border-top:1px solid #f1f5f9">Amount</td>
<td style="padding:10px 16px;font-size:15px;color:#059669;font-weight:800;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(payment.amount)}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Type</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${type}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payer</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payerName} &lt;${payerEmail}&gt;</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Event</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${eventTitle}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Method</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${payment.method || '—'}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Date</td>
<td style="padding:10px 16px;font-size:14px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtDate(payment.createdAt)}</td>
</tr>
<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">Payment ID</td>
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.id}</td>
</tr>
${payment.externalId ? `<tr>
<td style="padding:10px 16px;font-size:13px;color:#64748b;font-weight:600;font-family:${ff};border-top:1px solid #f1f5f9">External ID</td>
<td style="padding:10px 16px;font-size:12px;color:#94a3b8;font-family:${ff};border-top:1px solid #f1f5f9">${payment.externalId}</td>
</tr>` : ''}
</table>`;
const event = isReg ? payment.registration?.event : payment.event;
const to = buildEventNotifyRecipientList(event);
const text = `Payment recorded\n\nAmount: ${fmtAmount(payment.amount)}\nType: ${type}\nPayer: ${payerName} <${payerEmail}>\nEvent: ${eventTitle}\nMethod: ${payment.method || '—'}\nDate: ${fmtDate(payment.createdAt)}\nID: ${payment.id}`;
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-assignment — 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), relabeled so it
// doesn't read as a fresh incoming payment: no new money changed hands here, an
// already-recorded donation was just reallocated to a registration.
function buildDonationAssignmentAdminNotice(payment) {
const notice = buildPaymentAdminNotice(payment);
const eventTitle = payment.registration?.event?.title || 'Event';
const payerName = payment.user?.name || '—';
const subject = `Donation applied: ${fmtAmount(payment.amount)}${payerName} (${eventTitle})`;
return {
...notice,
subject,
html: notice.html
.replace('Payment recorded', 'Donation applied')
.replace('Internal notification', 'Internal notification — donation applied to a registration')
.replace('>Registration payment<', '>Donation applied<'),
text: notice.text
.replace('Payment recorded', 'Donation applied')
.replace('Type: Registration payment', 'Type: Donation applied'),
};
}
// 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')
.replace('>Registration payment<', '>Donation unassigned<'),
text: notice.text
.replace('Payment recorded', 'Donation unassigned')
.replace('Type: Registration payment', 'Type: Donation unassigned'),
};
}
// ─── Refund email ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) {
const org = getOrg();
const user = payment.user;
const amt = Math.abs(payment.amount || 0);
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const subject = `Refund processed ${fmtAmount(amt)} for ${eventTitle}`;
const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Refund processed</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your refund is on its way</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
A refund of <strong>${fmtAmount(amt)}</strong> has been processed for <strong>${eventTitle}</strong>.
${payment.status ? `<br/>Reason: ${payment.status}` : ''}
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(amt)} refunded</strong><br/>
<span style="font-size:13px">Method: ${payment.method || 'original payment method'} &bull; Date: ${fmtDate(payment.createdAt)}</span>`,
'info')}
<p style="margin:24px 0 0 0;font-size:14px;color:#374151;font-family:${ff}">
Refunds may take a few business days to appear depending on your bank and payment method.
If you have any questions, contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const text = `Refund processed\n\nHi ${user?.name || 'there'},\n\nA refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.\n\nMethod: ${payment.method || 'original method'}\nDate: ${fmtDate(payment.createdAt)}\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Daily event summary ──────────────────────────────────────────────────────
function buildDailySummary(ev, registrations, payments, now) {
const org = getOrg();
const subject = `Daily summary: ${ev.title}${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
const regRows = registrations.map(r => {
const totalDue = computeRegistrationTotalDue(r, now);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
return { name: r.user?.name || '?', email: r.user?.email || '', status: r.status, totalDue, totalPaid, balance };
});
const payRows = payments.map(p => ({
date: fmtDate(p.createdAt),
amount: p.amount,
method: p.method || '—',
isDonation: p.isDonation || !p.registrationId,
payerName: p.registration?.user?.name || p.user?.name || '?',
payerEmail: p.user?.email || p.registration?.user?.email || '',
}));
const totalRegistrations = regRows.length;
const totalRevenue = payRows.reduce((s, p) => s + (p.amount || 0), 0);
const paidCount = regRows.filter(r => r.status === 'paid').length;
const pendingCount = regRows.filter(r => r.status === 'pending' || r.status === 'partial_paid').length;
const statsRow = (label, value, color = '#1e293b') =>
`<td style="text-align:center;padding:16px;border-right:1px solid #f1f5f9">
<div style="font-size:24px;font-weight:800;color:${color};font-family:${ff}">${value}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">${label}</div>
</td>`;
const regTableHtml = regRows.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Name</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Email</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Status</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Total</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Paid</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Balance</th>
</tr>
${regRows.map((r, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${r.name}</td>
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${r.email}</td>
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
<span style="background:${r.status === 'paid' ? '#dcfce7' : r.status === 'partial_paid' ? '#fef9c3' : '#f1f5f9'};color:${r.status === 'paid' ? '#166534' : r.status === 'partial_paid' ? '#854d0e' : '#475569'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${r.status}</span>
</td>
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalDue)}</td>
<td style="padding:10px 12px;text-align:right;color:#374151;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.totalPaid)}</td>
<td style="padding:10px 12px;text-align:right;color:${r.balance > 0 ? '#b45309' : '#059669'};font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(r.balance)}</td>
</tr>`).join('')}
</table>`
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No registrations yet.</p>`;
const payTableHtml = payRows.length
? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;font-size:13px">
<tr style="background:#f8fafc">
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Date</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Payer</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Type</th>
<th style="padding:10px 12px;text-align:left;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Method</th>
<th style="padding:10px 12px;text-align:right;color:#64748b;font-weight:700;font-family:${ff};font-size:12px;text-transform:uppercase;letter-spacing:0.4px">Amount</th>
</tr>
${payRows.map((p, i) => `<tr style="background:${i % 2 === 0 ? '#ffffff' : '#f8fafc'}">
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9;font-size:12px">${p.date}</td>
<td style="padding:10px 12px;color:#1e293b;font-weight:500;font-family:${ff};border-top:1px solid #f1f5f9">${p.payerName}${p.payerEmail ? `<br/><span style="font-size:11px;color:#94a3b8">${p.payerEmail}</span>` : ''}</td>
<td style="padding:10px 12px;border-top:1px solid #f1f5f9">
<span style="background:${p.isDonation ? '#eff6ff' : '#f0fdf4'};color:${p.isDonation ? '#1e40af' : '#166534'};padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-family:${ff}">${p.isDonation ? 'Donation' : 'Registration'}</span>
</td>
<td style="padding:10px 12px;color:#64748b;font-family:${ff};border-top:1px solid #f1f5f9">${p.method}</td>
<td style="padding:10px 12px;text-align:right;color:#059669;font-weight:700;font-family:${ff};border-top:1px solid #f1f5f9">${fmtAmount(p.amount)}</td>
</tr>`).join('')}
</table>`
: `<p style="color:#94a3b8;font-size:14px;margin:0;font-family:${ff}">No payments recorded yet.</p>`;
const body = `
<p style="font-size:20px;font-weight:800;color:#0f172a;margin:0 0 4px 0;font-family:${ff}">Daily Summary</p>
<p style="font-size:13px;color:#64748b;margin:0 0 4px 0;font-family:${ff}">${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</p>
<p style="font-size:16px;font-weight:700;color:#1e293b;margin:0 0 24px 0;font-family:${ff}">${ev.title}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 32px 0;text-align:center">
<tr>
${statsRow('Total registrations', totalRegistrations)}
${statsRow('Confirmed paid', paidCount, '#059669')}
${statsRow('Awaiting payment', pendingCount, '#d97706')}
<td style="text-align:center;padding:16px">
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
</td>
</tr>
</table>
${divider()}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 16px 0;font-family:${ff}">Registrations</p>
${regTableHtml}
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:32px 0 16px 0;font-family:${ff}">Payments &amp; Donations</p>
${payTableHtml}
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
Event starts: ${fmtDate(ev.startDate)} &bull; Event ends: ${fmtDate(ev.endDate)}
</p>`;
const text = [
`Daily Summary — ${ev.title}`,
new Date(now).toLocaleDateString(),
'',
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
'',
'Registrations:',
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
'',
'Payments:',
...(payRows.length ? payRows.map(p => ` ${p.date}${fmtAmount(p.amount)} via ${p.method}${p.isDonation ? 'Donation' : 'Registration'}${p.payerName}`) : [' (none)']),
].join('\n');
return { subject, text, html: emailWrapper(body) };
}
// ─── Send functions ───────────────────────────────────────────────────────────
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails — callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
return null;
}
}
// Only used by the automatic registration-confirmation sends, which should stay silent (no
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (totalDue - totalPaid <= 0.01) return null;
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
try {
const { generateInvoicePdf } = require('./pdfDocs');
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
return null;
}
}
async function sendRegistrationEmails(registrationId) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
// Email: only for real addresses (skip guest.local placeholders)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: true });
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
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 registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendRegistrationUpdatedEmails(registrationId) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: false });
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
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 registration updated emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
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 self-service registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendPaymentEmails(paymentId) {
let receiptPdf = null;
try {
const payment = await loadPaymentFull(paymentId);
if (!payment) return;
const user = payment.registration?.user || payment.user;
const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
const { generateReceiptPdf } = require('./pdfDocs');
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildPaymentReceipt(payment);
if (shouldEmail(user)) sends.push(sendMail({
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
const waCaption = buildWAPayment(payment);
if (hasValidEmail) {
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
} else {
sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
}
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
if (hasEvent) {
const adminMsg = buildPaymentAdminNotice(payment);
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 payment emails:', e);
} finally {
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
async function sendRefundEmail(refundPaymentId) {
try {
const payment = await loadPaymentFull(refundPaymentId);
if (!payment) return;
const user = payment.user;
if (!user?.email || user.email.endsWith('@guest.local')) return;
const msg = buildRefundEmail(payment);
const sends = [sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })];
const adminMsg = buildPaymentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: `Refund: ${adminMsg.subject}`, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send refund email:', e);
}
}
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
// again here, and any leftover/unassigned remainder from a partial allocation is silent too.
async function sendDonationAssignmentEmails(paymentId) {
try {
const payment = await loadPaymentFull(paymentId);
if (!payment || !payment.registration) return;
const user = payment.registration.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWADonationAppliedToRegistrant } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildDonationAppliedToRegistrant(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
if (hasValidEmail) {
sends.push(waText(user, buildWADonationAppliedToRegistrant(payment)));
} else {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
}
const adminMsg = buildDonationAssignmentAdminNotice(payment);
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-assignment emails:', e);
}
}
// 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 } }, tranches: 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);
}
}
// ─── Manual "send to me" requests ──────────────────────────────────────────────
// Unlike the fire-and-forget sends above, these are awaited directly from a
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
// controller can surface a real error to the user rather than failing silently.
function userFacingError(message, statusCode) {
const e = new Error(message);
e.statusCode = statusCode;
return e;
}
/**
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
* @param {string} registrationId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendInvoiceToUser(registrationId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const reg = await loadRegistrationFull(registrationId);
if (!reg) throw userFacingError('Registration not found', 404);
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
// Unlike the automatic post-registration send, a manually-requested invoice is generated
// regardless of balance — someone may want it as a paid-in-full record too.
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
let invoicePdf;
try {
const { generateInvoicePdf } = require('./pdfDocs');
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
}
try {
const eventTitle = reg.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: reg.user.email,
subject: `Your invoice for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
});
} else {
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
// preference is email-only.
const { waPdfAny } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
}
} finally {
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
/**
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
* channel. Only the person the payment belongs to (via the payment itself or its registration)
* may request their own receipt.
* @param {string} paymentId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendReceiptToUser(paymentId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const payment = await loadPaymentFull(paymentId);
if (!payment) throw userFacingError('Payment not found', 404);
const owner = payment.registration?.user || payment.user;
const ownerId = payment.registration?.userId || payment.userId;
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !owner?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const { generateReceiptPdf } = require('./pdfDocs');
let receiptPdf;
try {
receiptPdf = await generateReceiptPdf(payment);
} catch (e) {
console.error('Failed to generate receipt PDF:', e);
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
}
try {
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: owner.email,
subject: `Your payment receipt for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
});
} else {
const { waPdfAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
}
} finally {
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
let events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
try {
events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
} catch {}
await Promise.allSettled(events.map(async ev => {
const registrations = await prisma.registration.findMany({
where: { eventId: ev.id },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
},
orderBy: { createdAt: 'asc' },
});
const payments = await prisma.payment.findMany({
where: { OR: [{ eventId: ev.id }, { registration: { eventId: ev.id } }] },
include: {
user: { select: { id: true, name: true, email: true } },
registration: { select: { id: true, user: { select: { id: true, name: true, email: true } } } },
},
orderBy: { createdAt: 'asc' },
});
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
const to = buildEventNotifyRecipientList(ev);
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
}));
} catch (e) {
console.error('Failed to send daily event summaries:', e);
}
}
module.exports = {
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
sendDailyEventSummaries,
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendDonationUnassignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
sendInvoiceToUser,
sendReceiptToUser,
};