fix for qbo changes
This commit is contained in:
@@ -14,6 +14,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let oauthClient = null;
|
||||
let _lastSavedAccessToken = null;
|
||||
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||
|
||||
const getOAuthClient = () => {
|
||||
@@ -73,12 +74,15 @@ function saveTokens() {
|
||||
const client = getOAuthClient();
|
||||
const token = client.getToken();
|
||||
|
||||
// Debug: Was genau bekommen wir vom Client?
|
||||
console.log("💾 Speichere Token... refresh_token vorhanden:", !!token.refresh_token,
|
||||
"| access_token Länge:", (token.access_token || '').length,
|
||||
"| realmId:", token.realmId || 'FEHLT');
|
||||
// ── NEU: Nur speichern wenn access_token sich tatsächlich geändert hat ──
|
||||
if (token.access_token === _lastSavedAccessToken) {
|
||||
return; // Token unverändert – kein Save, kein Log
|
||||
}
|
||||
_lastSavedAccessToken = token.access_token;
|
||||
|
||||
const ts = new Date().toISOString().replace('T',' ').substring(0,19);
|
||||
console.log(`[${ts}] 💾 Token changed – saving (realmId: ${token.realmId || 'FEHLT'})`);
|
||||
|
||||
// Sicherstellen dass alle Pflichtfelder vorhanden sind
|
||||
const tokenToSave = {
|
||||
token_type: token.token_type || 'bearer',
|
||||
access_token: token.access_token,
|
||||
@@ -90,14 +94,15 @@ function saveTokens() {
|
||||
};
|
||||
|
||||
fs.writeFileSync(tokenFile, JSON.stringify(tokenToSave, null, 2));
|
||||
console.log("💾 Tokens erfolgreich in qbo_token.json gespeichert.");
|
||||
console.log(`[${ts}] 💾 Token saved to qbo_token.json`);
|
||||
} catch (e) {
|
||||
console.error("❌ Fehler beim Speichern der Tokens:", e.message);
|
||||
console.error(`❌ Fehler beim Speichern der Tokens: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function makeQboApiCall(requestOptions) {
|
||||
const client = getOAuthClient();
|
||||
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
|
||||
|
||||
const currentToken = client.getToken();
|
||||
if (!currentToken || !currentToken.refresh_token) {
|
||||
@@ -105,29 +110,17 @@ async function makeQboApiCall(requestOptions) {
|
||||
}
|
||||
|
||||
const doRefresh = async () => {
|
||||
console.log("🔄 QBO Token Refresh wird ausgeführt...");
|
||||
|
||||
// Den Refresh Token als String extrahieren
|
||||
console.log(`[${ts()}] 🔄 QBO Token Refresh...`);
|
||||
const refreshTokenStr = currentToken.refresh_token;
|
||||
console.log("🔑 Refresh Token (erste 15 Zeichen):", refreshTokenStr.substring(0, 15) + "...");
|
||||
|
||||
try {
|
||||
// KRITISCHER FIX: refreshUsingToken() statt refresh() verwenden!
|
||||
//
|
||||
// refresh() ruft intern validateToken() auf, das bei unvollständigem
|
||||
// Token-Objekt "The Refresh token is invalid" wirft — OHNE jemals
|
||||
// Intuit zu kontaktieren.
|
||||
//
|
||||
// refreshUsingToken() akzeptiert den RT als String und umgeht das.
|
||||
const authResponse = await client.refreshUsingToken(refreshTokenStr);
|
||||
console.log("✅ Token erfolgreich erneuert via refreshUsingToken().");
|
||||
saveTokens();
|
||||
console.log(`[${ts()}] ✅ Token refreshed via refreshUsingToken()`);
|
||||
saveTokens(); // saveTokens prüft selbst ob sich was geändert hat
|
||||
return authResponse;
|
||||
} catch (e) {
|
||||
const errMsg = e.originalMessage || e.message || String(e);
|
||||
console.error("❌ Refresh fehlgeschlagen:", errMsg);
|
||||
if (e.intuit_tid) console.error(" intuit_tid:", e.intuit_tid);
|
||||
|
||||
console.error(`[${ts()}] ❌ Refresh failed: ${errMsg}`);
|
||||
if (e.intuit_tid) console.error(` intuit_tid: ${e.intuit_tid}`);
|
||||
if (errMsg.includes('invalid_grant')) {
|
||||
throw new Error(
|
||||
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
|
||||
@@ -140,35 +133,32 @@ async function makeQboApiCall(requestOptions) {
|
||||
|
||||
try {
|
||||
const response = await client.makeApiCall(requestOptions);
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
||||
if (data.fault && data.fault.error) {
|
||||
const errorCode = data.fault.error[0].code;
|
||||
|
||||
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
||||
console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`);
|
||||
console.log(`[${ts()}] ⚠️ QBO Token-Fehler (${errorCode}) – Refresh & Retry...`);
|
||||
await doRefresh();
|
||||
return await client.makeApiCall(requestOptions);
|
||||
}
|
||||
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
||||
}
|
||||
|
||||
saveTokens();
|
||||
// ── Kein saveTokens() hier – Token hat sich nicht geändert ──
|
||||
return response;
|
||||
|
||||
} catch (e) {
|
||||
const isAuthError =
|
||||
e.response?.status === 401 ||
|
||||
(e.authResponse && e.authResponse.response && e.authResponse.response.status === 401) ||
|
||||
(e.authResponse?.response?.status === 401) ||
|
||||
e.message?.includes('AuthenticationFailed');
|
||||
|
||||
if (isAuthError) {
|
||||
console.log("⚠️ 401 Unauthorized / AuthFailed erhalten. Versuche Refresh und Retry...");
|
||||
console.log(`[${ts()}] ⚠️ 401 – Refresh & Retry...`);
|
||||
await doRefresh();
|
||||
return await client.makeApiCall(requestOptions);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +409,9 @@ router.post('/record-payment', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Timestamp helper - add once at the top of qbo.js after the requires
|
||||
const log = (msg) => console.log(`[${new Date().toISOString().replace('T',' ').substring(0,19)}] ${msg}`);
|
||||
|
||||
// POST sync payments from QBO
|
||||
router.post('/sync-payments', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
@@ -430,6 +433,7 @@ router.post('/sync-payments', async (req, res) => {
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
// ── Batch-fetch all invoices from QBO (max 50 per query) ──────────
|
||||
const batchSize = 50;
|
||||
const qboInvoices = new Map();
|
||||
|
||||
@@ -437,18 +441,59 @@ router.post('/sync-payments', async (req, res) => {
|
||||
const batch = openInvoices.slice(i, i + batchSize);
|
||||
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||
(data.QueryResponse?.Invoice || []).forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||
}
|
||||
|
||||
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
||||
log(`🔍 QBO Sync: ${openInvoices.length} invoices checked, ${qboInvoices.size} loaded from QBO`);
|
||||
|
||||
// ── Collect all unique Payment IDs that need to be fetched ────────
|
||||
// Instead of fetching each payment one-by-one, collect all IDs first
|
||||
// then batch-fetch them in one query per 30 IDs
|
||||
const paymentIdsToFetch = new Set();
|
||||
for (const localInv of openInvoices) {
|
||||
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||
if (!qboInv || parseFloat(qboInv.Balance) !== 0) continue;
|
||||
if (qboInv.LinkedTxn) {
|
||||
for (const txn of qboInv.LinkedTxn) {
|
||||
if (txn.TxnType === 'Payment') paymentIdsToFetch.add(txn.TxnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch all payments in groups of 30
|
||||
const UNDEPOSITED_FUNDS_ID = '221';
|
||||
const paymentDepositMap = new Map(); // paymentId -> isDeposited (bool)
|
||||
|
||||
if (paymentIdsToFetch.size > 0) {
|
||||
log(`💳 Fetching ${paymentIdsToFetch.size} unique payment(s) from QBO...`);
|
||||
const pmIds = [...paymentIdsToFetch];
|
||||
const pmBatchSize = 30;
|
||||
for (let i = 0; i < pmIds.length; i += pmBatchSize) {
|
||||
const batch = pmIds.slice(i, i + pmBatchSize);
|
||||
const pmQuery = `SELECT Id, DepositToAccountRef FROM Payment WHERE Id IN (${batch.map(id => `'${id}'`).join(',')})`;
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(pmQuery)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||
for (const pm of (pmData.QueryResponse?.Payment || [])) {
|
||||
const isDeposited = pm.DepositToAccountRef?.value !== UNDEPOSITED_FUNDS_ID;
|
||||
paymentDepositMap.set(pm.Id, isDeposited);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`⚠️ Payment batch fetch error (non-fatal): ${e.message}`);
|
||||
}
|
||||
}
|
||||
log(`💳 Payment deposit status loaded for ${paymentDepositMap.size} payment(s)`);
|
||||
}
|
||||
|
||||
// ── Process invoices ───────────────────────────────────────────────
|
||||
let updated = 0;
|
||||
let newPayments = 0;
|
||||
|
||||
@@ -463,30 +508,18 @@ router.post('/sync-payments', async (req, res) => {
|
||||
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||
|
||||
if (qboBalance === 0 && qboTotal > 0) {
|
||||
const UNDEPOSITED_FUNDS_ID = '221';
|
||||
// Determine Paid vs Deposited using pre-fetched map
|
||||
let status = 'Paid';
|
||||
|
||||
if (qboInv.LinkedTxn) {
|
||||
for (const txn of qboInv.LinkedTxn) {
|
||||
if (txn.TxnType === 'Payment') {
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||
const payment = pmData.Payment;
|
||||
if (payment && payment.DepositToAccountRef &&
|
||||
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
|
||||
if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) {
|
||||
status = 'Deposited';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
|
||||
if (needsUpdate) {
|
||||
if (!localInv.paid_date || localInv.payment_status !== status) {
|
||||
await dbClient.query(
|
||||
`UPDATE invoices SET
|
||||
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
||||
@@ -496,14 +529,16 @@ router.post('/sync-payments', async (req, res) => {
|
||||
[status, localInv.id]
|
||||
);
|
||||
updated++;
|
||||
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
|
||||
log(` ✅ #${localInv.invoice_number}: ${status}`);
|
||||
}
|
||||
|
||||
const diff = qboTotal - localPaid;
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
|
||||
(SELECT customer_id FROM invoices WHERE id = $2),
|
||||
'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
@@ -512,15 +547,14 @@ router.post('/sync-payments', async (req, res) => {
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
||||
log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} synced`);
|
||||
}
|
||||
|
||||
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||
const qboPaid = qboTotal - qboBalance;
|
||||
const diff = qboPaid - localPaid;
|
||||
|
||||
const needsUpdate = localInv.payment_status !== 'Partial';
|
||||
if (needsUpdate) {
|
||||
if (localInv.payment_status !== 'Partial') {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['Partial', localInv.id]
|
||||
@@ -531,7 +565,9 @@ router.post('/sync-payments', async (req, res) => {
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
|
||||
(SELECT customer_id FROM invoices WHERE id = $2),
|
||||
'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
@@ -540,7 +576,7 @@ router.post('/sync-payments', async (req, res) => {
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
||||
log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} / $${qboTotal.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,7 +588,7 @@ router.post('/sync-payments', async (req, res) => {
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
||||
log(`✅ Sync complete: ${updated} updated, ${newPayments} new payments`);
|
||||
res.json({
|
||||
synced: updated,
|
||||
new_payments: newPayments,
|
||||
@@ -562,7 +598,7 @@ router.post('/sync-payments', async (req, res) => {
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Sync Error:', error);
|
||||
log(`❌ Sync Error: ${error.message}`);
|
||||
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
|
||||
Reference in New Issue
Block a user