const express = require('express'); const { Pool } = require('pg'); const path = require('path'); const puppeteer = require('puppeteer'); const fs = require('fs').promises; const multer = require('multer'); const OAuthClient = require('intuit-oauth'); const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require('./qbo_helper'); const app = express(); const PORT = process.env.PORT || 3000; // Global browser instance let browser = null; // Initialize browser on startup async function initBrowser() { if (!browser) { console.log('[BROWSER] Launching persistent browser...'); browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', '--no-zygote', '--single-process' ], protocolTimeout: 180000, timeout: 180000 }); console.log('[BROWSER] Browser launched and ready'); // Restart browser if it crashes browser.on('disconnected', () => { console.log('[BROWSER] Browser disconnected, restarting...'); browser = null; initBrowser(); }); } return browser; } // Database connection const pool = new Pool({ user: process.env.DB_USER || 'postgres', host: process.env.DB_HOST || 'localhost', database: process.env.DB_NAME || 'quotes_db', password: process.env.DB_PASSWORD || 'postgres', port: process.env.DB_PORT || 5432, }); // Middleware app.use(express.json()); app.use(express.static('public')); // Configure multer for logo upload const storage = multer.diskStorage({ destination: async (req, file, cb) => { const uploadDir = path.join(__dirname, 'public', 'uploads'); try { await fs.mkdir(uploadDir, { recursive: true }); } catch (err) { console.error('Error creating upload directory:', err); } cb(null, uploadDir); }, filename: (req, file, cb) => { cb(null, 'company-logo.png'); } }); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed')); } } }); // Helper functions function formatDate(date) { const d = new Date(date); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const year = d.getFullYear(); return `${month}/${day}/${year}`; } async function getNextQuoteNumber() { const year = new Date().getFullYear(); const result = await pool.query( 'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1', [`${year}-%`] ); if (result.rows.length === 0) { return `${year}-001`; } const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]); const nextNumber = String(lastNumber + 1).padStart(3, '0'); return `${year}-${nextNumber}`; } async function getNextInvoiceNumber() { const result = await pool.query( 'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\'' ); if (result.rows.length === 0 || result.rows[0].max_number === null) { return '110508'; } return String(parseInt(result.rows[0].max_number) + 1); } // ===================================================== // INVOICE CREATE + UPDATE — Auto QBO Export/Sync // ERSETZE POST /api/invoices und PUT /api/invoices/:id // ===================================================== const QBO_LABOR_ID = '5'; const QBO_PARTS_ID = '9'; // --- Helper: QBO Invoice Export (create) --- async function exportInvoiceToQbo(invoiceId, client) { 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 `, [invoiceId]); const invoice = invoiceRes.rows[0]; if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' }; const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]); const items = itemsRes.rows; 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'; // Nächste DocNumber ermitteln (aus lokaler DB) 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 parseNum = (val) => { if (val === null || val === undefined) return 0; if (typeof val === 'number') return val; return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0; }; const rate = parseNum(item.rate); const qty = parseNum(item.quantity) || 1; const amount = rate * qty; 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": qty } }; }); const qboPayload = { "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": "NotSet", "BillEmail": { "Address": invoice.email || "" } }; // Retry bei Duplicate let qboInvoice = null; for (let attempt = 0; attempt < 5; attempt++) { console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`); const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(qboPayload) }); const data = response.getJson ? response.getJson() : response.json; if (data.Fault?.Error?.[0]?.code === '6140') { console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`); qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString(); continue; } if (data.Fault) { const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault); console.error(`❌ QBO Export Fault:`, errMsg); throw new Error('QBO export failed: ' + errMsg); } qboInvoice = data.Invoice || data; if (qboInvoice.Id) break; throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500)); } if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.'); 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, invoiceId] ); console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`); return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber }; } // --- Helper: QBO Invoice Update (sync) --- async function syncInvoiceToQbo(invoiceId, client) { const invoiceRes = await client.query(` SELECT i.*, c.qbo_id as customer_qbo_id FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = $1 `, [invoiceId]); const invoice = invoiceRes.rows[0]; if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' }; const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]); 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'; // Aktuellen SyncToken holen const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, method: 'GET' }); const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; const currentSyncToken = qboData.Invoice?.SyncToken; if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO'); const lineItems = itemsRes.rows.map(item => { // Robust parsing: handle both string ("$1,250.00") and numeric types const parseNum = (val) => { if (val === null || val === undefined) return 0; if (typeof val === 'number') return val; return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0; }; const rate = parseNum(item.rate); const qty = parseNum(item.quantity) || 1; const amount = rate * qty; // Always compute amount = rate * qty for QBO const itemRefId = item.qbo_item_id || QBO_PARTS_ID; const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts"; console.log(` 📋 Item: qty=${qty}, rate=${rate}, amount=${amount}, ref=${itemRefId}`); 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(`📤 QBO Sync Invoice ${invoice.qbo_id}...`); const updateRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/invoice`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatePayload) }); const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json; // Prüfe auf Fault if (updateData.Fault) { const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault); console.error(`❌ QBO Sync Fault:`, errMsg); throw new Error('QBO sync failed: ' + errMsg); } const updated = updateData.Invoice || updateData; if (!updated.Id) { console.error(`❌ QBO unexpected response:`, JSON.stringify(updateData).substring(0, 500)); throw new Error('QBO update returned no ID'); } await client.query( 'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [updated.SyncToken, invoiceId] ); console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`); return { success: true, sync_token: updated.SyncToken }; } function formatMoney(val) { return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // ===================================================== // POST /api/invoices — Create + Auto QBO Export // ===================================================== app.post('/api/invoices', 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'); // invoice_number kann leer sein — wird von QBO vergeben // Falls angegeben, validieren if (invoice_number && !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Invoice number must be numeric.' }); } // Temporäre Nummer falls leer 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 (falls Kunde in QBO) let qboResult = null; try { qboResult = await exportInvoiceToQbo(invoiceId, client); 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); // Nicht abbrechen — lokal wurde gespeichert } 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 /api/invoices/:id — Update + Auto QBO Sync // ===================================================== app.put('/api/invoices/: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'); // Invoice-Nummer validieren (falls angegeben) 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 lokal — invoice_number nur ändern wenn angegeben 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] ); } // Items neu schreiben 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 { // Prüfe ob Invoice schon in QBO 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, client); } else { qboResult = await exportInvoiceToQbo(id, client); } 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(); } }); // Logo endpoints app.get('/api/logo-info', async (req, res) => { try { const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png'); try { await fs.access(logoPath); res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' }); } catch { res.json({ hasLogo: false }); } } catch (error) { console.error('Error checking logo:', error); res.status(500).json({ error: 'Error checking logo' }); } }); app.post('/api/upload-logo', upload.single('logo'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } res.json({ message: 'Logo uploaded successfully', path: '/uploads/company-logo.png' }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: 'Error uploading logo' }); } }); // Customer endpoints app.get('/api/customers', async (req, res) => { try { const result = await pool.query('SELECT * FROM customers ORDER BY name'); res.json(result.rows); } catch (error) { console.error('Error fetching customers:', error); res.status(500).json({ error: 'Error fetching customers' }); } }); // POST /api/customers app.post('/api/customers', async (req, res) => { const { name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks } = req.body; try { const result = await pool.query( `INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`, [name, contact || null, 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, remarks || null] ); res.json(result.rows[0]); } catch (error) { console.error('Error creating customer:', error); res.status(500).json({ error: 'Error creating customer' }); } }); // PUT /api/customers/:id app.put('/api/customers/:id', async (req, res) => { const { id } = req.params; const { name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks } = req.body; try { const result = await pool.query( `UPDATE customers SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6, city = $7, state = $8, zip_code = $9, account_number = $10, email = $11, phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP WHERE id = $16 RETURNING *`, [name, contact || null, 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, remarks || null, id] ); const customer = result.rows[0]; // QBO Update if (customer.qbo_id) { try { 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'; // SyncToken holen const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`, method: 'GET' }); const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; const syncToken = qboData.Customer?.SyncToken; if (syncToken !== undefined) { const updatePayload = { Id: customer.qbo_id, SyncToken: syncToken, sparse: true, DisplayName: name, CompanyName: name, PrimaryEmailAddr: email ? { Address: email } : undefined, PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined, Taxable: taxable !== false, Notes: remarks || undefined }; // Contact → GivenName / FamilyName if (contact) { const parts = contact.trim().split(/\s+/); if (parts.length >= 2) { updatePayload.GivenName = parts[0]; updatePayload.FamilyName = parts.slice(1).join(' '); } else { updatePayload.GivenName = parts[0]; } } // Adresse const addr = {}; if (line1) addr.Line1 = line1; if (line2) addr.Line2 = line2; if (line3) addr.Line3 = line3; if (line4) addr.Line4 = line4; if (city) addr.City = city; if (state) addr.CountrySubDivisionCode = state; if (zip_code) addr.PostalCode = zip_code; if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr; console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`); await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatePayload) }); console.log(`✅ QBO Customer ${customer.qbo_id} updated.`); } } catch (qboError) { console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message); } } res.json(customer); } catch (error) { console.error('Error updating customer:', error); res.status(500).json({ error: 'Error updating customer' }); } }); app.delete('/api/customers/:id', async (req, res) => { const { id } = req.params; try { // Kunde laden const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); if (custResult.rows.length === 0) { return res.status(404).json({ error: 'Customer not found' }); } const customer = custResult.rows[0]; // In QBO deaktivieren falls vorhanden if (customer.qbo_id) { try { 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'; // SyncToken holen const qboRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`, method: 'GET' }); const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; const syncToken = qboData.Customer?.SyncToken; if (syncToken !== undefined) { console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`); await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Id: customer.qbo_id, SyncToken: syncToken, sparse: true, Active: false }) }); console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`); } } catch (qboError) { console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message); // Trotzdem lokal löschen } } // Lokal löschen await pool.query('DELETE FROM customers WHERE id = $1', [id]); res.json({ success: true }); } catch (error) { console.error('Error deleting customer:', error); res.status(500).json({ error: 'Error deleting customer' }); } }); // Quote endpoints app.get('/api/quotes', async (req, res) => { try { const result = await pool.query(` SELECT q.*, c.name as customer_name FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id ORDER BY q.created_at DESC `); res.json(result.rows); } catch (error) { console.error('Error fetching quotes:', error); res.status(500).json({ error: 'Error fetching quotes' }); } }); 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.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); res.json({ quote: quoteResult.rows[0], items: itemsResult.rows }); } catch (error) { console.error('Error fetching quote:', error); res.status(500).json({ error: 'Error fetching quote' }); } }); app.post('/api/quotes', async (req, res) => { const { customer_id, quote_date, tax_exempt, items } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); const quote_number = await getNextQuoteNumber(); let subtotal = 0; let has_tbd = false; for (const item of items) { if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { has_tbd = true; } else { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; const quoteResult = await client.query( `INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd] ); const quoteId = quoteResult.rows[0].id; for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] ); } await client.query('COMMIT'); res.json(quoteResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating quote:', error); res.status(500).json({ error: 'Error creating quote' }); } finally { client.release(); } }); app.put('/api/quotes/:id', async (req, res) => { const { id } = req.params; const { customer_id, quote_date, tax_exempt, items } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); let subtotal = 0; let has_tbd = false; for (const item of items) { if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { has_tbd = true; } else { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); if (!isNaN(amount)) { subtotal += amount; } } } const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; await client.query( `UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4, subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $9`, [customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id] ); await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]); for (let i = 0; i < items.length; i++) { await client.query( 'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] ); } await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error updating quote:', error); res.status(500).json({ error: 'Error updating quote' }); } finally { client.release(); } }); app.delete('/api/quotes/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]); await client.query('DELETE FROM quotes WHERE id = $1', [id]); await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error deleting quote:', error); res.status(500).json({ error: 'Error deleting quote' }); } finally { client.release(); } }); // Invoice endpoints app.get('/api/invoices', 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' }); } }); // IMPORTANT: This must come BEFORE /api/invoices/:id to avoid route collision app.get('/api/invoices/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' }); } }); app.get('/api/invoices/: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' }); } }); app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { const { id } = req.params; const client = await pool.connect(); 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.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); const hasTBD = itemsResult.rows.some(item => item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD' ); if (hasTBD) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' }); } const invoice_number = null; const invoiceDate = new Date().toISOString().split('T')[0]; const invoiceResult = await client.query( `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id] ); const invoiceId = invoiceResult.rows[0].id; for (let i = 0; i < itemsResult.rows.length; i++) { const item = itemsResult.rows[i]; await client.query( 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', [invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9'] ); } await client.query('COMMIT'); res.json(invoiceResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); console.error('Error converting quote to invoice:', error); res.status(500).json({ error: 'Error converting quote to invoice' }); } finally { client.release(); } }); app.delete('/api/invoices/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect(); try { await client.query('BEGIN'); // Invoice laden um qbo_id zu prüfen 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]; // In QBO löschen falls vorhanden if (invoice.qbo_id) { try { 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'; // Aktuellen SyncToken aus QBO holen (sicherste Methode) 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) { // QBO Invoice "voiden" (nicht löschen — QBO empfiehlt Void statt Delete) // Void setzt Balance auf 0 und markiert als nichtig 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) { // QBO-Fehler loggen aber lokales Löschen trotzdem durchführen console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message); // Nicht abbrechen — lokal trotzdem löschen } } // Lokal löschen (payment_invoices hat ON DELETE CASCADE) 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(); } }); app.patch('/api/invoices/:id/email-status', async (req, res) => { const { id } = req.params; const { status } = req.body; // 'sent' or 'open' 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]; // QBO updaten falls vorhanden if (invoice.qbo_id) { 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'; // SyncToken holen 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}`); } } // Lokal updaten 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 }); } }); // PDF Generation code continues below... // PDF Generation using templates and persistent browser app.get('/api/quotes/:id/pdf', async (req, res) => { const { id } = req.params; 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.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); const templatePath = path.join(__dirname, 'templates', 'quote-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); 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 = ``; } catch (err) {} // 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); } return ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); // Totals itemsHTML += ` Subtotal: $${formatMoney(quote.subtotal)} `; if (!quote.tax_exempt) { itemsHTML += ` Tax (${quote.tax_rate}%): $${formatMoney(quote.tax_amount)} `; } itemsHTML += ` TOTAL: $${formatMoney(quote.total)} Thank you for your business! `; let tbdNote = quote.has_tbd ? '

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

