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