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>
1429 lines
81 KiB
JavaScript
1429 lines
81 KiB
JavaScript
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 || '—'} • 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 || '—'} • 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} <${payerEmail}></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'} • 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 & Donations</p>
|
||
${payTableHtml}
|
||
|
||
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0 0;font-family:${ff}">
|
||
Event starts: ${fmtDate(ev.startDate)} • 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,
|
||
};
|