' : ''; // --- 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); const streetBlock = addressLines.join('
'); // Ersetzen html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .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 || '') .replace('{{QUOTE_NUMBER}}', quote.quote_number) .replace('{{ACCOUNT_NUMBER}}', quote.account_number || '') .replace('{{QUOTE_DATE}}', formatDate(quote.quote_date)) .replace('{{ITEMS}}', itemsHTML) .replace('{{TBD_NOTE}}', tbdNote); const 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' } }); await page.close(); res.set({ 'Content-Type': 'application/pdf', 'Content-Length': pdf.length, 'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"` }); res.end(pdf, 'binary'); console.log('[PDF] Quote PDF sent successfully'); } catch (error) { console.error('[PDF] ERROR:', error); res.status(500).json({ error: 'Error generating PDF', details: error.message }); } }); 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.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'); 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 = ``; } catch (err) {} 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); } return ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); itemsHTML += ` Subtotal: $${formatMoney(invoice.subtotal)} `; if (!invoice.tax_exempt) { itemsHTML += ` Tax (${invoice.tax_rate}%): $${formatMoney(invoice.tax_amount)} `; } const amountPaid = parseFloat(invoice.amount_paid) || 0; const balanceDue = parseFloat(invoice.total) - amountPaid; itemsHTML += ` TOTAL: $${formatMoney(invoice.total)} `; if (amountPaid > 0) { itemsHTML += ` Downpayment: -$${formatMoney(amountPaid)} BALANCE DUE: $${formatMoney(balanceDue)} `; } itemsHTML += ` Thank you for your business! `; const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; // --- 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); const streetBlock = addressLines.join('
'); 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 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' } }); await page.close(); 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 }); } }); // HTML Debug Endpoints 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.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number FROM quotes q LEFT JOIN customers c ON q.customer_id = c.id WHERE q.id = $1 `, [id]); if (quoteResult.rows.length === 0) { return res.status(404).json({ error: 'Quote not found' }); } const quote = quoteResult.rows[0]; const itemsResult = await pool.query( 'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order', [id] ); const templatePath = path.join(__dirname, 'templates', 'quote-template.html'); let html = await fs.readFile(templatePath, 'utf-8'); 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 = ``; } catch (err) {} 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); } return ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); itemsHTML += ` Subtotal: $${formatMoney(quote.subtotal)} `; if (!quote.tax_exempt) { itemsHTML += ` Tax (${quote.tax_rate}%): $${formatMoney(quote.tax_amount)} `; } itemsHTML += ` TOTAL: $${formatMoney(quote.total)} Thank you for your business! `; let tbdNote = quote.has_tbd ? '

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

