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 ` ${name} ×${qty} ${fmtAmount(lineTotal)} `; }); 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(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 => ` ${ro.eventOption?.name || 'Option'} ×${ro.quantity} ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))} `).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) }; } // ─── 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
Date: ${fmtDate(new Date())}`, 'warning')}
Total due ${fmtAmount(totalDue)}
Total paid ${fmtAmount(totalPaid)}
${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'} ${fmtAmount(balance)}
${balance > 0 ? callout(`A balance is now owing. Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning') : ''} ${accountCta(isUserActive, org.url)}`; const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`; return { subject, text, html: emailWrapper(body, { preheader }) }; } // Internal admin notice for a donation-assignment — same table layout as // buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor, // since legs copy userId from the original donation, not the registrant), relabeled so it // doesn't read as a fresh incoming payment: no new money changed hands here, an // already-recorded donation was just reallocated to a registration. function buildDonationAssignmentAdminNotice(payment) { const notice = buildPaymentAdminNotice(payment); const eventTitle = payment.registration?.event?.title || 'Event'; const payerName = payment.user?.name || '—'; const subject = `Donation applied: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`; return { ...notice, subject, html: notice.html .replace('Payment recorded', 'Donation applied') .replace('Internal notification', 'Internal notification — donation applied to a registration') .replace('>Registration payment<', '>Donation applied<'), text: notice.text .replace('Payment recorded', 'Donation applied') .replace('Type: Registration payment', 'Type: Donation applied'), }; } // Internal admin notice for a donation-unassignment — same table layout as // buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor, // since legs copy userId from the original donation, not the registrant), with copy adjusted // for a removal rather than a new payment. function buildDonationUnassignmentAdminNotice(payment) { const notice = buildPaymentAdminNotice(payment); const eventTitle = payment.registration?.event?.title || 'Event'; const payerName = payment.user?.name || '—'; const subject = `Donation unassigned: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`; return { ...notice, subject, html: notice.html .replace('Payment recorded', 'Donation unassigned') .replace('Internal notification', 'Internal notification — donation removed from registration') .replace('>Registration payment<', '>Donation unassigned<'), text: notice.text .replace('Payment recorded', 'Donation unassigned') .replace('Type: Registration payment', 'Type: Donation unassigned'), }; } // ─── Refund email ───────────────────────────────────────────────────────────── function buildRefundEmail(payment) { const org = getOrg(); const user = payment.user; const amt = Math.abs(payment.amount || 0); const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event'; const subject = `Refund processed – ${fmtAmount(amt)} for ${eventTitle}`; const preheader = `Your refund of ${fmtAmount(amt)} for ${eventTitle} has been processed.`; const body = `

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 ─────────────────────────────────────────────────────────── /** * 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, };