Files
hope-events/backend/src/utils/waMessages.js
T
joshuaandClaude Opus 5.5 763b66ff56 Add bundle deals
Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00

335 lines
12 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 { discountLines } = require('./pdfDocs');
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...discountLines(reg).map(d => `- ${d.label} — -${fmtAmount(d.amount)}`),
].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');
}
// ─── Two-factor code ──────────────────────────────────────────────────────────
function buildWATwoFactorCode({ name, code }) {
const org = getOrg();
return [
`🔑 *Your verification code*`,
'',
`Hi ${name || 'there'},`,
'',
`Your *${org.name}* verification code is:`,
'',
`*${code}*`,
'',
`_It expires in 10 minutes. Never share this code with anyone._`,
'',
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
].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,
buildWATwoFactorCode,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};