diff --git a/.gitignore b/.gitignore index 8b693a9..83563e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -*.png \ No newline at end of file +*.png +node_modules +qbo_token.json \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8514486..497ad58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: volumes: - ./public/uploads:/app/public/uploads - ./templates:/app/templates # NEU! + - ./qbo_token.json:/app/qbo_token.json depends_on: postgres: condition: service_healthy diff --git a/public/app.js b/public/app.js index 1fba5fd..75eda88 100644 --- a/public/app.js +++ b/public/app.js @@ -1140,4 +1140,49 @@ async function exportToQBO(id) { btn.textContent = originalText; btn.disabled = false; } +} +async function checkQboOverdue() { + const btn = document.querySelector('button[onclick="checkQboOverdue()"]'); + const resultDiv = document.getElementById('qbo-result'); + const tbody = document.getElementById('qbo-result-list'); + + // UI Loading State + const originalText = btn.innerHTML; + btn.innerHTML = '⏳ Connecting to QBO...'; + btn.disabled = true; + resultDiv.classList.add('hidden'); + tbody.innerHTML = ''; + + try { + const response = await fetch('/api/qbo/overdue'); + const invoices = await response.json(); + + if (response.ok) { + resultDiv.classList.remove('hidden'); + + if (invoices.length === 0) { + tbody.innerHTML = '
Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.
+ + + + diff --git a/qbo_helper.js b/qbo_helper.js index dbc21b4..b32028b 100644 --- a/qbo_helper.js +++ b/qbo_helper.js @@ -1,8 +1,11 @@ // qbo_helper.js require('dotenv').config(); const OAuthClient = require('intuit-oauth'); +const fs = require('fs'); +const path = require('path'); let oauthClient = null; +const tokenFile = path.join(__dirname, 'qbo_token.json'); const getOAuthClient = () => { if (!oauthClient) { @@ -10,27 +13,55 @@ const getOAuthClient = () => { clientId: process.env.QBO_CLIENT_ID, clientSecret: process.env.QBO_CLIENT_SECRET, environment: process.env.QBO_ENVIRONMENT || 'sandbox', - redirectUri: process.env.QBO_REDIRECT_URI, - token: { + redirectUri: process.env.QBO_REDIRECT_URI + }); + + let savedToken = null; + try { + if (fs.existsSync(tokenFile)) { + savedToken = JSON.parse(fs.readFileSync(tokenFile, 'utf8')); + } + } catch (e) { + console.error("❌ Fehler beim Laden des gespeicherten Tokens:", e); + } + + if (savedToken) { + oauthClient.setToken(savedToken); + console.log("✅ Gespeicherter Token geladen."); + } else { + // Fallback auf .env (initiale Tokens) + const envToken = { access_token: process.env.QBO_ACCESS_TOKEN || '', refresh_token: process.env.QBO_REFRESH_TOKEN || '', realmId: process.env.QBO_REALM_ID - } - }); + }; + oauthClient.setToken(envToken); + console.log("ℹ️ Token aus .env geladen (Fallback)."); + } } return oauthClient; }; +function saveTokens() { + try { + const token = getOAuthClient().getToken(); + fs.writeFileSync(tokenFile, JSON.stringify(token, null, 2)); + console.log("💾 Tokens erfolgreich in qbo_token.json gespeichert."); + } catch (e) { + console.error("❌ Fehler beim Speichern der Tokens:", e); + } +} + async function makeQboApiCall(requestOptions) { const client = getOAuthClient(); // Funktion zum Aktualisieren des Tokens const doRefresh = async () => { - console.log("🔄 QBO Token Refresh wird ausgeführt (401 Error gefangen)..."); + console.log("🔄 QBO Token Refresh wird ausgeführt..."); try { const authResponse = await client.refresh(); console.log("✅ Token erfolgreich erneuert."); - // Hier müsste man idealerweise die neuen Tokens speichern + saveTokens(); // Neue Tokens persistent speichern return authResponse; } catch (e) { console.error("❌ Refresh fehlgeschlagen:", e.originalMessage || e); @@ -38,34 +69,35 @@ async function makeQboApiCall(requestOptions) { } }; - // --- ÄNDERUNG: KEINE VORAB-PRÜFUNG MEHR --- - // Wir vertrauen darauf, dass der Token in der .env aktuell ist (da du ihn gerade generiert hast). - // Wir entfernen client.isAccessTokenValid(), da dies oft falsch negativ ist nach Neustart. + // Vorab-Prüfung: Wenn Token ungültig (basierend auf expires_at), refreshen + if (!client.isAccessTokenValid()) { + console.log("⚠️ Access Token ist ungültig oder abgelaufen. Refresh wird durchgeführt."); + await doRefresh(); + } try { - // Versuch 1: Einfach machen! + // API-Aufruf durchführen const response = await client.makeApiCall(requestOptions); - // Prüfen, ob QBO eine Fehlermeldung im Body sendet (trotz HTTP 200/400) + // Prüfen, ob QBO eine Fehlermeldung im Body sendet const data = response.getJson ? response.getJson() : response.json; if (data.fault && data.fault.error) { const errorCode = data.fault.error[0].code; - // Fehler 3202 = Missing Access Token / Invalid - // Manchmal sendet QBO auch 401 im Body if (errorCode === '3202' || errorCode === '3100') { console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`); await doRefresh(); return await client.makeApiCall(requestOptions); } - // Anderen API-Fehler werfen (z.B. Validierung) throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`); } + // Erfolgreichen Aufruf: Tokens speichern (falls geändert) + saveTokens(); return response; } catch (e) { - // HTTP 401 Unauthorized fangen -> Das ist der ECHTE Indikator, dass der Token abgelaufen ist + // HTTP 401 Unauthorized fangen const isAuthError = e.response?.status === 401 || (e.authResponse && e.authResponse.response && e.authResponse.response.status === 401); if (isAuthError) { diff --git a/server.js b/server.js index c2180e9..78c10df 100644 --- a/server.js +++ b/server.js @@ -1272,6 +1272,41 @@ app.post('/api/invoices/:id/export', async (req, res) => { } }); +app.get('/api/qbo/overdue', async (req, res) => { + try { + // Datum vor 30 Tagen berechnen + const date = new Date(); + date.setDate(date.getDate() - 30); + const dateStr = date.toISOString().split('T')[0]; + + console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`); + + // Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist + const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`; + + const oauthClient = getOAuthClient(); + const companyId = oauthClient.getToken().realmId; + const baseUrl = process.env.QBO_ENVIRONMENT === 'production' + ? 'https://quickbooks.api.intuit.com' + : 'https://sandbox-quickbooks.api.intuit.com'; + + // makeQboApiCall kümmert sich um den Refresh, falls nötig! + 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 || []; + + console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`); + res.json(invoices); + + } catch (error) { + console.error("QBO Report Error:", error); + res.status(500).json({ error: error.message }); + } +}); // Start server and browser async function startServer() { await initBrowser();