diff --git a/qbo_helper.js b/qbo_helper.js index a9dd7f8..2cde3ce 100644 --- a/qbo_helper.js +++ b/qbo_helper.js @@ -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 = () => { @@ -72,13 +73,16 @@ function saveTokens() { try { 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'); - - // Sicherstellen dass alle Pflichtfelder vorhanden sind + + // ── 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'})`); + const tokenToSave = { token_type: token.token_type || 'bearer', access_token: token.access_token, @@ -88,16 +92,17 @@ function saveTokens() { realmId: token.realmId || process.env.QBO_REALM_ID, createdAt: token.createdAt || new Date().toISOString() }; - + 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) || + const isAuthError = + e.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; } } diff --git a/src/routes/qbo.js b/src/routes/qbo.js index be9b417..da6fe71 100644 --- a/src/routes/qbo.js +++ b/src/routes/qbo.js @@ -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; @@ -459,34 +504,22 @@ router.post('/sync-payments', async (req, res) => { if (!qboInv) continue; const qboBalance = parseFloat(qboInv.Balance) || 0; - const qboTotal = parseFloat(qboInv.TotalAmt) || 0; - const localPaid = parseFloat(localInv.local_paid) || 0; + const qboTotal = parseFloat(qboInv.TotalAmt) || 0; + 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) { - status = 'Deposited'; - } - } catch (e) { /* ignore */ } + if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) { + status = 'Deposited'; + 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();