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}`);
|
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 if (result.qbo_synced) console.log('✅ Invoice saved & synced to QBO');
|
||||||
else console.log('✅ Invoice saved locally (QBO sync pending)');
|
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();
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
} else {
|
} else {
|
||||||
alert(`Error: ${result.error}`);
|
alert(`Error: ${result.error}`);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { pool } = require('../config/database');
|
|||||||
const { getNextInvoiceNumber } = require('../utils/numberGenerators');
|
const { getNextInvoiceNumber } = require('../utils/numberGenerators');
|
||||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
|
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 { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { sendInvoiceEmail } = require('../services/email-service');
|
const { sendInvoiceEmail } = require('../services/email-service');
|
||||||
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
||||||
@@ -241,19 +241,27 @@ router.post('/', async (req, res) => {
|
|||||||
|
|
||||||
// Auto QBO Export
|
// Auto QBO Export
|
||||||
let qboResult = null;
|
let qboResult = null;
|
||||||
|
let qboError = null;
|
||||||
try {
|
try {
|
||||||
qboResult = await exportInvoiceToQbo(invoiceId, client);
|
qboResult = await exportInvoiceToQbo(invoiceId, client);
|
||||||
if (qboResult.skipped) {
|
if (qboResult.skipped) {
|
||||||
console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
|
console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
|
||||||
}
|
}
|
||||||
} catch (qboErr) {
|
} 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);
|
console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...invoiceResult.rows[0],
|
...invoiceResult.rows[0],
|
||||||
qbo_id: qboResult?.qbo_id || null,
|
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) {
|
} 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
|
// Auto QBO: Export if not yet in QBO, Sync if already in QBO
|
||||||
let qboResult = null;
|
let qboResult = null;
|
||||||
|
let qboError = null;
|
||||||
try {
|
try {
|
||||||
const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
|
const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
|
||||||
const hasQboId = !!checkRes.rows[0]?.qbo_id;
|
const hasQboId = !!checkRes.rows[0]?.qbo_id;
|
||||||
@@ -400,10 +409,20 @@ router.put('/:id', async (req, res) => {
|
|||||||
console.log(`ℹ️ Invoice ${id}: ${qboResult.reason}`);
|
console.log(`ℹ️ Invoice ${id}: ${qboResult.reason}`);
|
||||||
}
|
}
|
||||||
} catch (qboErr) {
|
} 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);
|
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) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
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();
|
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||||||
|
|
||||||
const lineItems = items.map(item => {
|
// Eine gemeinsame Zeilenerzeugung fuer alle vier QBO-Pfade — siehe
|
||||||
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
// buildQboLineItems() in qbo-service.js.
|
||||||
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
const lineItems = buildQboLineItems(items);
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const qboInvoicePayload = {
|
const qboInvoicePayload = {
|
||||||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||||
@@ -782,25 +785,9 @@ router.post('/:id/update-qbo', async (req, res) => {
|
|||||||
const currentSyncToken = currentQboInvoice.SyncToken;
|
const currentSyncToken = currentQboInvoice.SyncToken;
|
||||||
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
|
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
|
||||||
|
|
||||||
const lineItems = items.map(item => {
|
// Eine gemeinsame Zeilenerzeugung fuer alle vier QBO-Pfade — siehe
|
||||||
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
// buildQboLineItems() in qbo-service.js.
|
||||||
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
const lineItems = buildQboLineItems(items);
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
"Id": invoice.qbo_id,
|
"Id": invoice.qbo_id,
|
||||||
|
|||||||
@@ -17,6 +17,57 @@ function getClientInfo() {
|
|||||||
return { oauthClient, companyId, baseUrl };
|
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
|
* 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();
|
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||||||
|
|
||||||
const lineItems = items.map(item => {
|
const lineItems = 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;
|
|
||||||
};
|
|
||||||
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 qboPayload = {
|
const qboPayload = {
|
||||||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
"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');
|
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
||||||
|
|
||||||
const lineItems = itemsRes.rows.map(item => {
|
const lineItems = buildQboLineItems(itemsRes.rows);
|
||||||
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 updatePayload = {
|
const updatePayload = {
|
||||||
"Id": invoice.qbo_id,
|
"Id": invoice.qbo_id,
|
||||||
@@ -377,6 +380,7 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
buildQboLineItems,
|
||||||
QBO_LABOR_ID,
|
QBO_LABOR_ID,
|
||||||
QBO_PARTS_ID,
|
QBO_PARTS_ID,
|
||||||
QBO_SUBSCRIPTION_ID,
|
QBO_SUBSCRIPTION_ID,
|
||||||
|
|||||||
Reference in New Issue
Block a user