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 `
No items
`; return ``; } /** Renders a financial summary line. */ function financialSummary(totalDue, totalPaid, balance) { const ff2 = ff; return `| Total due | ${fmtAmount(totalDue)} |
| Amount paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid' : 'Balance due'} | ${fmtAmount(balance)} |
Manage your registration online
Log in to your account to view your registration, make payments, and download your tickets.
${ctaButton('Log in to your account', siteUrl + '/login', { bg: '#0f172a' })}`; } return `${divider()}Create your account
Create a free account to manage your registrations, make payments online, and access your tickets — all in one place.
${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( `🎟️ You\'re all set!How to pay
Choose any of the following payment methods:
Your tickets will be emailed once your payment is confirmed.
`; } // ─── 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 ${eventTitle}.` : `Your registration for ${eventTitle} 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 = `${heading}
${isNew ? 'Your spot is reserved' : 'Changes saved'}
Hi ${reg.user?.name || 'there'},
${subtext}${eventDate ? ` — ${eventDate}` : ''}
Your registration
${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 ${eventTitle}.` : `Your registration for ${eventTitle} 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 = `${heading}
${isNew ? 'Your spot is reserved' : 'Changes saved'}
Hi ${reg.user?.name || 'there'},
${subtext}${eventDate ? ` The event takes place on ${eventDate}.` : ''}
Your registration
${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 => `Registration ${verb}
${org.name} — internal notification
${itemRows ? `Items
` : ''}| Total due | ${fmtAmount(totalDue)} |
| Paid | ${fmtAmount(totalPaid)} |
| Balance | ${fmtAmount(balance)} |
Payment received
Thank you — we've got your payment
Hi ${payment.user?.name || 'there'},
We received your payment of ${fmtAmount(payment.amount)} for ${eventTitle}.
${callout(`${fmtAmount(payment.amount)} received| Total due | ${fmtAmount(totalDue)} |
| Total paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} | ${fmtAmount(balance)} |
Payment history
` : ''} ${balance <= 0 ? callout('You\'re fully paid! 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 = `Thank you for your donation!
Your generosity makes a difference
Hi ${payment.user?.name || 'there'},
We received your donation of ${fmtAmount(payment.amount)} to ${eventTitle}. Your support means the world to us.
${callout(`${fmtAmount(payment.amount)} donatedWe appreciate your generous support. Thank you!
`; 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 = `You're checked in! ✅
See you inside
Hi ${ticket.user?.name || 'there'},
${qtyRedeemed} ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for ${eventTitle}.
${callout(`${totalRedeemed} of ${ticket.quantity || 1} checked in${ fullyCheckedIn ? '' : `A donation was applied to your registration
Good news about your balance
Hi ${reg?.user?.name || 'there'},
A donation of ${fmtAmount(payment.amount)} was applied to your registration for ${eventTitle} by our team.
${callout(`${fmtAmount(payment.amount)} applied| Total due | ${fmtAmount(totalDue)} |
| Total paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} | ${fmtAmount(balance)} |
Payment recorded
Internal notification
`; 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 = `A donation was removed from your registration
Your balance has changed
Hi ${reg?.user?.name || 'there'},
A donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.
${callout(`${fmtAmount(payment.amount)} removed| Total due | ${fmtAmount(totalDue)} |
| Total paid | ${fmtAmount(totalPaid)} |
| ${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} | ${fmtAmount(balance)} |
Refund processed
Your refund is on its way
Hi ${user?.name || 'there'},
A refund of ${fmtAmount(amt)} has been processed for ${eventTitle}.
${payment.status ? `
Reason: ${payment.status}` : ''}
Refunds may take a few business days to appear depending on your bank and payment method. If you have any questions, contact us at ${org.email}.
`; 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') => `No registrations yet.
`; const payTableHtml = payRows.length ? `` : `No payments recorded yet.
`; const body = `Daily Summary
${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
${ev.title}
${divider()}Registrations
${regTableHtml}Payments & Donations
${payTableHtml}Event starts: ${fmtDate(ev.startDate)} • Event ends: ${fmtDate(ev.endDate)}
`; 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( `Your invoice is attached
Hi ${reg.user?.name || 'there'}, here's the invoice you requested for ${eventTitle}.
` ), 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( `Your receipt is attached
Hi ${owner?.name || 'there'}, here's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle}.
` ), 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, };