refactoring 1. step

This commit is contained in:
2026-03-02 10:09:24 -06:00
parent 198126c13e
commit 7226883a2e
18 changed files with 2915 additions and 2784 deletions

807
src/routes/invoices.js Normal file
View File

@@ -0,0 +1,807 @@
/**
* 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 ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
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 ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
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;