EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
pdfGenerator.js
Go to the documentation of this file.
1/**
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.
6 *
7 * Features:
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
14 *
15 * @requires pdfkit
16 * @requires fs
17 * @requires path
18 */
19
20const PDFDocument = require('pdfkit');
21const fs = require('fs');
22const path = require('path');
23
24// Ensure PDFs directory exists
25const PDFS_DIR = path.join(__dirname, '../pdfs');
26try {
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');
31 } else {
32 console.log('[PDF Generator] pdfs directory already exists:', PDFS_DIR);
33 }
34} catch (err) {
35 console.error('[PDF Generator] Failed to create pdfs directory:', err);
36 console.error('[PDF Generator] Will attempt to use /tmp instead');
37}
38
39/**
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
43 */
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
50 });
51
52 return new Promise((resolve, reject) => {
53 try {
54 const {
55 invoice,
56 customer,
57 companyInfo,
58 items = []
59 } = invoiceData;
60
61 console.log('[PDF Generator] Parsed invoice data:', {
62 invoiceId: invoice?.invoice_id,
63 customerId: customer?.customer_id,
64 companyName: companyInfo?.name
65 });
66
67 // Generate filename
68 const filename = `invoice-${invoice.invoice_id}-${Date.now()}.pdf`;
69 const filepath = path.join(PDFS_DIR, filename);
70
71 console.log('[PDF Generator] Will write PDF to:', filepath);
72
73 // Create PDF document
74 console.log('[PDF Generator] Creating PDFDocument...');
75 const doc = new PDFDocument({
76 size: 'A4',
77 margin: 50,
78 info: {
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',
83 }
84 });
85
86 // Pipe to file
87 const stream = fs.createWriteStream(filepath);
88 doc.pipe(stream);
89
90 // Helper functions
91 const formatCurrency = (amount, currency = 'AUD') => {
92 return `${currency} $${parseFloat(amount || 0).toFixed(2)}`;
93 };
94
95 const formatDate = (dateStr) => {
96 if (!dateStr) return '-';
97 return new Date(dateStr).toLocaleDateString('en-AU', {
98 year: 'numeric',
99 month: 'long',
100 day: 'numeric'
101 });
102 };
103
104 // Define colors
105 const primaryColor = '#2563eb';
106 const darkGray = '#333333';
107 const mediumGray = '#666666';
108 const lightGray = '#999999';
109 const borderColor = '#dddddd';
110
111 // Page dimensions
112 const pageWidth = doc.page.width;
113 const pageHeight = doc.page.height;
114 const contentWidth = pageWidth - 100; // 50px margin on each side
115
116 // ===== HEADER SECTION =====
117 let yPosition = 50;
118
119 // TAX INVOICE title
120 doc.fontSize(28)
121 .fillColor(darkGray)
122 .font('Helvetica-Bold')
123 .text('TAX INVOICE', 50, yPosition, { align: 'center' });
124
125 yPosition += 40;
126
127 // Invoice number
128 doc.fontSize(16)
129 .fillColor(mediumGray)
130 .font('Helvetica')
131 .text(`Invoice #${String(invoice.invoice_id).padStart(5, '0')}`, 50, yPosition, { align: 'center' });
132
133 yPosition += 30;
134
135 // Status badge
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' }
141 };
142 const statusColor = statusColors[invoice.status] || statusColors.draft;
143 const statusText = (invoice.payment_status === 'paid' ? 'PAID' : invoice.status?.toUpperCase()) || 'DRAFT';
144
145 const statusWidth = doc.widthOfString(statusText) + 30;
146 const statusX = (pageWidth - statusWidth) / 2;
147
148 doc.roundedRect(statusX, yPosition, statusWidth, 24, 12)
149 .fillAndStroke(statusColor.bg, statusColor.bg);
150
151 doc.fontSize(11)
152 .fillColor(statusColor.text)
153 .font('Helvetica-Bold')
154 .text(statusText, statusX, yPosition + 7, { width: statusWidth, align: 'center' });
155
156 yPosition += 50;
157
158 // Horizontal line
159 doc.moveTo(50, yPosition)
160 .lineTo(pageWidth - 50, yPosition)
161 .strokeColor(darkGray)
162 .lineWidth(3)
163 .stroke();
164
165 yPosition += 30;
166
167 // ===== FROM/BILL TO SECTION =====
168 const columnWidth = (contentWidth - 20) / 2;
169
170 // FROM box
171 doc.rect(50, yPosition, columnWidth, 140)
172 .fillAndStroke('#f9f9f9', borderColor);
173
174 doc.fontSize(12)
175 .fillColor(darkGray)
176 .font('Helvetica-Bold')
177 .text('FROM:', 60, yPosition + 10);
178
179 doc.fontSize(11)
180 .font('Helvetica-Bold')
181 .text(companyInfo.name || 'Independent Business Group', 60, yPosition + 30, { width: columnWidth - 20 });
182
183 doc.fontSize(9)
184 .font('Helvetica')
185 .fillColor(mediumGray);
186
187 let fromY = yPosition + 50;
188 if (companyInfo.abn) {
189 doc.text(`ABN: ${companyInfo.abn}`, 60, fromY);
190 fromY += 15;
191 }
192 if (companyInfo.address) {
193 doc.text(companyInfo.address, 60, fromY, { width: columnWidth - 20 });
194 fromY += 15;
195 }
196 if (companyInfo.phone) {
197 doc.text(`Phone: ${companyInfo.phone}`, 60, fromY);
198 fromY += 15;
199 }
200 if (companyInfo.email) {
201 doc.text(`Email: ${companyInfo.email}`, 60, fromY);
202 }
203
204 // BILL TO box
205 const billToX = 50 + columnWidth + 20;
206 doc.rect(billToX, yPosition, columnWidth, 140)
207 .stroke(borderColor);
208
209 doc.fontSize(12)
210 .fillColor(darkGray)
211 .font('Helvetica-Bold')
212 .text('BILL TO:', billToX + 10, yPosition + 10);
213
214 if (customer) {
215 doc.fontSize(11)
216 .font('Helvetica-Bold')
217 .text(customer.name, billToX + 10, yPosition + 30, { width: columnWidth - 20 });
218
219 doc.fontSize(9)
220 .font('Helvetica')
221 .fillColor(mediumGray);
222
223 let billToY = yPosition + 50;
224 if (customer.abn) {
225 doc.text(`ABN: ${customer.abn}`, billToX + 10, billToY);
226 billToY += 15;
227 }
228 if (customer.billing_address) {
229 doc.text(customer.billing_address, billToX + 10, billToY, { width: columnWidth - 20 });
230 billToY += 15;
231 }
232 if (customer.email) {
233 doc.text(`Email: ${customer.email}`, billToX + 10, billToY);
234 billToY += 15;
235 }
236 if (customer.phone) {
237 doc.text(`Phone: ${customer.phone}`, billToX + 10, billToY);
238 }
239 } else {
240 doc.fontSize(9)
241 .fillColor(mediumGray)
242 .text(`Customer ID: ${invoice.customer_id}`, billToX + 10, yPosition + 50);
243 }
244
245 yPosition += 170;
246
247 // ===== INVOICE DETAILS BAR =====
248 doc.rect(50, yPosition, contentWidth, 60)
249 .fillAndStroke('#f5f5f5', borderColor);
250
251 const detailsColumnWidth = contentWidth / 3;
252
253 // Invoice Date
254 doc.fontSize(8)
255 .fillColor(lightGray)
256 .font('Helvetica')
257 .text('INVOICE DATE:', 60, yPosition + 12);
258 doc.fontSize(10)
259 .fillColor(darkGray)
260 .font('Helvetica-Bold')
261 .text(formatDate(invoice.issued_date), 60, yPosition + 26);
262
263 // Due Date
264 doc.fontSize(8)
265 .fillColor(lightGray)
266 .font('Helvetica')
267 .text('DUE DATE:', 60 + detailsColumnWidth, yPosition + 12);
268 doc.fontSize(10)
269 .fillColor(darkGray)
270 .font('Helvetica-Bold')
271 .text(formatDate(invoice.due_date), 60 + detailsColumnWidth, yPosition + 26);
272
273 // Payment Status
274 doc.fontSize(8)
275 .fillColor(lightGray)
276 .font('Helvetica')
277 .text('PAYMENT STATUS:', 60 + detailsColumnWidth * 2, yPosition + 12);
278 doc.fontSize(10)
279 .fillColor(invoice.payment_status === 'paid' ? '#155724' : '#856404')
280 .font('Helvetica-Bold')
281 .text((invoice.payment_status || 'unpaid').toUpperCase(), 60 + detailsColumnWidth * 2, yPosition + 26);
282
283 yPosition += 80;
284
285 // ===== LINE ITEMS TABLE =====
286 if (invoice.description) {
287 doc.fontSize(10)
288 .fillColor(mediumGray)
289 .font('Helvetica-Oblique')
290 .text(`Description: ${invoice.description}`, 50, yPosition, { width: contentWidth });
291 yPosition += 30;
292 }
293
294 // Table header
295 const tableTop = yPosition;
296
297 // Column positions - properly spaced to avoid overlap
298 const colDescription = 50;
299 const colQty = 280;
300 const colPrice = 325;
301 const colSubtotal = 390;
302 const colGST = 455;
303 const colTotal = 505;
304
305 doc.rect(50, tableTop, contentWidth, 30)
306 .fillAndStroke(darkGray, darkGray);
307
308 doc.fontSize(9)
309 .fillColor('white')
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' });
317
318 yPosition = tableTop + 35;
319
320 // Table rows
321 items.forEach((item, index) => {
322 const rowHeight = 25;
323 const bgColor = index % 2 === 0 ? '#ffffff' : '#f9f9f9';
324
325 doc.rect(50, yPosition, contentWidth, rowHeight)
326 .fill(bgColor);
327
328 const subtotal = parseFloat(item.total || (item.quantity * item.unit_price) || 0);
329 const gstAmount = subtotal * 0.10;
330 const lineTotal = subtotal + gstAmount;
331
332 doc.fontSize(9)
333 .fillColor(darkGray)
334 .font('Helvetica')
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' });
341
342 yPosition += rowHeight;
343
344 // Check if we need a new page - only if we have more than 250px of content remaining
345 if (yPosition > pageHeight - 250) {
346 doc.addPage();
347 yPosition = 50;
348 }
349 });
350
351 // Table bottom border
352 doc.moveTo(50, yPosition)
353 .lineTo(pageWidth - 50, yPosition)
354 .strokeColor(borderColor)
355 .stroke();
356
357 yPosition += 30;
358
359 // ===== TOTALS SECTION =====
360 const totalsX = pageWidth - 230;
361 const totalsWidth = 180;
362
363 // Subtotal
364 doc.rect(totalsX, yPosition, totalsWidth, 25)
365 .fillAndStroke('#f9f9f9', borderColor);
366 doc.fontSize(10)
367 .fillColor(darkGray)
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' });
371
372 yPosition += 25;
373
374 // GST
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' });
379
380 yPosition += 25;
381
382 // Total
383 doc.rect(totalsX, yPosition, totalsWidth, 35)
384 .fillAndStroke(darkGray, darkGray);
385 doc.fontSize(12)
386 .fillColor('white')
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' });
390
391 yPosition += 60;
392
393 // ===== PAYMENT INFORMATION =====
394 if (invoice.payment_status === 'paid' && invoice.payment_date) {
395 doc.rect(50, yPosition, contentWidth, 60)
396 .fillAndStroke('#d4edda', '#28a745');
397
398 doc.fontSize(11)
399 .fillColor('#155724')
400 .font('Helvetica-Bold')
401 .text('PAYMENT RECEIVED', 60, yPosition + 10);
402
403 doc.fontSize(9)
404 .font('Helvetica')
405 .text(`Paid on: ${formatDate(invoice.payment_date)}`, 60, yPosition + 28);
406
407 if (invoice.payment_method) {
408 doc.text(`Method: ${invoice.payment_method}`, 60, yPosition + 42);
409 }
410
411 yPosition += 80;
412 }
413
414 // ===== FOOTER / TERMS =====
415 if (yPosition > pageHeight - 150) {
416 doc.addPage();
417 yPosition = 50;
418 }
419
420 doc.fontSize(8)
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, {
424 width: contentWidth,
425 align: 'center'
426 });
427
428 yPosition += 30;
429
430 // Page numbers
431 const pageCount = doc.bufferedPageRange().count;
432 for (let i = 0; i < pageCount; i++) {
433 doc.switchToPage(i);
434 doc.fontSize(8)
435 .fillColor(lightGray)
436 .text(
437 `Page ${i + 1} of ${pageCount}`,
438 50,
439 doc.page.height - 50,
440 { align: 'center', width: doc.page.width - 100 }
441 );
442 }
443
444 // Finalize PDF
445 doc.end();
446
447 stream.on('finish', () => {
448 console.log(`[PDF Generator] Generated invoice PDF: ${filename}`);
449 resolve(filepath);
450 });
451
452 stream.on('error', (err) => {
453 console.error('[PDF Generator] Error writing PDF:', err);
454 reject(err);
455 });
456
457 } catch (error) {
458 console.error('[PDF Generator] Error generating PDF:', error);
459 reject(error);
460 }
461 });
462}
463
464/**
465 * Delete old invoice PDFs (cleanup)
466 * @param {number} invoiceId - Invoice ID to clean up PDFs for
467 */
468async function deleteInvoicePDFs(invoiceId) {
469 const pattern = `invoice-${invoiceId}-`;
470 const files = fs.readdirSync(PDFS_DIR);
471
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}`);
477 }
478 });
479}
480
481module.exports = {
482 generateInvoicePDF,
483 deleteInvoicePDFs,
484 PDFS_DIR
485};