invoice-system/src/routes/invoices.js

808 lines
33 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;