2 * @file services/pdfGenerator.js
3 * @module services/pdfGenerator
4 * @description Server-side PDF generation service for invoices using PDFKit.
5 * Generates professional, template-based PDFs similar to Crystal Reports.
8 * - Professional invoice layout with company branding
9 * - GST/tax calculations and breakdown
10 * - Line items table with proper formatting
11 * - Automatic page breaks
12 * - Payment information section
13 * - Terms and conditions
20const PDFDocument = require('pdfkit');
21const fs = require('fs');
22const path = require('path');
24// Ensure PDFs directory exists
25const PDFS_DIR = path.join(__dirname, '../pdfs');
27 if (!fs.existsSync(PDFS_DIR)) {
28 console.log('[PDF Generator] Creating pdfs directory:', PDFS_DIR);
29 fs.mkdirSync(PDFS_DIR, { recursive: true });
30 console.log('[PDF Generator] Directory created successfully');
32 console.log('[PDF Generator] pdfs directory already exists:', PDFS_DIR);
35 console.error('[PDF Generator] Failed to create pdfs directory:', err);
36 console.error('[PDF Generator] Will attempt to use /tmp instead');
40 * Generate invoice PDF from template
41 * @param {Object} invoiceData - Complete invoice data including customer, items, company info
42 * @returns {Promise<string>} - Path to generated PDF file
44async function generateInvoicePDF(invoiceData) {
45 console.log('[PDF Generator] generateInvoicePDF called with:', {
46 hasInvoice: !!invoiceData?.invoice,
47 hasCustomer: !!invoiceData?.customer,
48 hasCompanyInfo: !!invoiceData?.companyInfo,
49 itemsCount: invoiceData?.items?.length || 0
52 return new Promise((resolve, reject) => {
61 console.log('[PDF Generator] Parsed invoice data:', {
62 invoiceId: invoice?.invoice_id,
63 customerId: customer?.customer_id,
64 companyName: companyInfo?.name
68 const filename = `invoice-${invoice.invoice_id}-${Date.now()}.pdf`;
69 const filepath = path.join(PDFS_DIR, filename);
71 console.log('[PDF Generator] Will write PDF to:', filepath);
73 // Create PDF document
74 console.log('[PDF Generator] Creating PDFDocument...');
75 const doc = new PDFDocument({
79 Title: `Invoice #${invoice.invoice_id}`,
80 Author: companyInfo.name || 'Independent Business Group',
81 Subject: `Tax Invoice for ${customer?.name || 'Customer'}`,
82 Keywords: 'invoice, tax invoice, gst',
87 const stream = fs.createWriteStream(filepath);
91 const formatCurrency = (amount, currency = 'AUD') => {
92 return `${currency} $${parseFloat(amount || 0).toFixed(2)}`;
95 const formatDate = (dateStr) => {
96 if (!dateStr) return '-';
97 return new Date(dateStr).toLocaleDateString('en-AU', {
105 const primaryColor = '#2563eb';
106 const darkGray = '#333333';
107 const mediumGray = '#666666';
108 const lightGray = '#999999';
109 const borderColor = '#dddddd';
112 const pageWidth = doc.page.width;
113 const pageHeight = doc.page.height;
114 const contentWidth = pageWidth - 100; // 50px margin on each side
116 // ===== HEADER SECTION =====
122 .font('Helvetica-Bold')
123 .text('TAX INVOICE', 50, yPosition, { align: 'center' });
129 .fillColor(mediumGray)
131 .text(`Invoice #${String(invoice.invoice_id).padStart(5, '0')}`, 50, yPosition, { align: 'center' });
136 const statusColors = {
137 'paid': { bg: '#d4edda', text: '#155724' },
138 'sent': { bg: '#fff3cd', text: '#856404' },
139 'draft': { bg: '#f8d7da', text: '#721c24' },
140 'void': { bg: '#e2e3e5', text: '#383d41' }
142 const statusColor = statusColors[invoice.status] || statusColors.draft;
143 const statusText = (invoice.payment_status === 'paid' ? 'PAID' : invoice.status?.toUpperCase()) || 'DRAFT';
145 const statusWidth = doc.widthOfString(statusText) + 30;
146 const statusX = (pageWidth - statusWidth) / 2;
148 doc.roundedRect(statusX, yPosition, statusWidth, 24, 12)
149 .fillAndStroke(statusColor.bg, statusColor.bg);
152 .fillColor(statusColor.text)
153 .font('Helvetica-Bold')
154 .text(statusText, statusX, yPosition + 7, { width: statusWidth, align: 'center' });
159 doc.moveTo(50, yPosition)
160 .lineTo(pageWidth - 50, yPosition)
161 .strokeColor(darkGray)
167 // ===== FROM/BILL TO SECTION =====
168 const columnWidth = (contentWidth - 20) / 2;
171 doc.rect(50, yPosition, columnWidth, 140)
172 .fillAndStroke('#f9f9f9', borderColor);
176 .font('Helvetica-Bold')
177 .text('FROM:', 60, yPosition + 10);
180 .font('Helvetica-Bold')
181 .text(companyInfo.name || 'Independent Business Group', 60, yPosition + 30, { width: columnWidth - 20 });
185 .fillColor(mediumGray);
187 let fromY = yPosition + 50;
188 if (companyInfo.abn) {
189 doc.text(`ABN: ${companyInfo.abn}`, 60, fromY);
192 if (companyInfo.address) {
193 doc.text(companyInfo.address, 60, fromY, { width: columnWidth - 20 });
196 if (companyInfo.phone) {
197 doc.text(`Phone: ${companyInfo.phone}`, 60, fromY);
200 if (companyInfo.email) {
201 doc.text(`Email: ${companyInfo.email}`, 60, fromY);
205 const billToX = 50 + columnWidth + 20;
206 doc.rect(billToX, yPosition, columnWidth, 140)
207 .stroke(borderColor);
211 .font('Helvetica-Bold')
212 .text('BILL TO:', billToX + 10, yPosition + 10);
216 .font('Helvetica-Bold')
217 .text(customer.name, billToX + 10, yPosition + 30, { width: columnWidth - 20 });
221 .fillColor(mediumGray);
223 let billToY = yPosition + 50;
225 doc.text(`ABN: ${customer.abn}`, billToX + 10, billToY);
228 if (customer.billing_address) {
229 doc.text(customer.billing_address, billToX + 10, billToY, { width: columnWidth - 20 });
232 if (customer.email) {
233 doc.text(`Email: ${customer.email}`, billToX + 10, billToY);
236 if (customer.phone) {
237 doc.text(`Phone: ${customer.phone}`, billToX + 10, billToY);
241 .fillColor(mediumGray)
242 .text(`Customer ID: ${invoice.customer_id}`, billToX + 10, yPosition + 50);
247 // ===== INVOICE DETAILS BAR =====
248 doc.rect(50, yPosition, contentWidth, 60)
249 .fillAndStroke('#f5f5f5', borderColor);
251 const detailsColumnWidth = contentWidth / 3;
255 .fillColor(lightGray)
257 .text('INVOICE DATE:', 60, yPosition + 12);
260 .font('Helvetica-Bold')
261 .text(formatDate(invoice.issued_date), 60, yPosition + 26);
265 .fillColor(lightGray)
267 .text('DUE DATE:', 60 + detailsColumnWidth, yPosition + 12);
270 .font('Helvetica-Bold')
271 .text(formatDate(invoice.due_date), 60 + detailsColumnWidth, yPosition + 26);
275 .fillColor(lightGray)
277 .text('PAYMENT STATUS:', 60 + detailsColumnWidth * 2, yPosition + 12);
279 .fillColor(invoice.payment_status === 'paid' ? '#155724' : '#856404')
280 .font('Helvetica-Bold')
281 .text((invoice.payment_status || 'unpaid').toUpperCase(), 60 + detailsColumnWidth * 2, yPosition + 26);
285 // ===== LINE ITEMS TABLE =====
286 if (invoice.description) {
288 .fillColor(mediumGray)
289 .font('Helvetica-Oblique')
290 .text(`Description: ${invoice.description}`, 50, yPosition, { width: contentWidth });
295 const tableTop = yPosition;
297 // Column positions - properly spaced to avoid overlap
298 const colDescription = 50;
300 const colPrice = 325;
301 const colSubtotal = 390;
303 const colTotal = 505;
305 doc.rect(50, tableTop, contentWidth, 30)
306 .fillAndStroke(darkGray, darkGray);
310 .font('Helvetica-Bold')
311 .text('DESCRIPTION', colDescription + 5, tableTop + 10, { width: 215 })
312 .text('QTY', colQty, tableTop + 10, { width: 35, align: 'center' })
313 .text('PRICE', colPrice, tableTop + 10, { width: 60, align: 'right' })
314 .text('SUBTOTAL', colSubtotal, tableTop + 10, { width: 60, align: 'right' })
315 .text('GST', colGST, tableTop + 10, { width: 45, align: 'right' })
316 .text('TOTAL', colTotal, tableTop + 10, { width: 40, align: 'right' });
318 yPosition = tableTop + 35;
321 items.forEach((item, index) => {
322 const rowHeight = 25;
323 const bgColor = index % 2 === 0 ? '#ffffff' : '#f9f9f9';
325 doc.rect(50, yPosition, contentWidth, rowHeight)
328 const subtotal = parseFloat(item.total || (item.quantity * item.unit_price) || 0);
329 const gstAmount = subtotal * 0.10;
330 const lineTotal = subtotal + gstAmount;
335 .text(item.description || '', colDescription + 5, yPosition + 8, { width: 215, ellipsis: true })
336 .text(item.quantity.toString(), colQty, yPosition + 8, { width: 35, align: 'center' })
337 .text(`$${parseFloat(item.unit_price).toFixed(2)}`, colPrice, yPosition + 8, { width: 60, align: 'right' })
338 .text(`$${subtotal.toFixed(2)}`, colSubtotal, yPosition + 8, { width: 60, align: 'right' })
339 .text(`$${gstAmount.toFixed(2)}`, colGST, yPosition + 8, { width: 45, align: 'right' })
340 .text(`$${lineTotal.toFixed(2)}`, colTotal, yPosition + 8, { width: 40, align: 'right' });
342 yPosition += rowHeight;
344 // Check if we need a new page - only if we have more than 250px of content remaining
345 if (yPosition > pageHeight - 250) {
351 // Table bottom border
352 doc.moveTo(50, yPosition)
353 .lineTo(pageWidth - 50, yPosition)
354 .strokeColor(borderColor)
359 // ===== TOTALS SECTION =====
360 const totalsX = pageWidth - 230;
361 const totalsWidth = 180;
364 doc.rect(totalsX, yPosition, totalsWidth, 25)
365 .fillAndStroke('#f9f9f9', borderColor);
368 .font('Helvetica-Bold')
369 .text('Subtotal (ex GST):', totalsX + 10, yPosition + 8)
370 .text(formatCurrency(invoice.subtotal, invoice.currency), totalsX + 10, yPosition + 8, { width: totalsWidth - 20, align: 'right' });
375 doc.rect(totalsX, yPosition, totalsWidth, 25)
376 .fillAndStroke('#f9f9f9', borderColor);
377 doc.text(`GST (${parseFloat(invoice.tax_rate || 10).toFixed(0)}%):`, totalsX + 10, yPosition + 8)
378 .text(formatCurrency(invoice.tax_amount, invoice.currency), totalsX + 10, yPosition + 8, { width: totalsWidth - 20, align: 'right' });
383 doc.rect(totalsX, yPosition, totalsWidth, 35)
384 .fillAndStroke(darkGray, darkGray);
387 .font('Helvetica-Bold')
388 .text('TOTAL (inc GST):', totalsX + 10, yPosition + 11)
389 .text(formatCurrency(invoice.total, invoice.currency), totalsX + 10, yPosition + 11, { width: totalsWidth - 20, align: 'right' });
393 // ===== PAYMENT INFORMATION =====
394 if (invoice.payment_status === 'paid' && invoice.payment_date) {
395 doc.rect(50, yPosition, contentWidth, 60)
396 .fillAndStroke('#d4edda', '#28a745');
399 .fillColor('#155724')
400 .font('Helvetica-Bold')
401 .text('PAYMENT RECEIVED', 60, yPosition + 10);
405 .text(`Paid on: ${formatDate(invoice.payment_date)}`, 60, yPosition + 28);
407 if (invoice.payment_method) {
408 doc.text(`Method: ${invoice.payment_method}`, 60, yPosition + 42);
414 // ===== FOOTER / TERMS =====
415 if (yPosition > pageHeight - 150) {
421 .fillColor(lightGray)
422 .font('Helvetica-Oblique')
423 .text('Terms & Conditions: Payment is due within 30 days. Please include invoice number with payment.', 50, yPosition, {
431 const pageCount = doc.bufferedPageRange().count;
432 for (let i = 0; i < pageCount; i++) {
435 .fillColor(lightGray)
437 `Page ${i + 1} of ${pageCount}`,
439 doc.page.height - 50,
440 { align: 'center', width: doc.page.width - 100 }
447 stream.on('finish', () => {
448 console.log(`[PDF Generator] Generated invoice PDF: ${filename}`);
452 stream.on('error', (err) => {
453 console.error('[PDF Generator] Error writing PDF:', err);
458 console.error('[PDF Generator] Error generating PDF:', error);
465 * Delete old invoice PDFs (cleanup)
466 * @param {number} invoiceId - Invoice ID to clean up PDFs for
468async function deleteInvoicePDFs(invoiceId) {
469 const pattern = `invoice-${invoiceId}-`;
470 const files = fs.readdirSync(PDFS_DIR);
472 files.forEach(file => {
473 if (file.startsWith(pattern)) {
474 const filepath = path.join(PDFS_DIR, file);
475 fs.unlinkSync(filepath);
476 console.log(`[PDF Generator] Deleted old PDF: ${file}`);