const express = require('express'); const { Pool } = require('pg'); const path = require('path'); const puppeteer = require('puppeteer'); const fs = require('fs').promises; const multer = require('multer'); const app = express(); const PORT = process.env.PORT || 3000; // Global browser instance let browser = null; // Initialize browser on startup async function initBrowser() { if (!browser) { console.log('[BROWSER] Launching persistent browser...'); browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--no-zygote', '--single-process' ], protocolTimeout: 180000, timeout: 180000 }); console.log('[BROWSER] Browser launched and ready'); // Restart browser if it crashes browser.on('disconnected', () => { console.log('[BROWSER] Browser disconnected, restarting...'); browser = null; initBrowser(); }); } return browser; } // Database connection const pool = new Pool({ user: process.env.DB_USER || 'postgres', host: process.env.DB_HOST || 'localhost', database: process.env.DB_NAME || 'quotes_db', password: process.env.DB_PASSWORD || 'postgres', port: process.env.DB_PORT || 5432, }); // Middleware app.use(express.json()); app.use(express.static('public')); // Configure multer for logo upload const storage = multer.diskStorage({ destination: async (req, file, cb) => { const uploadDir = path.join(__dirname, 'public', 'uploads'); try { await fs.mkdir(uploadDir, { recursive: true }); } catch (err) { console.error('Error creating upload directory:', err); } cb(null, uploadDir); }, filename: (req, file, cb) => { cb(null, 'company-logo.png'); } }); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed')); } } }); // Helper functions function formatDate(date) { const d = new Date(date); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const year = d.getFullYear(); return `${month}/${day}/${year}`; } async function getNextQuoteNumber() { const year = new Date().getFullYear(); const result = await pool.query( 'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1', [`${year}-%`] ); if (result.rows.length === 0) { return `${year}-001`; } const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]); const nextNumber = String(lastNumber + 1).padStart(3, '0'); return `${year}-${nextNumber}`; } async function getNextInvoiceNumber() { const result = await pool.query( 'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\'' ); if (result.rows.length === 0 || result.rows[0].max_number === null) { return '110508'; } return String(parseInt(result.rows[0].max_number) + 1); } // Logo endpoints app.get('/api/logo-info', async (req, res) => { try { const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png'); try { await fs.access(logoPath); res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' }); } catch { res.json({ hasLogo: false }); } } catch (error) { console.error('Error checking logo:', error); res.status(500).json({ error: 'Error checking logo' }); } }); app.post('/api/upload-logo', upload.single('logo'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } res.json({ message: 'Logo uploaded successfully', path: '/uploads/company-logo.png' }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: 'Error uploading logo' }); } }); // Customer endpoints app.get('/api/customers', async (req, res) => { try { const result = await pool.query('SELECT * FROM customers ORDER BY name'); res.json(result.rows); } catch (error) { console.error('Error fetching customers:', error); res.status(500).json({ error: 'Error fetching customers' }); } }); app.post('/api/customers', async (req, res) => { const { name, street, city, state, zip_code, account_number } = req.body; try { const result = await pool.query( 'INSERT INTO customers (name, street, city, state, zip_code, account_number) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', [name, street, city, state, zip_code, account_number] ); res.json(result.rows[0]); } catch (error) { console.error('Error creating customer:', error); res.status(500).json({ error: 'Error creating customer' }); } }); app.put('/api/customers/:id', async (req, res) => { const { id } = req.params; const { name, street, city, state, zip_code, account_number } = req.body; try { const result = await pool.query( 'UPDATE customers SET name = $1, street = $2, city = $3, state = $4, zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $7 RETURNING *', [name, street, city, state, zip_code, account_number, id] ); res.json(result.rows[0]); } catch (error) { console.error('Error updating customer:', error); res.status(500).json({ error: 'Error updating customer' }); } }); app.delete('/api/customers/:id', async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM customers WHERE id = $1', [id]); res.json({ success: true }); } catch (error) { console.error('Error deleting customer:', error); res.status(500).json({ error: 'Error deleting customer' }); } }); // Quote endpoints app.get('/api/quotes', async (req, res) => { try { const result = await pool.query(` SELECT q.*, c.name as customer_name FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id ORDER BY q.created_at DESC `); res.json(result.rows); } catch (error) { console.error('Error fetching quotes:', error); res.status(500).json({ error: 'Error fetching quotes' }); } }); app.get('/api/quotes/:id', async (req, res) => { const { id } = req.params; try { const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); res.json({ quote: quoteResult.rows[0], items: itemsResult.rows }); } catch (error) { console.error('Error fetching quote:', error); res.status(500).json({ error: 'Error fetching quote' }); } }); app.post('/api/quotes', async (req, res) => { const { customer_id, quote_date, tax_exempt, items } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); const quote_number = await getNextQuoteNumber(); let subtotal = 0; let has_tbd = false; for (const item of items) { if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { has_tbd = true; } else { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; const quoteResult = await client.query( `INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd] ); const quoteId = quoteResult.rows[0].id; for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', [quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] ); } await client.query('COMMIT'); res.json(quoteResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating quote:', error); res.status(500).json({ error: 'Error creating quote' }); } finally { client.release(); } }); app.put('/api/quotes/:id', async (req, res) => { const { id } = req.params; const { customer_id, quote_date, tax_exempt, items } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); let subtotal = 0; let has_tbd = false; for (const item of items) { if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { has_tbd = true; } else { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; await client.query( `UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4, subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $9`, [customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id] ); await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]); for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] ); } await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error updating quote:', error); res.status(500).json({ error: 'Error updating quote' }); } finally { client.release(); } }); app.delete('/api/quotes/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]); await client.query('DELETE FROM quotes WHERE id = $1', [id]); await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error deleting quote:', error); res.status(500).json({ error: 'Error deleting quote' }); } finally { client.release(); } }); // Invoice endpoints app.get('/api/invoices', async (req, res) => { try { const result = await pool.query(` SELECT i.*, c.name as customer_name FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id ORDER BY i.created_at DESC `); res.json(result.rows); } catch (error) { console.error('Error fetching invoices:', error); res.status(500).json({ error: 'Error fetching invoices' }); } }); // IMPORTANT: This must come BEFORE /api/invoices/:id to avoid route collision app.get('/api/invoices/next-number', async (req, res) => { try { const nextNumber = await getNextInvoiceNumber(); res.json({ next_number: nextNumber }); } catch (error) { console.error('Error getting next invoice number:', error); res.status(500).json({ error: 'Error getting next invoice number' }); } }); app.get('/api/invoices/:id', async (req, res) => { const { id } = req.params; try { const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); if (invoiceResult.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const itemsResult = await pool.query( 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id] ); res.json({ invoice: invoiceResult.rows[0], items: itemsResult.rows }); } catch (error) { console.error('Error fetching invoice:', error); res.status(500).json({ error: 'Error fetching invoice' }); } }); app.post('/api/invoices', async (req, res) => { const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); // Validate invoice_number is provided and is numeric if (!invoice_number || !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); } // Check if invoice number already exists const existingInvoice = await client.query( 'SELECT id FROM invoices WHERE invoice_number = $1', [invoice_number] ); if (existingInvoice.rows.length > 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); } let subtotal = 0; for (const item of items) { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; const invoiceResult = await client.query( `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id] ); const invoiceId = invoiceResult.rows[0].id; for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', [invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] ); } await client.query('COMMIT'); res.json(invoiceResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating invoice:', error); res.status(500).json({ error: 'Error creating invoice' }); } finally { client.release(); } }); app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); const hasTBD = itemsResult.rows.some(item => item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD' ); if (hasTBD) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' }); } const invoice_number = await getNextInvoiceNumber(); const invoiceDate = new Date().toISOString().split('T')[0]; const invoiceResult = await client.query( `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id] ); const invoiceId = invoiceResult.rows[0].id; for (let i = 0; i < itemsResult.rows.length; i++) { const item = itemsResult.rows[i]; await client.query( 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', [invoiceId, item.quantity, item.description, item.rate, item.amount, i] ); } await client.query('COMMIT'); res.json(invoiceResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); console.error('Error converting quote to invoice:', error); res.status(500).json({ error: 'Error converting quote to invoice' }); } finally { client.release(); } }); app.put('/api/invoices/:id', async (req, res) => { const { id } = req.params; const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); // Validate invoice_number is provided and is numeric if (!invoice_number || !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); } // Check if invoice number already exists (excluding current invoice) const existingInvoice = await client.query( 'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id] ); if (existingInvoice.rows.length > 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); } let subtotal = 0; for (const item of items) { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; await client.query( `UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6, tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, updated_at = CURRENT_TIMESTAMP WHERE id = $11`, [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id] ); await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)', [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i] ); } await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error updating invoice:', error); res.status(500).json({ error: 'Error updating invoice' }); } finally { client.release(); } }); app.delete('/api/invoices/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); await client.query('DELETE FROM invoices WHERE id = $1', [id]); await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error deleting invoice:', error); res.status(500).json({ error: 'Error deleting invoice' }); } finally { client.release(); } }); // PDF Generation code continues below... // PDF Generation using templates and persistent browser app.get('/api/quotes/:id/pdf', async (req, res) => { const { id } = req.params; console.log(`[PDF] Starting quote PDF generation for ID: ${id}`); try { const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); // Load template and replace placeholders const templatePath = path.join(__dirname, 'templates', 'quote-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); // Get logo 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 = ``; } catch (err) { // No logo } // Generate items HTML let itemsHTML = itemsResult.rows.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 ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); // Add totals itemsHTML += ` Subtotal: $${parseFloat(quote.subtotal).toFixed(2)} `; if (!quote.tax_exempt) { itemsHTML += ` Tax (${quote.tax_rate}%): $${parseFloat(quote.tax_amount).toFixed(2)} `; } itemsHTML += ` TOTAL: $${parseFloat(quote.total).toFixed(2)} Thank you for your business! `; let tbdNote = ''; if (quote.has_tbd) { tbdNote = '

* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.

'; } // Replace placeholders html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .replace('{{CUSTOMER_STREET}}', quote.street || '') .replace('{{CUSTOMER_CITY}}', quote.city || '') .replace('{{CUSTOMER_STATE}}', quote.state || '') .replace('{{CUSTOMER_ZIP}}', quote.zip_code || '') .replace('{{QUOTE_NUMBER}}', quote.quote_number) .replace('{{ACCOUNT_NUMBER}}', quote.account_number || '') .replace('{{QUOTE_DATE}}', formatDate(quote.quote_date)) .replace('{{ITEMS}}', itemsHTML) .replace('{{TBD_NOTE}}', tbdNote); // Use persistent browser const browserInstance = await initBrowser(); const page = await browserInstance.newPage(); await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 }); const pdf = await page.pdf({ format: 'Letter', printBackground: true, margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, timeout: 60000 }); await page.close(); // Close page, not browser res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdf.length, 'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"` }); res.end(pdf, 'binary'); console.log('[PDF] Quote PDF sent successfully'); } catch (error) { console.error('[PDF] ERROR:', error); res.status(500).json({ error: 'Error generating PDF', details: error.message }); } }); app.get('/api/invoices/:id/pdf', async (req, res) => { const { id } = req.params; console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`); try { const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); if (invoiceResult.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const invoice = invoiceResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id] ); // Load template const templatePath = path.join(__dirname, 'templates', 'invoice-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); // Get logo 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 = ``; } catch (err) { // No logo } // Generate items HTML let itemsHTML = itemsResult.rows.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 ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); // Add totals itemsHTML += ` Subtotal: $${parseFloat(invoice.subtotal).toFixed(2)} `; if (!invoice.tax_exempt) { itemsHTML += ` Tax (${invoice.tax_rate}%): $${parseFloat(invoice.tax_amount).toFixed(2)} `; } itemsHTML += ` TOTAL: $${parseFloat(invoice.total).toFixed(2)} Thank you for your business! `; // Authorization field const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; // Replace placeholders html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', invoice.customer_name || '') .replace('{{CUSTOMER_STREET}}', invoice.street || '') .replace('{{CUSTOMER_CITY}}', invoice.city || '') .replace('{{CUSTOMER_STATE}}', invoice.state || '') .replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '') .replace('{{INVOICE_NUMBER}}', invoice.invoice_number) .replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '') .replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date)) .replace('{{TERMS}}', invoice.terms) .replace('{{AUTHORIZATION}}', authHTML) .replace('{{ITEMS}}', itemsHTML); // Use persistent browser const browserInstance = await initBrowser(); const page = await browserInstance.newPage(); await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 }); const pdf = await page.pdf({ format: 'Letter', printBackground: true, margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }, timeout: 60000 }); await page.close(); // Close page, not browser res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdf.length, 'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"` }); res.end(pdf, 'binary'); console.log('[INVOICE-PDF] Invoice PDF sent successfully'); } catch (error) { console.error('[INVOICE-PDF] ERROR:', error); res.status(500).json({ error: 'Error generating PDF', details: error.message }); } }); // HTML Debug Endpoints app.get('/api/quotes/:id/html', async (req, res) => { const { id } = req.params; try { const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); const templatePath = path.join(__dirname, 'templates', 'quote-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); 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 = ``; } catch (err) {} let itemsHTML = itemsResult.rows.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 ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); itemsHTML += ` Subtotal: $${parseFloat(quote.subtotal).toFixed(2)} `; if (!quote.tax_exempt) { itemsHTML += ` Tax (${quote.tax_rate}%): $${parseFloat(quote.tax_amount).toFixed(2)} `; } itemsHTML += ` TOTAL: $${parseFloat(quote.total).toFixed(2)} Thank you for your business! `; let tbdNote = ''; if (quote.has_tbd) { tbdNote = '

* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.

'; } html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .replace('{{CUSTOMER_STREET}}', quote.street || '') .replace('{{CUSTOMER_CITY}}', quote.city || '') .replace('{{CUSTOMER_STATE}}', quote.state || '') .replace('{{CUSTOMER_ZIP}}', quote.zip_code || '') .replace('{{QUOTE_NUMBER}}', quote.quote_number) .replace('{{ACCOUNT_NUMBER}}', quote.account_number || '') .replace('{{QUOTE_DATE}}', formatDate(quote.quote_date)) .replace('{{ITEMS}}', itemsHTML) .replace('{{TBD_NOTE}}', tbdNote); res.setHeader('Content-Type', 'text/html'); res.send(html); } catch (error) { console.error('[HTML] ERROR:', error); res.status(500).json({ error: 'Error generating HTML' }); } }); app.get('/api/invoices/:id/html', async (req, res) => { const { id } = req.params; try { const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); if (invoiceResult.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } const invoice = invoiceResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id] ); const templatePath = path.join(__dirname, 'templates', 'invoice-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); 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 = ``; } catch (err) {} let itemsHTML = itemsResult.rows.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 ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); itemsHTML += ` Subtotal: $${parseFloat(invoice.subtotal).toFixed(2)} `; if (!invoice.tax_exempt) { itemsHTML += ` Tax (${invoice.tax_rate}%): $${parseFloat(invoice.tax_amount).toFixed(2)} `; } itemsHTML += ` TOTAL: $${parseFloat(invoice.total).toFixed(2)} Thank you for your business! `; const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', invoice.customer_name || '') .replace('{{CUSTOMER_STREET}}', invoice.street || '') .replace('{{CUSTOMER_CITY}}', invoice.city || '') .replace('{{CUSTOMER_STATE}}', invoice.state || '') .replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '') .replace('{{INVOICE_NUMBER}}', invoice.invoice_number) .replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '') .replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date)) .replace('{{TERMS}}', invoice.terms) .replace('{{AUTHORIZATION}}', authHTML) .replace('{{ITEMS}}', itemsHTML); res.setHeader('Content-Type', 'text/html'); res.send(html); } catch (error) { console.error('[HTML] ERROR:', error); res.status(500).json({ error: 'Error generating HTML' }); } }); // Start server and browser async function startServer() { await initBrowser(); app.listen(PORT, () => { console.log(`Quote System running on port ${PORT}`); }); } startServer(); // Graceful shutdown process.on('SIGTERM', async () => { if (browser) { await browser.close(); } await pool.end(); process.exit(0); });