Files
invoice-system/src/services/pdf-service.js

225 lines
7.2 KiB
JavaScript

/**
* PDF Generation Service
* Handles HTML to PDF conversion using Puppeteer
*/
const path = require('path');
const fs = require('fs').promises;
const { formatMoney, formatDate } = require('../utils/helpers');
// Initialize browser - will be set from main app
let browserInstance = null;
function setBrowser(browser) {
browserInstance = browser;
}
async function getBrowser() {
return browserInstance;
}
/**
* Generate PDF from HTML template
*/
async function generatePdfFromHtml(html, options = {}) {
const {
format = 'Letter',
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
printBackground = true
} = options;
const browser = await getBrowser();
if (!browser) {
throw new Error('Browser not initialized');
}
const page = await browser.newPage();
try {
// Erhöhtes Timeout: 5 Sekunden sind unter Docker manchmal zu wenig.
// Besser auf 15 Sekunden (15000) setzen, um den Fehler von vornherein zu vermeiden.
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
const pdf = await page.pdf({
format,
printBackground,
margin
});
return pdf;
} finally {
// Dieser Block wird IMMER ausgeführt, selbst wenn oben ein Fehler fliegt.
// Der Tab wird also zu 100% wieder geschlossen.
if (page) {
await page.close();
}
}
}
/**
* Get company logo as base64 HTML
*/
async function getLogoHtml() {
let logoHTML = '';
try {
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (err) {
// No logo found
}
return logoHTML;
}
/**
* Render invoice items to HTML table rows
*/
function renderInvoiceItems(items, invoice = null) {
let itemsHTML = items.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>`;
}).join('');
// Add subtotal
const subtotal = invoice ? invoice.subtotal : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${formatMoney(subtotal)}</td>
</tr>`;
// Add tax if not exempt
if (invoice && !invoice.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
<td class="total-amount">$${formatMoney(invoice.tax_amount)}</td>
</tr>`;
}
// Add total
const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0;
const total = invoice ? parseFloat(invoice.total) : 0;
const balanceDue = total - amountPaid;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL:</td>
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>`;
// Add downpayment/balance if partial
// Add downpayment/balance if partial
if (amountPaid > 0) {
const isFullyPaid = balanceDue <= 0.01; // allow for rounding
const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:';
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="color: #059669;">${paymentLabel}</td>
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
</tr>`;
// Only show BALANCE DUE row if there's actually a remaining balance
if (!isFullyPaid) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
</tr>`;
}
}
// Thank you message
itemsHTML += `
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
return itemsHTML;
}
/**
* Render quote items to HTML table rows
*/
function renderQuoteItems(items, quote = null) {
let itemsHTML = items.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>`;
}).join('');
// Add subtotal
const subtotal = quote ? quote.subtotal : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal</td>
<td class="total-amount">$${formatMoney(subtotal)}</td>
</tr>`;
// Add tax if not exempt
if (quote && !quote.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%)</td>
<td class="total-amount">$${formatMoney(quote.tax_amount)}</td>
</tr>`;
}
// Add total
const total = quote ? quote.total : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL</td>
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>
<tr class="footer-row">
<td colspan="4" class="thank-you">This quote is valid for 14 days. We appreciate your business </td>
</tr>`;
return itemsHTML;
}
/**
* Format address lines for template
*/
function formatAddressLines(line1, line2, line3, line4, customerName) {
const addressLines = [];
if (line1 && line1.trim().toLowerCase() !== (customerName || '').trim().toLowerCase()) {
addressLines.push(line1);
}
if (line2) addressLines.push(line2);
if (line3) addressLines.push(line3);
if (line4) addressLines.push(line4);
return addressLines.join('<br>');
}
module.exports = {
setBrowser,
getBrowser,
generatePdfFromHtml,
getLogoHtml,
renderInvoiceItems,
renderQuoteItems,
formatAddressLines
};