' : ''; // --- 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('
'); html = html .replace('{{LOGO_HTML}}', logoHTML) .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') .replace('{{CUSTOMER_STREET}}', streetBlock) .replace('{{CUSTOMER_CITY}}', quote.city || '') .replace('{{CUSTOMER_STATE}}', quote.state || '') .replace('{{CUSTOMER_ZIP}}', quote.zip_code || '') .replace('{{QUOTE_NUMBER}}', quote.quote_number) .replace('{{ACCOUNT_NUMBER}}', quote.account_number || '') .replace('{{QUOTE_DATE}}', formatDate(quote.quote_date)) .replace('{{ITEMS}}', itemsHTML) .replace('{{TBD_NOTE}}', tbdNote); res.setHeader('Content-Type', 'text/html'); res.send(html); } catch (error) { console.error('[HTML] ERROR:', error); res.status(500).json({ error: 'Error generating HTML' }); } }); 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.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'); 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 = ``; } catch (err) {} 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); } return ` ${item.quantity} ${item.description} ${rateFormatted} ${item.amount} `; }).join(''); itemsHTML += ` Subtotal: $${formatMoney(invoice.subtotal)} `; if (!invoice.tax_exempt) { itemsHTML += ` Tax (${invoice.tax_rate}%): $${formatMoney(invoice.tax_amount)} `; } const amountPaid = parseFloat(invoice.amount_paid) || 0; const balanceDue = parseFloat(invoice.total) - amountPaid; itemsHTML += ` TOTAL: $${formatMoney(invoice.total)} `; if (amountPaid > 0) { itemsHTML += ` Downpayment: -$${formatMoney(amountPaid)} BALANCE DUE: $${formatMoney(balanceDue)} `; } itemsHTML += ` Thank you for your business! `; const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; // --- 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('
'); 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' }); } }); // QBO Export Endpoint app.post('/api/invoices/:id/export', async (req, res) => { const { id } = req.params; const client = await pool.connect(); // IDs für deine Items (Labor / Parts) const QBO_LABOR_ID = '5'; const QBO_PARTS_ID = '9'; 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 const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]); const items = itemsRes.rows; // 3. QBO Client vorbereiten 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'; // --- SCHRITT 3a: NÄCHSTE NUMMER ERMITTELN (lokal) --- // Wir nehmen das Maximum aus qbo_doc_number UND invoice_number 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(); console.log(`✅ Nächste Nummer (aus lokaler DB): ${nextDocNumber}`); // 4. QBO JSON bauen 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 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 || "" } }; // 5. An QBO senden — mit Retry bei Duplicate 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; // Prüfe auf Duplicate Error (code 6140) 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; // Erfolg! } 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}`); // 6. DB Update 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(); } }); app.get('/api/qbo/overdue', async (req, res) => { try { // Datum vor 30 Tagen berechnen const date = new Date(); date.setDate(date.getDate() - 30); const dateStr = date.toISOString().split('T')[0]; console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`); // Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`; 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'; // makeQboApiCall kümmert sich um den Refresh, falls nötig! const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const invoices = data.QueryResponse?.Invoice || []; console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`); res.json(invoices); } catch (error) { console.error("QBO Report Error:", error); res.status(500).json({ error: error.message }); } }); // Schritt 1: User klickt "Authorize" → Redirect zu Intuit app.get('/auth/qbo', (req, res) => { const client = getOAuthClient(); const authUri = client.authorizeUri({ scope: [OAuthClient.scopes.Accounting], state: 'intuit-qbo-auth' }); console.log('🔗 Redirecting to QBO Authorization:', authUri); res.redirect(authUri); }); // Schritt 2: Intuit redirected zurück mit Code → Token holen app.get('/auth/qbo/callback', async (req, res) => { const client = getOAuthClient(); try { const authResponse = await client.createToken(req.url); console.log('✅ QBO Authorization erfolgreich!'); saveTokens(); // Redirect zurück zur App (Settings Tab) res.redirect('/#settings'); } catch (e) { console.error('❌ QBO Authorization fehlgeschlagen:', e); res.status(500).send(`

