/** * Invoice Routes * Handles invoice CRUD operations, QBO sync, 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 { getNextInvoiceNumber } = require('../utils/numberGenerators'); const { formatDate, formatMoney } = require('../utils/helpers'); const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service'); const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service'); const { getOAuthClient, getQboBaseUrl } = require('../config/qbo'); const { makeQboApiCall } = require('../../qbo_helper'); // GET all invoices router.get('/', async (req, res) => { try { const result = await pool.query(` SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id ORDER BY i.created_at DESC `); const rows = result.rows.map(r => ({ ...r, amount_paid: parseFloat(r.amount_paid) || 0, balance: (parseFloat(r.total) || 0) - (parseFloat(r.amount_paid) || 0) })); res.json(rows); } catch (error) { console.error('Error fetching invoices:', error); res.status(500).json({ error: 'Error fetching invoices' }); } }); // GET next invoice number router.get('/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' }); } }); // GET single invoice router.get('/:id', async (req, res) => { const { id } = req.params; try { const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid 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]; invoice.amount_paid = parseFloat(invoice.amount_paid) || 0; invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid; const itemsResult = await pool.query( 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id] ); res.json({ invoice, items: itemsResult.rows }); } catch (error) { console.error('Error fetching invoice:', error); res.status(500).json({ error: 'Error fetching invoice' }); } }); // POST create invoice router.post('/', async (req, res) => { const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); // Validate invoice_number if provided if (invoice_number && !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Invoice number must be numeric.' }); } const tempNumber = invoice_number || `DRAFT-${Date.now()}`; if (invoice_number) { const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1', [invoice_number]); if (existing.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, scheduled_send_date, bill_to_name, created_from_quote_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, 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, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] ); } await client.query('COMMIT'); // Auto QBO Export let qboResult = null; try { qboResult = await exportInvoiceToQbo(invoiceId, pool); if (qboResult.skipped) { console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`); } } catch (qboErr) { console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message); } res.json({ ...invoiceResult.rows[0], qbo_id: qboResult?.qbo_id || null, qbo_doc_number: qboResult?.qbo_doc_number || null }); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating invoice:', error); res.status(500).json({ error: 'Error creating invoice' }); } finally { client.release(); } }); // PUT update invoice router.put('/:id', async (req, res) => { const { id } = req.params; const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); // Validate invoice_number if provided if (invoice_number && !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Invoice number must be numeric.' }); } if (invoice_number) { const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id]); if (existing.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; // Update local if (invoice_number) { 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, scheduled_send_date = $11, bill_to_name = $12, updated_at = CURRENT_TIMESTAMP WHERE id = $13`, [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id] ); } else { await client.query( `UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5, tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, scheduled_send_date = $10, bill_to_name = $11, updated_at = CURRENT_TIMESTAMP WHERE id = $12`, [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id] ); } // Delete and re-insert items 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, 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'); // Auto QBO: Export if not yet in QBO, Sync if already in QBO let qboResult = null; try { const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]); const hasQboId = !!checkRes.rows[0]?.qbo_id; if (hasQboId) { qboResult = await syncInvoiceToQbo(id, pool); } else { qboResult = await exportInvoiceToQbo(id, pool); } if (qboResult.skipped) { console.log(`ℹ️ Invoice ${id}: ${qboResult.reason}`); } } catch (qboErr) { console.error(`⚠️ Auto QBO failed for Invoice ${id}:`, qboErr.message); } res.json({ success: true, qbo_synced: !!qboResult?.success, qbo_id: qboResult?.qbo_id || null, qbo_doc_number: qboResult?.qbo_doc_number || null }); } catch (error) { await client.query('ROLLBACK'); console.error('Error updating invoice:', error); res.status(500).json({ error: 'Error updating invoice' }); } finally { client.release(); } }); // DELETE invoice router.delete('/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); // Load invoice to check qbo_id const invResult = await client.query('SELECT qbo_id, qbo_sync_token, invoice_number FROM invoices WHERE id = $1', [id]); if (invResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Invoice not found' }); } const invoice = invResult.rows[0]; // Delete in QBO if present if (invoice.qbo_id) { try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, method: 'GET' }); const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; const syncToken = qboData.Invoice?.SyncToken; if (syncToken !== undefined) { console.log(`🗑️ Voiding QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.invoice_number})...`); await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice?operation=void`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Id: invoice.qbo_id, SyncToken: syncToken }) }); console.log(`✅ QBO Invoice ${invoice.qbo_id} voided.`); } } catch (qboError) { console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message); } } // Delete locally await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); await client.query('DELETE FROM payment_invoices 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(); } }); // PATCH invoice email status router.patch('/:id/email-status', async (req, res) => { const { id } = req.params; const { status } = req.body; if (!['sent', 'open'].includes(status)) { return res.status(400).json({ error: 'Status must be "sent" or "open".' }); } try { const invResult = await pool.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]); if (invResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); const invoice = invResult.rows[0]; // Update QBO if present if (invoice.qbo_id) { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, method: 'GET' }); const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; const syncToken = qboData.Invoice?.SyncToken; if (syncToken !== undefined) { const emailStatus = status === 'sent' ? 'EmailSent' : 'NotSet'; await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Id: invoice.qbo_id, SyncToken: syncToken, sparse: true, EmailStatus: emailStatus }) }); console.log(`✅ QBO Invoice ${invoice.qbo_id} email status → ${emailStatus}`); } } // Update local await pool.query( 'UPDATE invoices SET email_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [status, id] ); res.json({ success: true, status }); } catch (error) { console.error('Error updating email status:', error); res.status(500).json({ error: 'Failed to update status: ' + error.message }); } }); // PATCH mark invoice as paid router.patch('/:id/mark-paid', async (req, res) => { const { id } = req.params; const { paid_date } = req.body; try { const dateToUse = paid_date || new Date().toISOString().split('T')[0]; const result = await pool.query( 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *', [dateToUse, id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`); res.json(result.rows[0]); } catch (error) { console.error('Error marking invoice as paid:', error); res.status(500).json({ error: 'Error marking invoice as paid' }); } }); // PATCH mark invoice as unpaid router.patch('/:id/mark-unpaid', async (req, res) => { const { id } = req.params; try { const result = await pool.query( 'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`); res.json(result.rows[0]); } catch (error) { console.error('Error marking invoice as unpaid:', error); res.status(500).json({ error: 'Error marking invoice as unpaid' }); } }); // PATCH reset QBO link router.patch('/:id/reset-qbo', async (req, res) => { const { id } = req.params; try { const result = await pool.query( `UPDATE invoices SET qbo_id = NULL, qbo_sync_token = NULL, qbo_doc_number = NULL, invoice_number = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *`, [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Invoice not found' }); } console.log(`🔄 Invoice ID ${id} QBO-Verknüpfung zurückgesetzt`); res.json(result.rows[0]); } catch (error) { console.error('Error resetting QBO link:', error); res.status(500).json({ error: 'Error resetting QBO link' }); } }); // POST export to QBO router.post('/:id/export', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { const invoiceRes = await client.query(` SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); const invoice = invoiceRes.rows[0]; if (!invoice.customer_qbo_id) { return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` }); } const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]); const items = itemsRes.rows; const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const maxNumResult = await client.query(` SELECT GREATEST( COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0), COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0) ) as max_num `); let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString(); const lineItems = items.map(item => { const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; const itemRefId = item.qbo_item_id || '9'; const itemRefName = itemRefId == '5' ? "Labor:Labor" : "Parts:Parts"; return { "DetailType": "SalesItemLineDetail", "Amount": amount, "Description": item.description, "SalesItemLineDetail": { "ItemRef": { "value": itemRefId, "name": itemRefName }, "UnitPrice": rate, "Qty": parseFloat(item.quantity) || 1 } }; }); const qboInvoicePayload = { "CustomerRef": { "value": invoice.customer_qbo_id }, "DocNumber": nextDocNumber, "TxnDate": invoice.invoice_date.toISOString().split('T')[0], "Line": lineItems, "CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }, "EmailStatus": "EmailSent", "BillEmail": { "Address": invoice.email || "" } }; let qboInvoice = null; const MAX_RETRIES = 5; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { console.log(`📤 Sende Rechnung an QBO (DocNumber: ${qboInvoicePayload.DocNumber})...`); const createResponse = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(qboInvoicePayload) }); const responseData = createResponse.getJson ? createResponse.getJson() : createResponse.json; if (responseData.Fault?.Error?.[0]?.code === '6140') { const oldNum = parseInt(qboInvoicePayload.DocNumber); qboInvoicePayload.DocNumber = (oldNum + 1).toString(); console.log(`⚠️ DocNumber ${oldNum} existiert bereits. Versuche ${qboInvoicePayload.DocNumber}...`); continue; } qboInvoice = responseData.Invoice || responseData; if (qboInvoice.Id) { break; } else { console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2)); throw new Error("QBO hat keine ID zurückgegeben: " + (responseData.Fault?.Error?.[0]?.Message || JSON.stringify(responseData))); } } if (!qboInvoice || !qboInvoice.Id) { throw new Error(`Konnte nach ${MAX_RETRIES} Versuchen keine freie DocNumber finden.`); } console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`); await client.query( `UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5`, [qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, id] ); res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber }); } catch (error) { console.error("QBO Export Error:", error); let errorDetails = error.message; if (error.response?.data?.Fault?.Error?.[0]) { errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail; } res.status(500).json({ error: "QBO Export failed: " + errorDetails }); } finally { client.release(); } }); // POST update in QBO router.post('/:id/update-qbo', async (req, res) => { const { id } = req.params; const QBO_LABOR_ID = '5'; const QBO_PARTS_ID = '9'; const dbClient = await pool.connect(); try { const invoiceRes = await dbClient.query(` SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [id]); if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); const invoice = invoiceRes.rows[0]; if (!invoice.qbo_id) { return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' }); } if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') { return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' }); } const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]); const items = itemsRes.rows; const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`); const currentQboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, method: 'GET' }); const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json; const currentQboInvoice = currentQboData.Invoice; if (!currentQboInvoice) { return res.status(500).json({ error: 'Could not load current invoice from QBO.' }); } const currentSyncToken = currentQboInvoice.SyncToken; console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`); const lineItems = items.map(item => { const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; const itemRefId = item.qbo_item_id || QBO_PARTS_ID; const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts"; return { "DetailType": "SalesItemLineDetail", "Amount": amount, "Description": item.description, "SalesItemLineDetail": { "ItemRef": { "value": itemRefId, "name": itemRefName }, "UnitPrice": rate, "Qty": parseFloat(item.quantity) || 1 } }; }); const updatePayload = { "Id": invoice.qbo_id, "SyncToken": currentSyncToken, "sparse": true, "Line": lineItems, "CustomerRef": { "value": invoice.customer_qbo_id }, "TxnDate": invoice.invoice_date.toISOString().split('T')[0], "CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" } }; console.log(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`); const updateResponse = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatePayload) }); const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json; const updatedInvoice = updateData.Invoice || updateData; if (!updatedInvoice.Id) { console.error("QBO Update Response:", JSON.stringify(updateData, null, 2)); throw new Error("QBO did not return an updated invoice."); } console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`); await dbClient.query( 'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [updatedInvoice.SyncToken, id] ); res.json({ success: true, qbo_id: updatedInvoice.Id, sync_token: updatedInvoice.SyncToken, message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.` }); } catch (error) { console.error("QBO Update Error:", error); let errorDetails = error.message; if (error.response?.data?.Fault?.Error?.[0]) { errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail; } res.status(500).json({ error: "QBO Update failed: " + errorDetails }); } finally { dbClient.release(); } }); // GET invoice PDF router.get('/: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.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid 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'); const logoHTML = await getLogoHtml(); const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice); const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name); html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '') .replace('{{CUSTOMER_STREET}}', streetBlock) .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); const pdf = await generatePdfFromHtml(html); 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 }); } }); // GET invoice HTML (debug) router.get('/:id/html', async (req, res) => { const { id } = req.params; try { const invoiceResult = await pool.query(` SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid 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'); const logoHTML = await getLogoHtml(); const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice); const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name); html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '') .replace('{{CUSTOMER_STREET}}', streetBlock) .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' }); } }); module.exports = router;