Files
hope-events/backend/src/utils/waMessages.js
T
joshuaandClaude Sonnet 5 e9cb238ce1 Fix early-bird total in notifications; group dashboard registration items
Registration/payment/reminder emails and WhatsApp messages loaded
registrations without their price tranches, so any line spanning more
than one tranche fell back to charging the full quantity at the most
recent tranche's price, silently dropping the early-bird discount from
the outstanding balance and itemized amounts shown to the user.

The user dashboard's registration detail popup also listed one raw
line per tranche; it now merges same item/price/tier lines and groups
early-bird lines separately from standard-price ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:57:29 +02:00

313 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WhatsApp-formatted message builders.
*
* Styling reference: https://faq.whatsapp.com/539178204879377/
* *bold* _italic_ ~strikethrough~ ```monospace```
* > blockquote - unordered list 1. numbered list
* # Heading 1 ## Heading 2 ### Heading 3
*/
function fmtAmount(amt) {
return `R${Number(amt || 0).toFixed(2)}`;
}
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(/\/$/, ''),
};
}
// ─── Registration confirmation ────────────────────────────────────────────────
/**
* @param {object} reg - full registration from loadRegistrationFull
* @param {{ isNew?: boolean, balance?: number, totalDue?: number, totalPaid?: number }} opts
*/
function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } = {}) {
const org = getOrg();
const eventTitle = reg.event?.title || 'the event';
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
const name = reg.user?.name || 'there';
const heading = isNew ? '🎉 *Registration Confirmed!*' : '✏️ *Registration Updated*';
const intro = isNew
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const due = totalDue ?? 0;
const bal = balance ?? Math.max(due - paid, 0);
const finLine = bal <= 0
? `✅ *Fully paid — you're all set!*`
: `*Balance due:* ${fmtAmount(bal)}\n_Pay at ${org.url} or at the door._`;
return [
heading,
'',
`Hi ${name},`,
'',
intro,
'',
'*Your selections:*',
items || '—',
'',
`*Total:* ${fmtAmount(due)} *Paid:* ${fmtAmount(paid)}`,
finLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
function buildWAPayment(payment) {
const org = getOrg();
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const name = (payment.registration?.user || payment.user)?.name || 'there';
const amount = fmtAmount(payment.amount);
const reg = payment.registration;
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
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);
balLine = balance <= 0
? `\n✅ *Fully paid!* Your tickets have been sent.`
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
}
return [
`✅ *Payment Received*`,
'',
`Hi ${name},`,
'',
`We've received your payment of *${amount}* for *${eventTitle}*.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
const org = getOrg();
return [
`🔐 *New Login Detected*`,
'',
`Hi ${name || 'there'},`,
'',
`A new login to your *${org.name}* account was detected.`,
'',
`*Time:* ${when}`,
`*Location:* ${location}`,
`*Device:* ${userAgent}`,
'',
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
'',
`_If this was you, no action is needed._`,
].join('\n');
}
// ─── Welcome ──────────────────────────────────────────────────────────────────
function buildWAWelcome({ name, events }) {
const org = getOrg();
const hasEvents = Array.isArray(events) && events.length > 0;
const eventsBlock = hasEvents
? [
'*Upcoming events:*',
...events.map(e =>
`- *${e.title}* — ${new Date(e.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
),
].join('\n')
: 'Keep an eye on our website for upcoming events.';
return [
`🎉 *Welcome to ${org.name}!*`,
'',
`Hi ${name || 'there'},`,
'',
`Your account is set up and ready. Use it to register for events, manage your bookings, and access your tickets.`,
'',
eventsBlock,
'',
`${org.url}`,
].join('\n');
}
// ─── Account closed ───────────────────────────────────────────────────────────
function buildWAAccountClosed({ name, dataDeleted }) {
const org = getOrg();
const detail = dataDeleted
? 'All personal data associated with your account has been permanently deleted.'
: `Your account has been deactivated. To also delete your personal data, contact ${org.email}.`;
return [
`🔒 *Account Closed*`,
'',
`Hi ${name || 'there'},`,
'',
`Your *${org.name}* account has been successfully closed.`,
'',
detail,
'',
`_${org.name}_ | ${org.email}`,
].join('\n');
}
// ─── Ticket delivery caption ──────────────────────────────────────────────────
function buildWATicketCaption({ name, eventTitle, eventDate }) {
return [
`🎟️ *Your tickets for ${eventTitle}*`,
'',
`Hi ${name || 'there'}! Your tickets${eventDate ? ` for *${eventDate}*` : ''} are attached.`,
'Please show this PDF (printed or on your phone) at the event entrance.',
].join('\n');
}
// ─── Refund notification ──────────────────────────────────────────────────────
function buildWARefund(payment) {
const org = getOrg();
const user = payment.user;
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
const amt = fmtAmount(Math.abs(payment.amount || 0));
const name = user?.name || 'there';
return [
`💸 *Refund Processed*`,
'',
`Hi ${name},`,
'',
`A refund of *${amt}* for *${eventTitle}* has been processed.`,
'',
`Refunds may take a few business days to appear depending on your bank.`,
'',
`_${org.name}_ | ${org.email}`,
].join('\n');
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildWAPayment: sent to the REGISTRANT when staff apply someone else's
// donation to their registration — anonymous (no donor name), and never "payment received"
// wording since they didn't pay anything themselves.
function buildWADonationAppliedToRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const name = reg?.user?.name || 'there';
const amount = fmtAmount(payment.amount);
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
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);
balLine = balance <= 0
? `\n✅ *Fully paid!* Your tickets have been sent.`
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
}
return [
`🎁 *Donation Applied*`,
'',
`Hi ${name},`,
'',
`A donation of *${amount}* was applied to your registration for *${eventTitle}*.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
// the leg's original creation time, not "now"; use the current date for the balance display.
function buildWADonationUnassignedFromRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const name = reg?.user?.name || 'there';
const amount = fmtAmount(payment.amount);
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
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);
balLine = balance > 0
? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
: '';
}
return [
`⚠️ *Donation Removed*`,
'',
`Hi ${name},`,
'',
`A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
module.exports = {
buildWARegistration,
buildWAPayment,
buildWARefund,
buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};