QBO Authorization Failed

${e.message || e}

Zurück zur App `); } }); // Status-Check Endpoint (für die UI) app.get('/api/qbo/status', (req, res) => { try { const client = getOAuthClient(); const token = client.getToken(); const hasToken = !!(token && token.refresh_token); res.json({ connected: hasToken, realmId: token?.realmId || null }); } catch (e) { res.json({ connected: false }); } }); app.post('/api/qbo/import-unpaid', async (req, res) => { const dbClient = await pool.connect(); try { 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'; // 1. Alle unbezahlten Rechnungen aus QBO holen // Balance > '0' = noch nicht vollständig bezahlt // MAXRESULTS 1000 = sicherheitshalber hoch setzen console.log('📥 QBO Import: Lade unbezahlte Rechnungen...'); const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const qboInvoices = data.QueryResponse?.Invoice || []; console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`); if (qboInvoices.length === 0) { return res.json({ success: true, imported: 0, skipped: 0, skippedNoCustomer: 0, message: 'Keine unbezahlten Rechnungen in QBO gefunden.' }); } // 2. Lokale Kunden laden (die mit QBO verknüpft sind) const customersResult = await dbClient.query( 'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL' ); const customerMap = new Map(); customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c)); // 3. Bereits importierte QBO-Rechnungen ermitteln (nach qbo_id) const existingResult = await dbClient.query( 'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL' ); const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id)); // 4. Import durchführen let imported = 0; let skipped = 0; let skippedNoCustomer = 0; const skippedCustomerNames = []; await dbClient.query('BEGIN'); for (const qboInv of qboInvoices) { const qboId = String(qboInv.Id); // Bereits importiert? → Überspringen if (existingQboIds.has(qboId)) { skipped++; continue; } // Kunde lokal vorhanden? const customerQboId = String(qboInv.CustomerRef?.value || ''); const localCustomer = customerMap.get(customerQboId); if (!localCustomer) { skippedNoCustomer++; const custName = qboInv.CustomerRef?.name || 'Unbekannt'; if (!skippedCustomerNames.includes(custName)) { skippedCustomerNames.push(custName); } continue; } // Werte aus QBO-Rechnung extrahieren const docNumber = qboInv.DocNumber || ''; const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0]; const syncToken = qboInv.SyncToken || ''; // Terms aus QBO mappen (SalesTermRef) let terms = 'Net 30'; if (qboInv.SalesTermRef?.name) { terms = qboInv.SalesTermRef.name; } // Tax: Prüfen ob TaxLine vorhanden const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0; const taxExempt = taxAmount === 0; // Subtotal berechnen (Total - Tax) const total = parseFloat(qboInv.TotalAmt) || 0; const subtotal = total - taxAmount; const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25; // Memo als auth_code (falls vorhanden) const authCode = qboInv.CustomerMemo?.value || ''; // Rechnung einfügen const invoiceResult = await dbClient.query( `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, qbo_id, qbo_sync_token, qbo_doc_number) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, [docNumber, localCustomer.id, txnDate, terms, authCode, taxExempt, taxRate, subtotal, taxAmount, total, qboId, syncToken, docNumber] ); const localInvoiceId = invoiceResult.rows[0].id; // Line Items importieren const lines = qboInv.Line || []; let itemOrder = 0; for (const line of lines) { // Nur SalesItemLineDetail-Zeilen (keine SubTotalLine etc.) if (line.DetailType !== 'SalesItemLineDetail') continue; const detail = line.SalesItemLineDetail || {}; const qty = String(detail.Qty || 1); const rate = String(detail.UnitPrice || 0); const amount = String(line.Amount || 0); const description = line.Description || ''; // Item-Typ ermitteln (Labor=5, Parts=9) const itemRefValue = detail.ItemRef?.value || '9'; const itemRefName = (detail.ItemRef?.name || '').toLowerCase(); let qboItemId = '9'; // Default: Parts if (itemRefValue === '5' || itemRefName.includes('labor')) { qboItemId = '5'; } await dbClient.query( `INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId] ); itemOrder++; } imported++; console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`); } await dbClient.query('COMMIT'); const message = [ `${imported} Rechnungen importiert.`, skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '', skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : '' ].filter(Boolean).join(' '); console.log(`📥 QBO Import abgeschlossen: ${message}`); res.json({ success: true, imported, skipped, skippedNoCustomer, skippedCustomerNames, message }); } catch (error) { await dbClient.query('ROLLBACK'); console.error('❌ QBO Import Error:', error); res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message }); } finally { dbClient.release(); } }); // Mark invoice as paid app.patch('/api/invoices/:id/mark-paid', async (req, res) => { const { id } = req.params; const { paid_date } = req.body; // Optional: explizites Datum, sonst heute 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' }); } }); // Mark invoice as unpaid app.patch('/api/invoices/: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' }); } }); app.patch('/api/invoices/: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' }); } }); // ===================================================== // QBO PAYMENT ENDPOINTS v3 — In server.js einfügen // - Invoice payments (multi, partial, overpay) // - Downpayment (separate endpoint, called from customer view) // - Customer credit query // ===================================================== // --- Bank-Konten aus QBO --- app.get('/api/qbo/accounts', async (req, res) => { try { 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'; const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- Payment Methods aus QBO --- app.get('/api/qbo/payment-methods', async (req, res) => { try { 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'; const query = "SELECT * FROM PaymentMethod WHERE Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // --- Record Payment (against invoices) --- app.post('/api/qbo/record-payment', async (req, res) => { const { invoice_payments, // [{ invoice_id, amount }] payment_date, reference_number, payment_method_id, payment_method_name, deposit_to_account_id, deposit_to_account_name } = req.body; if (!invoice_payments || invoice_payments.length === 0) { return res.status(400).json({ error: 'No invoices selected.' }); } const dbClient = await pool.connect(); try { 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'; const ids = invoice_payments.map(ip => ip.invoice_id); const result = await dbClient.query( `SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = ANY($1)`, [ids] ); const invoicesData = result.rows; const notInQbo = invoicesData.filter(inv => !inv.qbo_id); if (notInQbo.length > 0) { return res.status(400).json({ error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}` }); } const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))]; if (custIds.length > 1) { return res.status(400).json({ error: 'All invoices must belong to the same customer.' }); } const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)])); const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0); const qboPayment = { CustomerRef: { value: custIds[0] }, TotalAmt: totalAmt, TxnDate: payment_date, PaymentRefNum: reference_number || '', PaymentMethodRef: { value: payment_method_id }, DepositToAccountRef: { value: deposit_to_account_id }, Line: invoicesData.map(inv => ({ Amount: paymentMap.get(inv.id) || parseFloat(inv.total), LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }] })) }; console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`); const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/payment`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(qboPayment) }); const data = response.getJson ? response.getJson() : response.json; if (!data.Payment) { return res.status(500).json({ error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)) }); } const qboPaymentId = data.Payment.Id; console.log(`✅ QBO Payment ID: ${qboPaymentId}`); await dbClient.query('BEGIN'); const payResult = await dbClient.query( `INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, [payment_date, reference_number || null, payment_method_name || 'Check', deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId] ); const localPaymentId = payResult.rows[0].id; for (const ip of invoice_payments) { const payAmt = parseFloat(ip.amount); const inv = invoicesData.find(i => i.id === ip.invoice_id); const invTotal = inv ? parseFloat(inv.total) : 0; await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [localPaymentId, ip.invoice_id, payAmt] ); if (payAmt >= invTotal) { await dbClient.query( 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [payment_date, ip.invoice_id] ); } } await dbClient.query('COMMIT'); res.json({ success: true, payment_id: localPaymentId, qbo_payment_id: qboPaymentId, total: totalAmt, invoices_paid: invoice_payments.length, message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).` }); } catch (error) { await dbClient.query('ROLLBACK').catch(() => {}); console.error('❌ Payment Error:', error); res.status(500).json({ error: 'Payment failed: ' + error.message }); } finally { dbClient.release(); } }); // ===================================================== // QBO INVOICE UPDATE — Sync local changes to QBO // ===================================================== // Aktualisiert eine bereits exportierte Invoice in QBO. // Benötigt qbo_id + qbo_sync_token (Optimistic Locking). // Sendet alle Items neu (QBO ersetzt die Line-Items komplett). app.post('/api/invoices/: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 { // 1. Lokale Rechnung + Items laden 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; // 2. QBO vorbereiten 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'; // 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen 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}`); // 4. Line Items bauen 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 } }; }); // 5. QBO Update Payload — sparse update // Id + SyncToken sind Pflicht. Alles was mitgesendet wird, wird aktualisiert. 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}`); // 6. Neuen SyncToken lokal speichern 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(); } }); // --- List local payments --- app.get('/api/payments', async (req, res) => { try { const result = await pool.query(` SELECT p.*, c.name as customer_name, COALESCE(json_agg(json_build_object( 'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number )) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices FROM payments p LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN payment_invoices pi ON pi.payment_id = p.id LEFT JOIN invoices i ON i.id = pi.invoice_id GROUP BY p.id, c.name ORDER BY p.payment_date DESC `); res.json(result.rows); } catch (error) { res.status(500).json({ error: 'Error fetching payments' }); } }); // ===================================================== // Neue Server Endpoints — In server.js einfügen // 1. Customer QBO Export // 2. Labor Rate aus QBO // ===================================================== // --- 1. Kunde nach QBO exportieren --- app.post('/api/customers/:id/export-qbo', async (req, res) => { const { id } = req.params; try { const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' }); const customer = custResult.rows[0]; if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' }); 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'; const qboCustomer = { DisplayName: customer.name, CompanyName: customer.name, PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined, PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined, Taxable: customer.taxable !== false, Notes: customer.remarks || undefined }; // Contact if (customer.contact) { const parts = customer.contact.trim().split(/\s+/); if (parts.length >= 2) { qboCustomer.GivenName = parts[0]; qboCustomer.FamilyName = parts.slice(1).join(' '); } else { qboCustomer.GivenName = parts[0]; } } // Address const addr = {}; if (customer.line1) addr.Line1 = customer.line1; if (customer.line2) addr.Line2 = customer.line2; if (customer.line3) addr.Line3 = customer.line3; if (customer.line4) addr.Line4 = customer.line4; if (customer.city) addr.City = customer.city; if (customer.state) addr.CountrySubDivisionCode = customer.state; if (customer.zip_code) addr.PostalCode = customer.zip_code; if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/customer`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(qboCustomer) }); const data = response.getJson ? response.getJson() : response.json; const qboId = data.Customer?.Id; if (!qboId) throw new Error('QBO returned no ID'); await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]); console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`); res.json({ success: true, qbo_id: qboId, name: customer.name }); } catch (error) { console.error('QBO Customer Export Error:', error); res.status(500).json({ error: 'Export failed: ' + error.message }); } }); // --- 2. Labor Rate aus QBO laden --- // Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO app.get('/api/qbo/labor-rate', async (req, res) => { try { 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'; // Item ID 5 = Labor const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/item/5`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const rate = data.Item?.UnitPrice || null; console.log(`💰 QBO Labor Rate: $${rate}`); res.json({ rate }); } catch (error) { console.error('Error fetching labor rate:', error); // Nicht kritisch — Fallback auf Frontend-Default res.json({ rate: null }); } }); // --- 3. Sync Payments from QBO --- // Prüft alle offenen lokalen Invoices gegen QBO. // Aktualisiert paid_date und payment_status (Paid/Deposited). app.post('/api/qbo/sync-payments', async (req, res) => { const dbClient = await pool.connect(); try { // Alle lokalen Invoices die in QBO sind und noch aktualisiert werden könnten // Auch bereits bezahlte prüfen um payment_status zu korrigieren (Paid↔Deposited) const openResult = await dbClient.query(` SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid FROM invoices i WHERE i.qbo_id IS NOT NULL `); const openInvoices = openResult.rows; if (openInvoices.length === 0) { await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]); return res.json({ synced: 0, message: 'All invoices up to date.' }); } 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'; // QBO Invoices in Batches laden (max 50 IDs pro Query) const batchSize = 50; const qboInvoices = new Map(); for (let i = 0; i < openInvoices.length; i += batchSize) { const batch = openInvoices.slice(i, i + batchSize); const ids = batch.map(inv => `'${inv.qbo_id}'`).join(','); const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const invoices = data.QueryResponse?.Invoice || []; invoices.forEach(inv => qboInvoices.set(inv.Id, inv)); } console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`); let updated = 0; let newPayments = 0; await dbClient.query('BEGIN'); for (const localInv of openInvoices) { const qboInv = qboInvoices.get(localInv.qbo_id); if (!qboInv) continue; const qboBalance = parseFloat(qboInv.Balance) || 0; const qboTotal = parseFloat(qboInv.TotalAmt) || 0; const localPaid = parseFloat(localInv.local_paid) || 0; // Prüfe ob in QBO bezahlt/teilweise bezahlt if (qboBalance === 0 && qboTotal > 0) { // Voll bezahlt in QBO const UNDEPOSITED_FUNDS_ID = '221'; let status = 'Paid'; if (qboInv.LinkedTxn) { for (const txn of qboInv.LinkedTxn) { if (txn.TxnType === 'Payment') { try { const pmRes = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`, method: 'GET' }); const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json; const payment = pmData.Payment; if (payment && payment.DepositToAccountRef && payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) { status = 'Deposited'; } } catch (e) { /* ignore */ } } } } // Update wenn sich etwas geändert hat const needsUpdate = !localInv.paid_date || localInv.payment_status !== status; if (needsUpdate) { await dbClient.query( `UPDATE invoices SET paid_date = COALESCE(paid_date, CURRENT_DATE), payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [status, localInv.id] ); updated++; console.log(` ✅ #${localInv.invoice_number}: ${status}`); } // Fehlenden Payment-Eintrag NUR erstellen wenn Differenz > $0.01 const diff = qboTotal - localPaid; if (diff > 0.01) { const payResult = await dbClient.query( `INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at) VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP) RETURNING id`, [diff, localInv.id] ); await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [payResult.rows[0].id, localInv.id, diff] ); newPayments++; console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`); } } else if (qboBalance > 0 && qboBalance < qboTotal) { // Teilweise bezahlt in QBO const qboPaid = qboTotal - qboBalance; const diff = qboPaid - localPaid; const needsUpdate = localInv.payment_status !== 'Partial'; if (needsUpdate) { await dbClient.query( 'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', ['Partial', localInv.id] ); updated++; } // Payment nur erstellen wenn echte Differenz > $0.01 if (diff > 0.01) { const payResult = await dbClient.query( `INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at) VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP) RETURNING id`, [diff, localInv.id] ); await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [payResult.rows[0].id, localInv.id, diff] ); newPayments++; console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`); } } } // Last sync timestamp speichern await dbClient.query(` INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1) ON CONFLICT (key) DO UPDATE SET value = $1 `, [new Date().toISOString()]); await dbClient.query('COMMIT'); console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`); res.json({ synced: updated, new_payments: newPayments, total_checked: openInvoices.length, message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.` }); } catch (error) { await dbClient.query('ROLLBACK').catch(() => {}); console.error('❌ Sync Error:', error); res.status(500).json({ error: 'Sync failed: ' + error.message }); } finally { dbClient.release(); } }); // --- 4. Last sync timestamp --- app.get('/api/qbo/last-sync', async (req, res) => { try { const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'"); res.json({ last_sync: result.rows[0]?.value || null }); } catch (error) { res.json({ last_sync: null }); } }); // Start server and browser async function startServer() { await initBrowser(); app.listen(PORT, () => { console.log(`Quote System running on port ${PORT}`); }); } startServer(); // Graceful shutdown process.on('SIGTERM', async () => { if (browser) { await browser.close(); } await pool.end(); process.exit(0); });