fix
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user