This commit is contained in:
2026-08-19 22:19:46 +02:00
parent b644253e0b
commit 1bb38ba02d
3 changed files with 94 additions and 91 deletions

View File

@@ -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}`);

View File

@@ -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,

View File

@@ -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,