225 lines
7.2 KiB
JavaScript
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
|
|
};
|