/** * Quote Routes * Handles quote CRUD operations and PDF generation */ const express = require('express'); const router = express.Router(); const path = require('path'); const fs = require('fs').promises; const { pool } = require('../config/database'); const { getNextQuoteNumber } = require('../utils/numberGenerators'); const { formatDate, formatMoney } = require('../utils/helpers'); const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service'); // GET all quotes router.get('/', 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' }); } }); // GET single quote router.get('/:id', async (req, res) => { const { id } = req.params; try { const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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' }); } }); // POST create quote router.post('/', 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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] ); } 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(); } }); // PUT update quote router.put('/: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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] ); } 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(); } }); // DELETE quote router.delete('/: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(); } }); // GET quote PDF router.get('/: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.line1, c.line2, c.line3, c.line4, 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'); const logoHTML = await getLogoHtml(); const itemsHTML = renderQuoteItems(itemsResult.rows, quote); let tbdNote = quote.has_tbd ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name); html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .replace('{{CUSTOMER_STREET}}', streetBlock) .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); const pdf = await generatePdfFromHtml(html); 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 }); } }); // GET quote HTML (debug) router.get('/:id/html', async (req, res) => { const { id } = req.params; try { const quoteResult = await pool.query(` SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, 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'); const logoHTML = await getLogoHtml(); const itemsHTML = renderQuoteItems(itemsResult.rows, quote); let tbdNote = quote.has_tbd ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name); html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .replace('{{CUSTOMER_STREET}}', streetBlock) .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' }); } }); // POST convert quote to invoice router.post('/: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.line1, c.line2, c.line3, c.line4, 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 = null; 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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9'] ); } 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(); } }); module.exports = router;