diff --git a/public/js/modals/invoice-modal.js b/public/js/modals/invoice-modal.js index c1265db..9aa7937 100644 --- a/public/js/modals/invoice-modal.js +++ b/public/js/modals/invoice-modal.js @@ -465,6 +465,18 @@ export async function handleInvoiceSubmit(e) { 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)'); + + // Ein fehlgeschlagener QBO-Sync wurde frueher nur in die Konsole + // geschrieben. Folge: Die Rechnung stand lokal korrekt da, in QBO aber + // unvollstaendig — der Kunde zahlte den vollen Betrag, und die Differenz + // blieb als Guthaben haengen. Deshalb jetzt sichtbar melden. + if (result.qbo_error) { + alert('⚠️ Invoice saved locally, but QuickBooks was NOT updated:\n\n' + + result.qbo_error + + '\n\nThe QBO invoice may be missing or incomplete. Please check it in QBO.'); + } else if (result.qbo_skipped) { + console.log(`ℹ️ QBO skipped: ${result.qbo_skipped}`); + } if (window.invoiceView) window.invoiceView.loadInvoices(); } else { alert(`Error: ${result.error}`); diff --git a/src/routes/invoices.js b/src/routes/invoices.js index bf1fb98..1f45c22 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -10,7 +10,7 @@ const { pool } = require('../config/database'); const { getNextInvoiceNumber } = require('../utils/numberGenerators'); const { formatDate, formatMoney } = require('../utils/helpers'); const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service'); -const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service'); +const { exportInvoiceToQbo, syncInvoiceToQbo, buildQboLineItems } = require('../services/qbo-service'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); const { sendInvoiceEmail } = require('../services/email-service'); const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service'); @@ -241,19 +241,27 @@ router.post('/', async (req, res) => { // Auto QBO Export let qboResult = null; + let qboError = null; try { qboResult = await exportInvoiceToQbo(invoiceId, client); if (qboResult.skipped) { console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`); } } catch (qboErr) { + // Der Fehler wird an den Client durchgereicht, nicht nur geloggt: + // Ein stillschweigend fehlgeschlagener Export bedeutet, dass QBO die + // Rechnung nicht oder unvollstaendig kennt — genau das ist monatelang + // unbemerkt geblieben und hat zu abweichenden QBO-Summen gefuehrt. + qboError = qboErr.message; console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message); } res.json({ ...invoiceResult.rows[0], qbo_id: qboResult?.qbo_id || null, - qbo_doc_number: qboResult?.qbo_doc_number || null + qbo_doc_number: qboResult?.qbo_doc_number || null, + qbo_error: qboError, + qbo_skipped: qboResult?.skipped ? qboResult.reason : null }); } catch (error) { @@ -386,6 +394,7 @@ router.put('/:id', async (req, res) => { // Auto QBO: Export if not yet in QBO, Sync if already in QBO let qboResult = null; + let qboError = null; try { const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]); const hasQboId = !!checkRes.rows[0]?.qbo_id; @@ -400,10 +409,20 @@ router.put('/:id', async (req, res) => { console.log(`ℹ️ Invoice ${id}: ${qboResult.reason}`); } } catch (qboErr) { + // Siehe POST: ein fehlgeschlagener Sync darf nicht unsichtbar bleiben, + // sonst laufen lokale Rechnung und QBO-Rechnung auseinander. + qboError = qboErr.message; 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 }); + res.json({ + success: true, + qbo_synced: !!qboResult?.success, + qbo_id: qboResult?.qbo_id || null, + qbo_doc_number: qboResult?.qbo_doc_number || null, + qbo_error: qboError, + qbo_skipped: qboResult?.skipped ? qboResult.reason : null + }); } catch (error) { await client.query('ROLLBACK'); @@ -647,25 +666,9 @@ router.post('/:id/export', async (req, res) => { `); let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString(); - const lineItems = items.map(item => { - const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; - const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; - const itemRefId = item.qbo_item_id || '9'; - const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" - : itemRefId == '115' ? "Subscription" - : "Parts:Parts"; - - return { - "DetailType": "SalesItemLineDetail", - "Amount": amount, - "Description": item.description, - "SalesItemLineDetail": { - "ItemRef": { "value": itemRefId, "name": itemRefName }, - "UnitPrice": rate, - "Qty": parseFloat(item.quantity) || 1 - } - }; - }); + // Eine gemeinsame Zeilenerzeugung fuer alle vier QBO-Pfade — siehe + // buildQboLineItems() in qbo-service.js. + const lineItems = buildQboLineItems(items); const qboInvoicePayload = { "CustomerRef": { "value": invoice.customer_qbo_id }, @@ -782,25 +785,9 @@ router.post('/:id/update-qbo', async (req, res) => { const currentSyncToken = currentQboInvoice.SyncToken; console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`); - const lineItems = items.map(item => { - const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; - const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; - const itemRefId = item.qbo_item_id || QBO_PARTS_ID; - const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" - : itemRefId == '115' ? "Subscription" - : "Parts:Parts"; - - return { - "DetailType": "SalesItemLineDetail", - "Amount": amount, - "Description": item.description, - "SalesItemLineDetail": { - "ItemRef": { "value": itemRefId, "name": itemRefName }, - "UnitPrice": rate, - "Qty": parseFloat(item.quantity) || 1 - } - }; - }); + // Eine gemeinsame Zeilenerzeugung fuer alle vier QBO-Pfade — siehe + // buildQboLineItems() in qbo-service.js. + const lineItems = buildQboLineItems(items); const updatePayload = { "Id": invoice.qbo_id, diff --git a/src/services/qbo-service.js b/src/services/qbo-service.js index 38ede43..ca3e8fe 100644 --- a/src/services/qbo-service.js +++ b/src/services/qbo-service.js @@ -17,6 +17,57 @@ function getClientInfo() { return { oauthClient, companyId, baseUrl }; } +/** + * Baut die QBO-Zeilen einer Rechnung/eines Angebots aus den lokalen Positionen. + * + * WICHTIG -- massgeblich ist der gespeicherte `amount`, NICHT rate x quantity. + * Frueher wurde der Betrag hier neu multipliziert, und das ist in beide + * Richtungen schiefgegangen: + * - Position ohne Rate (Pauschalbetrag, nur `amount` gefuellt) ging mit + * Amount 0 nach QBO -- die QBO-Rechnung war zu niedrig, der Kunde zahlte + * den vollen Betrag, und die Differenz blieb als unangewendetes Guthaben + * am Kunden haengen (real passiert: 110718 mit $1.000, 110594 mit $0,56). + * - Position mit Rate, aber bewusst auf 0 gesetztem Betrag ("no charge") + * waere mit dem vollen Rate-Betrag nach QBO gegangen. + * `amount` ist die Zahl, aus der sich lokal Subtotal und Steuer bilden und die + * auf dem Ausdruck steht, den der Kunde bekommt. QBO muss dieselbe sehen. + * + * Rate und Menge werden nur dann als UnitPrice/Qty uebergeben, wenn sie zum + * Betrag passen. Andernfalls wuerde QBO eine in sich widerspruechliche Zeile + * anzeigen (0,75 x $125 = $0,00); dann geht die Zeile als Menge 1 zum + * Einzelpreis = Betrag hinaus. + */ +function buildQboLineItems(items) { + 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; + }; + + return items.map(item => { + const amount = parseNum(item.amount); + const rate = parseNum(item.rate); + const qty = parseNum(item.quantity) || 1; + const consistent = Math.abs(rate * qty - amount) < 0.005; + + const itemRefId = item.qbo_item_id || QBO_PARTS_ID; + const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" + : itemRefId == QBO_SUBSCRIPTION_ID ? "Subscription" + : "Parts:Parts"; + + return { + "DetailType": "SalesItemLineDetail", + "Amount": amount, + "Description": item.description, + "SalesItemLineDetail": { + "ItemRef": { "value": itemRefId, "name": itemRefName }, + "UnitPrice": consistent ? rate : amount, + "Qty": consistent ? qty : 1 + } + }; + }); +} + /** * Export invoice to QBO */ @@ -45,31 +96,7 @@ async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbCl `); 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" - : itemRefId == QBO_SUBSCRIPTION_ID ? "Subscription" - : "Parts:Parts"; - - return { - "DetailType": "SalesItemLineDetail", - "Amount": amount, - "Description": item.description, - "SalesItemLineDetail": { - "ItemRef": { "value": itemRefId, "name": itemRefName }, - "UnitPrice": rate, - "Qty": qty - } - }; - }); + const lineItems = buildQboLineItems(items); const qboPayload = { "CustomerRef": { "value": invoice.customer_qbo_id }, @@ -164,31 +191,7 @@ async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClie if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO'); - const lineItems = itemsRes.rows.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" - : itemRefId == QBO_SUBSCRIPTION_ID ? "Subscription" - : "Parts:Parts"; - - return { - "DetailType": "SalesItemLineDetail", - "Amount": amount, - "Description": item.description, - "SalesItemLineDetail": { - "ItemRef": { "value": itemRefId, "name": itemRefName }, - "UnitPrice": rate, - "Qty": qty - } - }; - }); + const lineItems = buildQboLineItems(itemsRes.rows); const updatePayload = { "Id": invoice.qbo_id, @@ -377,6 +380,7 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, }; } module.exports = { + buildQboLineItems, QBO_LABOR_ID, QBO_PARTS_ID, QBO_SUBSCRIPTION_ID,