Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+984
View File
@@ -0,0 +1,984 @@
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = 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 || 'Hope Events'),
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 } } } },
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 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 } } } },
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 price = ro.eventOption?.price || 0;
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(price * qty)}</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((ro.eventOption?.price || 0) * ro.quantity)}`).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((ro.eventOption?.price || 0) * ro.quantity)}`).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((ro.eventOption?.price || 0) * ro.quantity)}</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 }) };
}
// ─── 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) };
}
// ─── 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 ───────────────────────────────────────────────────────────
async function sendRegistrationEmails(registrationId) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, 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);
// 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 }));
}
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
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);
}
}
async function sendRegistrationUpdatedEmails(registrationId) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, 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);
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 }));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
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);
}
}
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, 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);
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 }));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
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);
}
}
async function sendPaymentEmails(paymentId) {
try {
const payment = await loadPaymentFull(paymentId);
if (!payment) return;
const user = payment.registration?.user || payment.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
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 }));
}
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
if (hasValidEmail) {
sends.push(waText(user, buildWAPayment(payment)));
} else {
sends.push(waTextAny(user, buildWAPayment(payment)));
}
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);
}
}
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);
}
}
// 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 = 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 donation-assignment emails:', e);
}
}
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 } },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
try {
events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
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 } } } },
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,
};