Percentage, fixed-amount and comp codes, limited by event, ticket option or variant, dates, use counts, minimum quantity, and people, roles or an email domain. The discount is locked in on the registration (mirrored on Registration.discountTotal so every total sees it), shows on invoices, receipts, emails and the Yoco checkout, and is released on cancellation. Use limits are enforced under an advisory lock. Ticket options and variants can also carry a staff price that Staff and above get automatically. Adds a Supervisor > Coupon codes page, code fields on registration, My Events, manual registration, at the door and the kiosk, a Discounts report, and audit log entries. Also fixes Yoco line items and the free-registration check for lines spanning several price tranches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
/**
|
||
* 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 { couponDiscountLine } = require('./pdfDocs');
|
||
const coupon = couponDiscountLine(reg);
|
||
const items = [
|
||
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
|
||
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.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,
|
||
}; |