Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown, early-bird discount, balance due, Yoco pay-now link/QR) whenever a balance is outstanding; payment/donation confirmations attach a payment receipt PDF. Sent as an email attachment and, over WhatsApp, as the PDF itself with the existing message as its caption. - Users can also (re)send either document on demand: an "Invoice" button on the registration detail popup, and a "Receipt" button next to each payment there and on the Payment history page, each opening an Email/WhatsApp choice popup, via two new endpoints restricted to the registration/payment's own owner. - Fix: editing an event option's early-bird tiers deleted and recreated every tier for that option with brand-new ids, silently severing the appliedTierId link on all historical purchases (losing early-bird attribution and undercounting stock-limit usage) even for tiers the admin didn't touch. Tiers are now upserted by id. - Update the "My Events" help content and the API docs index for the new endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -691,26 +691,49 @@ const updateEventOption = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
|
||||
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
||||
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
||||
// every tier for the option on every save, which silently reassigned each one a brand-new
|
||||
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
||||
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
||||
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
||||
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
||||
// ids stable across edits so that history stays linked.
|
||||
if (Array.isArray(earlyBirdTiers)) {
|
||||
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
|
||||
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const et of existingTiers) {
|
||||
if (incomingIds.includes(et.id)) continue;
|
||||
// Never delete a tier that's still attributed on past purchases — just leave it
|
||||
// orphaned from the option's active tier list rather than nulling out history.
|
||||
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
||||
if (usageCount === 0) {
|
||||
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
||||
const t = earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
await prisma.earlyBirdTier.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
}
|
||||
});
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
};
|
||||
if (t.id) {
|
||||
await prisma.earlyBirdTier.upsert({
|
||||
where: { id: t.id },
|
||||
update: data,
|
||||
create: { id: t.id, ...data },
|
||||
});
|
||||
} else {
|
||||
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1420,6 +1420,20 @@ const getPaymentStats = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// @desc Re-send the receipt PDF for one of the caller's own payments
|
||||
// @route POST /api/payments/:id/send-receipt
|
||||
// @access Private
|
||||
const sendReceipt = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.body || {};
|
||||
const { sendReceiptToUser } = require('../utils/notifications');
|
||||
await sendReceiptToUser(req.params.id, req.user.id, channel);
|
||||
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -1433,5 +1447,6 @@ module.exports = {
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
};
|
||||
@@ -1747,6 +1747,20 @@ const saveFormDraft = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send the current invoice PDF for one of the caller's own registrations
|
||||
// @route POST /api/registrations/:id/send-invoice
|
||||
// @access Private
|
||||
const sendInvoice = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.body || {};
|
||||
const { sendInvoiceToUser } = require('../utils/notifications');
|
||||
await sendInvoiceToUser(req.params.id, req.user.id, channel);
|
||||
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createRegistration,
|
||||
getRegistrations,
|
||||
@@ -1761,4 +1775,5 @@ module.exports = {
|
||||
replaceFormResponses,
|
||||
getFormDraft,
|
||||
saveFormDraft,
|
||||
sendInvoice,
|
||||
};
|
||||
@@ -529,6 +529,15 @@ app.get('/docs', async (req, res) => {
|
||||
pathParams:{ ':id':'Registration UUID' },
|
||||
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
|
||||
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
|
||||
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
|
||||
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
|
||||
pathParams:{ ':id':'Registration UUID' },
|
||||
request:{ body:{ channel:'email' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
|
||||
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
|
||||
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
@@ -546,6 +555,14 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||||
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
|
||||
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
|
||||
pathParams:{ ':id':'Payment UUID' },
|
||||
request:{ body:{ channel:'whatsapp' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
|
||||
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -21,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
@@ -72,7 +73,7 @@ async function loadRegistrationFull(registrationId) {
|
||||
return prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
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 } } } },
|
||||
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
registration: {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
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 } } } },
|
||||
@@ -898,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
|
||||
// ─── 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, waText } = require('./notify');
|
||||
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 }));
|
||||
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 checks canWhatsApp (preference + valid phone) internally
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
// 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) {
|
||||
@@ -925,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
|
||||
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, waText } = require('./notify');
|
||||
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 }));
|
||||
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' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
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 }));
|
||||
@@ -951,25 +1010,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
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, waText } = require('./notify');
|
||||
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 }));
|
||||
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' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
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 }));
|
||||
@@ -977,28 +1045,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
|
||||
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, waText, waTextAny } = require('./notify');
|
||||
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 }));
|
||||
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
|
||||
// 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(waText(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
|
||||
}
|
||||
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
|
||||
if (hasEvent) {
|
||||
@@ -1010,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails:', e);
|
||||
} finally {
|
||||
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,6 +1234,137 @@ async function sendDonationUnassignmentEmails(leg) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
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(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
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);
|
||||
@@ -1210,4 +1423,6 @@ module.exports = {
|
||||
sendDonationUnassignmentEmails,
|
||||
sendCheckInEmails,
|
||||
buildCheckInConfirmation,
|
||||
sendInvoiceToUser,
|
||||
sendReceiptToUser,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Branded PDF documents: payment receipts and registration invoices.
|
||||
*
|
||||
* Both are generated with pdfkit into backend/temp and returned as
|
||||
* { filePath, filename } for callers to attach to an email/WhatsApp send and
|
||||
* clean up afterwards (see notifications.js).
|
||||
*
|
||||
* Line items are built the same tranche-aware way the user dashboard renders
|
||||
* them (backend/src/utils/pricing.js) — merging tranches that share a
|
||||
* name/price/tier and separating early-bird lines from standard-price ones —
|
||||
* so the PDF total always matches computeRegistrationTotalDue().
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
return `R${Number(amt || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
|
||||
}
|
||||
|
||||
function formatMethod(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
if (!m) return '—';
|
||||
if (m === 'eft') return 'EFT';
|
||||
return m.replace(/^./, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function tempDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function docNumber(prefix, id, date) {
|
||||
const year = new Date(date || Date.now()).getFullYear();
|
||||
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
|
||||
return `${prefix}-${year}-${short}`;
|
||||
}
|
||||
|
||||
async function getBranding() {
|
||||
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_address', ''),
|
||||
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
getSetting('org_phone', ''),
|
||||
getSetting('primary_color', ''),
|
||||
getSetting('accent_color', ''),
|
||||
getSetting('logo_url', ''),
|
||||
]);
|
||||
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
let logoPath = null;
|
||||
if (logoUrl) {
|
||||
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(p)) logoPath = p;
|
||||
}
|
||||
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a registration's tranches into display rows, same grouping logic as
|
||||
* the user dashboard: one row per (name, unit price, early-bird flag), with
|
||||
* `basePrice` carried along so callers can work out the early-bird discount.
|
||||
*/
|
||||
function buildLineItems(registrationOptions) {
|
||||
const rows = [];
|
||||
for (const ro of (registrationOptions || [])) {
|
||||
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
|
||||
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
|
||||
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
|
||||
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
|
||||
? ro.tranches
|
||||
: [{
|
||||
quantity: ro.quantity,
|
||||
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
|
||||
appliedTierId: ro.appliedTierId,
|
||||
}];
|
||||
for (const t of tranches) {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
const isEarlyBird = !!t.appliedTierId;
|
||||
const key = `${label}__${isEarlyBird}__${unitPrice}`;
|
||||
let row = rows.find(r => r.key === key);
|
||||
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
|
||||
row.quantity += (t.quantity || 0);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
|
||||
const colDesc = x;
|
||||
// Fixed-width columns anchored to the right edge so amounts never wrap,
|
||||
// regardless of the overall table width (receipt vs. narrower invoice table).
|
||||
const totalColW = 75, priceColW = 65, qtyColW = 35;
|
||||
const colTotal = x + width - totalColW;
|
||||
const colPrice = colTotal - priceColW;
|
||||
const colQty = colPrice - qtyColW;
|
||||
const rowH = 22;
|
||||
|
||||
if (headerLight) {
|
||||
doc.rect(x, y, width, rowH).fill('#f8fafc');
|
||||
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
|
||||
} else {
|
||||
doc.rect(x, y, width, rowH).fill(brandColor);
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
|
||||
}
|
||||
doc.text('DESCRIPTION', colDesc + 8, y + 7);
|
||||
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
|
||||
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
|
||||
y += rowH;
|
||||
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#374151');
|
||||
for (const row of rows) {
|
||||
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
|
||||
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
|
||||
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
|
||||
y += rowH;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
|
||||
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateReceiptPdf(payment) {
|
||||
const org = await getBranding();
|
||||
const reg = payment.registration;
|
||||
const user = reg?.user || payment.user;
|
||||
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
|
||||
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
|
||||
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
|
||||
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
|
||||
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
|
||||
|
||||
const filename = `receipt-${receiptNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const marginX = 40;
|
||||
|
||||
// Header banner
|
||||
const bannerH = 150;
|
||||
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
|
||||
}
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
|
||||
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
|
||||
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
|
||||
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
|
||||
|
||||
const metaX = pageWidth - 250;
|
||||
const meta = [
|
||||
['Receipt No.', receiptNo],
|
||||
['Date', fmtDate(payment.createdAt)],
|
||||
['Payment Method', formatMethod(payment.method)],
|
||||
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
|
||||
];
|
||||
let metaY = 34;
|
||||
for (const [label, value] of meta) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
|
||||
metaY += 24;
|
||||
}
|
||||
|
||||
// Body
|
||||
let y = bannerH + 30;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
|
||||
y += 16;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
|
||||
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
|
||||
y += 14;
|
||||
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
|
||||
y += 30;
|
||||
|
||||
if (rows.length > 0) {
|
||||
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
|
||||
}
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
|
||||
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
|
||||
y += boxH + 30;
|
||||
|
||||
// Footer
|
||||
const footerH = 60;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
|
||||
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
// ─── Invoice ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
|
||||
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
|
||||
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
|
||||
const org = await getBranding();
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
|
||||
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(due - paid, 0);
|
||||
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
|
||||
const rows = buildLineItems(registration.registrationOptions);
|
||||
|
||||
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
|
||||
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
|
||||
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
|
||||
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
|
||||
const discount = Math.max(0, fullPriceTotal - chargedTotal);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const sidebarW = 190;
|
||||
const mainX = sidebarW + 30;
|
||||
const mainW = pageWidth - mainX - 40;
|
||||
|
||||
// Sidebar
|
||||
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
|
||||
let sy = 40;
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
|
||||
}
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
|
||||
sy += 60;
|
||||
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 28;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
|
||||
sy += 34;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
|
||||
sy += 40;
|
||||
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
|
||||
sy += 13;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 15;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 12;
|
||||
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
|
||||
|
||||
let by = pageHeight - 140;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
|
||||
by += 13;
|
||||
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
|
||||
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
|
||||
|
||||
// Main content
|
||||
let y = 40;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
|
||||
y += 14;
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
|
||||
y += 18;
|
||||
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
|
||||
y += 16;
|
||||
|
||||
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
|
||||
y += 16;
|
||||
|
||||
const totalsX = mainX + mainW - 220;
|
||||
const totalLine = (label, value, opts = {}) => {
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(label, totalsX, y, { width: 120 });
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
|
||||
y += (opts.size || 10) + 10;
|
||||
};
|
||||
if (discount > 0.01) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
|
||||
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
|
||||
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
|
||||
y += 20;
|
||||
|
||||
// Payment section
|
||||
if (balance > 0.01) {
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
|
||||
y += 16;
|
||||
if (paymentUrl) {
|
||||
const qrSize = 100;
|
||||
try {
|
||||
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
|
||||
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
|
||||
} catch {}
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
|
||||
y += 60;
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
|
||||
y += 24;
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
|
||||
y += 24;
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerH = 40;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
|
||||
Reference in New Issue
Block a user