Lets staff redeem a registration's Main Tickets by quantity at the door (via the Payment/registration flow) instead of scanning each QR code, and automatically emails/WhatsApps a check-in confirmation to the attendee. Also routes the At The Door "Open" button and post-payment flow dynamically: paid registrations jump straight to Check-In instead of a forced ticket print, since tickets are already sent automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1051 lines
61 KiB
JavaScript
1051 lines
61 KiB
JavaScript
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 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 } } } },
|
||
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 || '—'} • 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) };
|
||
}
|
||
|
||
// ─── 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 ───────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 = 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,
|
||
sendCheckInEmails,
|
||
buildCheckInConfirmation,
|
||
};
|