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 ` ${name} ×${qty} ${fmtAmount(price * qty)} `; }); if (!rows.length) return `

No items

`; return ` ${rows.join('')}
Item Qty Amount
`; } /** 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)}
`; } /** * 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()}

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!
No payment required. Your tickets have been sent in a separate email.`, 'success' ); } if (balance <= 0 && formRequired) { return callout( `One more step — attendee form required
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.`, '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)', `${yocoLink}
Already paid? You can safely ignore this option.` )); } options.push(paymentOption( optNum++, `Pay via our website`, `Visit ${siteUrl} to pay online.${ isUserActive === false ? `
You'll need to create a free account to pay online.` : isUserActive === true ? `
Log in to access your registration and pay.` : '' }` )); 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 `

How to pay

Choose any of the following payment methods:

${options.join('')}

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((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 ${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((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 => ` ${ro.eventOption?.name || 'Option'} ×${ro.quantity} ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)} `).join(''); const body = `

Registration ${verb}

${org.name} — internal notification

${reg.user?.phoneNumber ? `` : ''}
Registrant details
Name ${reg.user?.name || '—'}
Email ${reg.user?.email || '—'}
Phone ${reg.user.phoneNumber}
Event ${eventTitle}
Status ${reg.status || 'pending'}
Reg ID ${reg.id}
${itemRows ? `

Items

${itemRows}
Option Qty Amount
` : ''}
Total due ${fmtAmount(totalDue)}
Paid ${fmtAmount(totalPaid)}
Balance ${fmtAmount(balance)}
`; 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 => ` ${fmtDate(p.createdAt)} ${p.method || '—'} ${fmtAmount(p.amount)} `).join(''); const body = `

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
Payment method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}`, 'success')}
Total due ${fmtAmount(totalDue)}
Total paid ${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} ${fmtAmount(balance)}
${historyRows ? `

Payment history

${historyRows}
Date Method Amount
` : ''} ${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)} donated
Method: ${payment.method || '—'} • Date: ${fmtDate(payment.createdAt)}`, 'success')}

We 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 ? '' : `
${remaining} remaining on this ticket` }`, 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 = `

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
Date: ${fmtDate(payment.createdAt)}`, 'success')}
Total due ${fmtAmount(totalDue)}
Total paid ${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} ${fmtAmount(balance)}
${balance <= 0 ? callout('You\'re fully paid! 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 = `

Payment recorded

Internal notification

${payment.externalId ? `` : ''}
Payment details
Amount ${fmtAmount(payment.amount)}
Type ${type}
Payer ${payerName} <${payerEmail}>
Event ${eventTitle}
Method ${payment.method || '—'}
Date ${fmtDate(payment.createdAt)}
Payment ID ${payment.id}
External ID ${payment.externalId}
`; 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 = `

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}` : ''}

${callout(`${fmtAmount(amt)} refunded
Method: ${payment.method || 'original payment method'} • Date: ${fmtDate(payment.createdAt)}`, 'info')}

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') => `
${value}
${label}
`; const regTableHtml = regRows.length ? ` ${regRows.map((r, i) => ``).join('')}
Name Email Status Total Paid Balance
${r.name} ${r.email} ${r.status} ${fmtAmount(r.totalDue)} ${fmtAmount(r.totalPaid)} ${fmtAmount(r.balance)}
` : `

No registrations yet.

`; const payTableHtml = payRows.length ? ` ${payRows.map((p, i) => ``).join('')}
Date Payer Type Method Amount
${p.date} ${p.payerName}${p.payerEmail ? `
${p.payerEmail}` : ''}
${p.isDonation ? 'Donation' : 'Registration'} ${p.method} ${fmtAmount(p.amount)}
` : `

No payments recorded yet.

`; const body = `

Daily Summary

${new Date(now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}

${ev.title}

${statsRow('Total registrations', totalRegistrations)} ${statsRow('Confirmed paid', paidCount, '#059669')} ${statsRow('Awaiting payment', pendingCount, '#d97706')}
${fmtAmount(totalRevenue)}
Total collected
${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 ─────────────────────────────────────────────────────────── 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, };