This commit is contained in:
2026-02-16 18:42:20 -06:00
parent 911b25d96b
commit 25da1a46a8
11 changed files with 4225 additions and 695 deletions

339
server.js
View File

@@ -4,6 +4,7 @@ const path = require('path');
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
const multer = require('multer');
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -161,12 +162,25 @@ app.get('/api/customers', async (req, res) => {
}
});
// POST /api/customers
app.post('/api/customers', async (req, res) => {
const { name, street, city, state, zip_code, account_number } = req.body;
// line1 bis line4 statt street/pobox/suite
const {
name, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable
} = req.body;
try {
const result = await pool.query(
'INSERT INTO customers (name, street, city, state, zip_code, account_number) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
[name, street, city, state, zip_code, account_number]
`INSERT INTO customers
(name, line1, line2, line3, line4, city, state,
zip_code, account_number, email, phone, phone2, taxable)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[name, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null,
account_number || null, email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true]
);
res.json(result.rows[0]);
} catch (error) {
@@ -175,13 +189,26 @@ app.post('/api/customers', async (req, res) => {
}
});
// PUT /api/customers/:id
app.put('/api/customers/:id', async (req, res) => {
const { id } = req.params;
const { name, street, city, state, zip_code, account_number } = req.body;
const {
name, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable
} = req.body;
try {
const result = await pool.query(
'UPDATE customers SET name = $1, street = $2, city = $3, state = $4, zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $7 RETURNING *',
[name, street, city, state, zip_code, account_number, id]
`UPDATE customers
SET name = $1, line1 = $2, line2 = $3, line3 = $4, line4 = $5,
city = $6, state = $7, zip_code = $8, account_number = $9, email = $10,
phone = $11, phone2 = $12, taxable = $13, updated_at = CURRENT_TIMESTAMP
WHERE id = $14
RETURNING *`,
[name, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null,
account_number || null, email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, id]
);
res.json(result.rows[0]);
} catch (error) {
@@ -220,8 +247,9 @@ app.get('/api/quotes', async (req, res) => {
app.get('/api/quotes/:id', async (req, res) => {
const { id } = req.params;
try {
// KORRIGIERT: c.line1...c.line4 statt c.street
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
@@ -283,8 +311,8 @@ app.post('/api/quotes', async (req, res) => {
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
'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']
);
}
@@ -336,8 +364,8 @@ app.put('/api/quotes/:id', async (req, res) => {
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
'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']
);
}
@@ -400,8 +428,9 @@ app.get('/api/invoices/next-number', async (req, res) => {
app.get('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
try {
// KORRIGIERT: c.line1, c.line2, c.line3, c.line4 statt c.street
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
@@ -473,8 +502,9 @@ app.post('/api/invoices', async (req, res) => {
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
// qbo_item_id hinzugefügt
'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'] // Default '9' (Parts)
);
}
@@ -496,8 +526,9 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
try {
await client.query('BEGIN');
// KORRIGIERT: c.line1...c.line4 statt c.street
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
@@ -538,8 +569,8 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
for (let i = 0; i < itemsResult.rows.length; i++) {
const item = itemsResult.rows[i];
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[invoiceId, item.quantity, item.description, item.rate, item.amount, i]
'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']
);
}
@@ -603,8 +634,8 @@ app.put('/api/invoices/:id', async (req, res) => {
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
'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']
);
}
@@ -646,8 +677,9 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
try {
// KORRIGIERT: Abfrage von line1-4 statt street/pobox
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
@@ -663,29 +695,23 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
[id]
);
// Load template and replace placeholders
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
// Get logo
let logoHTML = '';
try {
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (err) {
// No logo
}
} catch (err) {}
// Generate items HTML
// Items HTML generieren
let itemsHTML = itemsResult.rows.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) {
rateFormatted = rateNum.toFixed(2);
}
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
@@ -693,17 +719,15 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`;
</tr>`;
}).join('');
// Add totals
// Totals
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
</tr>`;
if (!quote.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
@@ -711,7 +735,6 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
@@ -720,17 +743,26 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
let tbdNote = '';
if (quote.has_tbd) {
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
// --- ADRESS-LOGIK (NEU) ---
const addressLines = [];
// Wenn line1 existiert UND ungleich dem Namen ist, hinzufügen. Sonst überspringen (da Name eh drüber steht).
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
addressLines.push(quote.line1);
}
if (quote.line2) addressLines.push(quote.line2);
if (quote.line3) addressLines.push(quote.line3);
if (quote.line4) addressLines.push(quote.line4);
// Replace placeholders
const streetBlock = addressLines.join('<br>');
// Ersetzen
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
.replace('{{CUSTOMER_STREET}}', quote.street || '')
.replace('{{CUSTOMER_STREET}}', streetBlock) // Hier kommt der Block rein
.replace('{{CUSTOMER_CITY}}', quote.city || '')
.replace('{{CUSTOMER_STATE}}', quote.state || '')
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
@@ -739,21 +771,15 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
.replace('{{ITEMS}}', itemsHTML)
.replace('{{TBD_NOTE}}', tbdNote);
// Use persistent browser
const browserInstance = await initBrowser();
const page = await browserInstance.newPage();
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
timeout: 60000
format: 'Letter', printBackground: true,
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
});
await page.close(); // Close page, not browser
await page.close();
res.set({
'Content-Type': 'application/pdf',
@@ -771,12 +797,12 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
app.get('/api/invoices/:id/pdf', async (req, res) => {
const { id } = req.params;
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
try {
// KORRIGIERT: Abfrage von line1-4
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
@@ -792,29 +818,22 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
[id]
);
// Load template
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
// Get logo
let logoHTML = '';
try {
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (err) {
// No logo
}
} catch (err) {}
// Generate items HTML
let itemsHTML = itemsResult.rows.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) {
rateFormatted = rateNum.toFixed(2);
}
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
@@ -822,17 +841,14 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`;
</tr>`;
}).join('');
// Add totals
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
</tr>`;
if (!invoice.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
@@ -840,7 +856,6 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
@@ -849,16 +864,24 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
// Authorization field
const authHTML = invoice.auth_code ?
`<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
// --- ADRESS-LOGIK (NEU) ---
const addressLines = [];
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
addressLines.push(invoice.line1);
}
if (invoice.line2) addressLines.push(invoice.line2);
if (invoice.line3) addressLines.push(invoice.line3);
if (invoice.line4) addressLines.push(invoice.line4);
// Replace placeholders
const streetBlock = addressLines.join('<br>');
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
.replace('{{CUSTOMER_STREET}}', invoice.street || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
@@ -868,21 +891,15 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
// Use persistent browser
const browserInstance = await initBrowser();
const page = await browserInstance.newPage();
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
const pdf = await page.pdf({
format: 'Letter',
printBackground: true,
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
timeout: 60000
format: 'Letter', printBackground: true,
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
});
await page.close(); // Close page, not browser
await page.close();
res.set({
'Content-Type': 'application/pdf',
@@ -904,8 +921,9 @@ app.get('/api/quotes/:id/html', async (req, res) => {
const { id } = req.params;
try {
// KORREKTUR: Line 1-4 abfragen
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
@@ -936,9 +954,7 @@ app.get('/api/quotes/:id/html', async (req, res) => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) {
rateFormatted = rateNum.toFixed(2);
}
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
@@ -946,8 +962,7 @@ app.get('/api/quotes/:id/html', async (req, res) => {
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`;
</tr>`;
}).join('');
itemsHTML += `
@@ -955,7 +970,6 @@ app.get('/api/quotes/:id/html', async (req, res) => {
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
</tr>`;
if (!quote.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
@@ -963,7 +977,6 @@ app.get('/api/quotes/:id/html', async (req, res) => {
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
@@ -972,16 +985,24 @@ app.get('/api/quotes/:id/html', async (req, res) => {
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
let tbdNote = '';
if (quote.has_tbd) {
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
// --- ADRESS LOGIK ---
const addressLines = [];
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
addressLines.push(quote.line1);
}
if (quote.line2) addressLines.push(quote.line2);
if (quote.line3) addressLines.push(quote.line3);
if (quote.line4) addressLines.push(quote.line4);
const streetBlock = addressLines.join('<br>');
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
.replace('{{CUSTOMER_STREET}}', quote.street || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', quote.city || '')
.replace('{{CUSTOMER_STATE}}', quote.state || '')
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
@@ -990,7 +1011,7 @@ app.get('/api/quotes/:id/html', async (req, res) => {
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
.replace('{{ITEMS}}', itemsHTML)
.replace('{{TBD_NOTE}}', tbdNote);
res.setHeader('Content-Type', 'text/html');
res.send(html);
@@ -1004,8 +1025,9 @@ app.get('/api/invoices/:id/html', async (req, res) => {
const { id } = req.params;
try {
// KORREKTUR: Line 1-4 abfragen
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
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
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
@@ -1036,9 +1058,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) {
rateFormatted = rateNum.toFixed(2);
}
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
@@ -1046,8 +1066,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`;
</tr>`;
}).join('');
itemsHTML += `
@@ -1055,7 +1074,6 @@ app.get('/api/invoices/:id/html', async (req, res) => {
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
</tr>`;
if (!invoice.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
@@ -1063,7 +1081,6 @@ app.get('/api/invoices/:id/html', async (req, res) => {
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
</tr>`;
}
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">TOTAL:</td>
@@ -1072,14 +1089,24 @@ app.get('/api/invoices/:id/html', async (req, res) => {
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
const authHTML = invoice.auth_code ?
`<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
// --- ADRESS LOGIK ---
const addressLines = [];
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
addressLines.push(invoice.line1);
}
if (invoice.line2) addressLines.push(invoice.line2);
if (invoice.line3) addressLines.push(invoice.line3);
if (invoice.line4) addressLines.push(invoice.line4);
const streetBlock = addressLines.join('<br>');
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
.replace('{{CUSTOMER_STREET}}', invoice.street || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
@@ -1089,7 +1116,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
res.setHeader('Content-Type', 'text/html');
res.send(html);
@@ -1099,6 +1126,108 @@ app.get('/api/invoices/:id/html', async (req, res) => {
}
});
// QBO Export Endpoint
app.post('/api/invoices/:id/export', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
// HIER SIND DEINE FESTEN IDs
const QBO_LABOR_ID = '5'; // Labor:Labor
const QBO_PARTS_ID = '9'; // Parts:Parts
try {
// 1. Lokale Rechnung laden
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.` });
}
// 2. Items laden (inkl. qbo_item_id)
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
const items = itemsRes.rows;
// 3. QBO Client
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// 4. QBO JSON bauen (OHNE "select * from Item...")
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;
// WICHTIG: Hier nutzen wir die ID aus der Datenbank oder den Fallback
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 qboInvoicePayload = {
"CustomerRef": { "value": invoice.customer_qbo_id },
"DocNumber": invoice.invoice_number,
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"Line": lineItems,
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
};
console.log(`📤 Sende Rechnung ${invoice.invoice_number} an QBO...`);
const createResponse = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboInvoicePayload)
});
const qboInvoice = createResponse.getJson ? createResponse.getJson() : createResponse.json;
onsole.log("🔍 FULL QBO RESPONSE:", JSON.stringify(qboInvoice, null, 2));
if (!qboInvoice.Id) {
throw new Error("QBO hat keine ID zurückgegeben. Wahrscheinlich ein Fehler im Request (siehe Logs).");
}
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}`);
await client.query(
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3 WHERE id = $4`,
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, id]
);
res.json({ success: true, qbo_id: qboInvoice.Id });
} 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();
}
});
// Start server and browser
async function startServer() {