diff --git a/public/app.js b/public/app.js index 7014739..6790cf7 100644 --- a/public/app.js +++ b/public/app.js @@ -940,61 +940,65 @@ function getInvoiceItems() { async function handleInvoiceSubmit(e) { e.preventDefault(); - - const items = getInvoiceItems(); - - if (items.length === 0) { - alert('Please add at least one item'); - return; - } - - const invoiceNumber = document.getElementById('invoice-number').value.trim(); - - // Invoice Number ist jetzt OPTIONAL - // Wenn angegeben, muss sie numerisch sein - if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) { - alert('Invalid invoice number. Must be a numeric value or left empty.'); - return; - } - + const data = { - invoice_number: invoiceNumber || null, // null wenn leer - customer_id: parseInt(document.getElementById('invoice-customer').value), + invoice_number: document.getElementById('invoice-number').value || null, + customer_id: document.getElementById('invoice-customer').value, invoice_date: document.getElementById('invoice-date').value, terms: document.getElementById('invoice-terms').value, auth_code: document.getElementById('invoice-authorization').value, tax_exempt: document.getElementById('invoice-tax-exempt').checked, - scheduled_send_date: document.getElementById('invoice-send-date').value || null, - items: items + scheduled_send_date: document.getElementById('invoice-send-date')?.value || null, + items: getInvoiceItems() // Deine bestehende Funktion }; - + + if (!data.customer_id) { + alert('Please select a customer.'); + return; + } + if (!data.items || data.items.length === 0) { + alert('Please add at least one item.'); + return; + } + + const invoiceId = currentInvoiceId; + const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices'; + const method = invoiceId ? 'PUT' : 'POST'; + + // Spinner anzeigen + showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...'); + try { - let response; - if (currentInvoiceId) { - response = await fetch(`/api/invoices/${currentInvoiceId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - } else { - response = await fetch('/api/invoices', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - } - + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const result = await response.json(); - + if (response.ok) { closeInvoiceModal(); - loadInvoices(); + + // Info über QBO Status + if (result.qbo_doc_number) { + console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`); + } else if (result.qbo_synced) { + console.log('✅ Invoice saved & synced to QBO'); + } else { + console.log('✅ Invoice saved locally (QBO sync pending)'); + } + + // Invoices neu laden + if (window.invoiceView) window.invoiceView.loadInvoices(); } else { - alert(result.error || 'Error saving invoice'); + alert(`Error: ${result.error}`); } } catch (error) { console.error('Error:', error); alert('Error saving invoice'); + } finally { + hideSpinner(); } } diff --git a/public/customer-view.js b/public/customer-view.js index 843854f..d3f2d62 100644 --- a/public/customer-view.js +++ b/public/customer-view.js @@ -313,6 +313,8 @@ async function handleSubmit(e) { const url = customerId ? `/api/customers/${customerId}` : '/api/customers'; const method = customerId ? 'PUT' : 'POST'; + if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...'); + try { const response = await fetch(url, { method, @@ -330,6 +332,8 @@ async function handleSubmit(e) { } catch (error) { console.error('Error saving customer:', error); alert('Network error saving customer.'); + } finally { + if (typeof hideSpinner === 'function') hideSpinner(); } } diff --git a/public/invoice-view.js b/public/invoice-view.js index b1356cc..5ac9782 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -221,11 +221,11 @@ function renderInvoiceRow(invoice) { const customerHasQbo = !!invoice.customer_qbo_id; let qboBtn; if (hasQbo) { - qboBtn = ``; + qboBtn = `✓ QBO`; } else if (!customerHasQbo) { qboBtn = `QBO ⚠`; } else { - qboBtn = ``; + qboBtn = `QBO pending`; } const pdfBtn = draft diff --git a/server.js b/server.js index 566469a..770a28b 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,9 @@ const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require const app = express(); const PORT = process.env.PORT || 3000; +const QBO_LABOR_ID = '5'; +const QBO_PARTS_ID = '9'; + // Global browser instance let browser = null; @@ -121,6 +124,174 @@ async function getNextInvoiceNumber() { return String(parseInt(result.rows[0].max_number) + 1); } +// --- 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 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 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": "EmailSent", + "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') { + qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString(); + continue; + } + qboInvoice = data.Invoice || data; + if (qboInvoice.Id) break; + throw new Error("QBO returned no ID: " + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))); + } + + 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 => { + const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; + const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; + const itemRefId = item.qbo_item_id || QBO_PARTS_ID; + const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts"; + return { + "DetailType": "SalesItemLineDetail", + "Amount": amount, + "Description": item.description, + "SalesItemLineDetail": { + "ItemRef": { "value": itemRefId, "name": itemRefName }, + "UnitPrice": rate, + "Qty": parseFloat(item.quantity) || 1 + } + }; + }); + + const updatePayload = { + "Id": invoice.qbo_id, + "SyncToken": currentSyncToken, + "sparse": true, + "Line": lineItems, + "CustomerRef": { "value": invoice.customer_qbo_id }, + "TxnDate": invoice.invoice_date.toISOString().split('T')[0], + "CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" } + }; + + console.log(`📤 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; + const updated = updateData.Invoice || updateData; + if (!updated.Id) 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 }; +} + + // Logo endpoints app.get('/api/logo-info', async (req, res) => { try { @@ -584,64 +755,74 @@ app.get('/api/invoices/:id', async (req, res) => { app.post('/api/invoices', async (req, res) => { - const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body; - + const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, created_from_quote_id } = req.body; + const client = await pool.connect(); try { await client.query('BEGIN'); - - // invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben - // Wenn angegeben, muss sie numerisch sein und darf nicht existieren - if (invoice_number && invoice_number.trim() !== '') { - if (!/^\d+$/.test(invoice_number)) { - await client.query('ROLLBACK'); - return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); - } - - const existingInvoice = await client.query( - 'SELECT id FROM invoices WHERE invoice_number = $1', - [invoice_number] - ); - - if (existingInvoice.rows.length > 0) { + + // 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; - } + 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; - - // invoice_number kann NULL sein - const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null; - const sendDate = scheduled_send_date || null; - + 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, scheduled_send_date) + `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, created_from_quote_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, - [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate] + [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || 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'); - res.json(invoiceResult.rows[0]); + + // 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); @@ -720,61 +901,76 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { 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 } = req.body; - + const client = await pool.connect(); try { await client.query('BEGIN'); - - // invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein - const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null; - - if (invNum && !/^\d+$/.test(invNum)) { + + // Invoice-Nummer validieren (falls angegeben) + if (invoice_number && !/^\d+$/.test(invoice_number)) { await client.query('ROLLBACK'); - return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); + return res.status(400).json({ error: 'Invoice number must be numeric.' }); } - - if (invNum) { - const existingInvoice = await client.query( - 'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', - [invNum, id] - ); - if (existingInvoice.rows.length > 0) { + + 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 ${invNum} already exists.` }); + 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; - } + 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 sendDate = scheduled_send_date || null; - - 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, updated_at = CURRENT_TIMESTAMP - WHERE id = $12`, - [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id] - ); - + + // 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, updated_at = CURRENT_TIMESTAMP + WHERE id = $12`, + [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || 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, updated_at = CURRENT_TIMESTAMP + WHERE id = $11`, + [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || 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'); - res.json({ success: true }); + + // Auto QBO Sync (falls bereits in QBO) + let qboResult = null; + try { + qboResult = await syncInvoiceToQbo(id, client); + if (qboResult.skipped) { + console.log(`ℹ️ Invoice ${id} not synced to QBO: ${qboResult.reason}`); + } + } catch (qboErr) { + console.error(`⚠️ Auto QBO sync failed for Invoice ${id}:`, qboErr.message); + } + + res.json({ success: true, qbo_synced: !!qboResult?.success }); + } catch (error) { await client.query('ROLLBACK'); console.error('Error updating invoice:', error); @@ -784,7 +980,6 @@ app.put('/api/invoices/:id', async (req, res) => { } }); - app.delete('/api/invoices/:id', async (req, res) => { const { id } = req.params; const client = await pool.connect();