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>
391 lines
19 KiB
JavaScript
391 lines
19 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
|
||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
|
||
* Registration.discountTotal less the bundle part, both always present.
|
||
*/
|
||
function couponDiscountLine(registration) {
|
||
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
|
||
if (!(amount > 0.001)) return null;
|
||
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
|
||
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
|
||
}
|
||
|
||
/**
|
||
* The registration's bundle-deal saving, labelled with the bundle names when
|
||
* `bundleApplications[].bundle.name` is loaded, e.g. "Bundle: Family deal ×2".
|
||
*/
|
||
function bundleDiscountLine(registration) {
|
||
const amount = Number(registration?.bundleDiscount || 0);
|
||
if (!(amount > 0.001)) return null;
|
||
const names = (registration.bundleApplications || [])
|
||
.map(a => a.bundle?.name ? `${a.bundle.name}${a.count > 1 ? ` ×${a.count}` : ''}` : null)
|
||
.filter(Boolean);
|
||
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
|
||
}
|
||
|
||
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
|
||
function discountLines(registration) {
|
||
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
|
||
}
|
||
|
||
/** 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 });
|
||
}
|
||
for (const line of discountLines(reg)) {
|
||
y += 8;
|
||
doc.font('Helvetica').fontSize(10).fillColor('#059669')
|
||
.text(`${line.label}: -${fmtAmount(line.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||
y += 6;
|
||
}
|
||
if (discountLines(reg).length > 0) y += 8;
|
||
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 extraDiscounts = discountLines(registration);
|
||
|
||
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 || extraDiscounts.length > 0) {
|
||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||
if (discount > 0.01) totalLine(extraDiscounts.length ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||
for (const line of extraDiscounts) totalLine(line.label.toUpperCase(), `-${fmtAmount(line.amount)}`, { 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, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
|