update add report

This commit is contained in:
Andreas Knuth 2026-02-17 09:53:13 -06:00
parent df1be3b823
commit eb19b2785e
6 changed files with 158 additions and 16 deletions

4
.gitignore vendored
View File

@ -1,2 +1,4 @@
.env .env
*.png *.png
node_modules
qbo_token.json

View File

@ -42,6 +42,7 @@ services:
volumes: volumes:
- ./public/uploads:/app/public/uploads - ./public/uploads:/app/public/uploads
- ./templates:/app/templates # NEU! - ./templates:/app/templates # NEU!
- ./qbo_token.json:/app/qbo_token.json
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@ -1140,4 +1140,49 @@ async function exportToQBO(id) {
btn.textContent = originalText; btn.textContent = originalText;
btn.disabled = false; 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 = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
} else {
tbody.innerHTML = invoices.map(inv => `
<tr>
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
</tr>
`).join('');
}
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
} else {
throw new Error(invoices.error || 'Unknown error');
}
} catch (error) {
console.error('QBO Test Error:', error);
alert('❌ Connection Test Failed: ' + error.message);
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
resultDiv.classList.remove('hidden');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
} }

View File

@ -146,6 +146,33 @@
</button> </button>
<div id="upload-status" class="mt-4"></div> <div id="upload-status" class="mt-4"></div>
<hr class="my-8 border-gray-200">
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
<button onclick="checkQboOverdue()" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
<span id="qbo-btn-icon" class="mr-2">📡</span> Test Connection & Get Overdue Report
</button>
<div id="qbo-result" class="mt-6 hidden">
<h4 class="font-bold text-gray-700 mb-2">Results from QBO:</h4>
<div class="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Inv #</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Customer</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Due Date</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Balance</th>
</tr>
</thead>
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,11 @@
// qbo_helper.js // qbo_helper.js
require('dotenv').config(); require('dotenv').config();
const OAuthClient = require('intuit-oauth'); const OAuthClient = require('intuit-oauth');
const fs = require('fs');
const path = require('path');
let oauthClient = null; let oauthClient = null;
const tokenFile = path.join(__dirname, 'qbo_token.json');
const getOAuthClient = () => { const getOAuthClient = () => {
if (!oauthClient) { if (!oauthClient) {
@ -10,27 +13,55 @@ const getOAuthClient = () => {
clientId: process.env.QBO_CLIENT_ID, clientId: process.env.QBO_CLIENT_ID,
clientSecret: process.env.QBO_CLIENT_SECRET, clientSecret: process.env.QBO_CLIENT_SECRET,
environment: process.env.QBO_ENVIRONMENT || 'sandbox', environment: process.env.QBO_ENVIRONMENT || 'sandbox',
redirectUri: process.env.QBO_REDIRECT_URI, redirectUri: process.env.QBO_REDIRECT_URI
token: { });
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 || '', access_token: process.env.QBO_ACCESS_TOKEN || '',
refresh_token: process.env.QBO_REFRESH_TOKEN || '', refresh_token: process.env.QBO_REFRESH_TOKEN || '',
realmId: process.env.QBO_REALM_ID realmId: process.env.QBO_REALM_ID
} };
}); oauthClient.setToken(envToken);
console.log(" Token aus .env geladen (Fallback).");
}
} }
return oauthClient; 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) { async function makeQboApiCall(requestOptions) {
const client = getOAuthClient(); const client = getOAuthClient();
// Funktion zum Aktualisieren des Tokens // Funktion zum Aktualisieren des Tokens
const doRefresh = async () => { const doRefresh = async () => {
console.log("🔄 QBO Token Refresh wird ausgeführt (401 Error gefangen)..."); console.log("🔄 QBO Token Refresh wird ausgeführt...");
try { try {
const authResponse = await client.refresh(); const authResponse = await client.refresh();
console.log("✅ Token erfolgreich erneuert."); console.log("✅ Token erfolgreich erneuert.");
// Hier müsste man idealerweise die neuen Tokens speichern saveTokens(); // Neue Tokens persistent speichern
return authResponse; return authResponse;
} catch (e) { } catch (e) {
console.error("❌ Refresh fehlgeschlagen:", e.originalMessage || e); console.error("❌ Refresh fehlgeschlagen:", e.originalMessage || e);
@ -38,34 +69,35 @@ async function makeQboApiCall(requestOptions) {
} }
}; };
// --- ÄNDERUNG: KEINE VORAB-PRÜFUNG MEHR --- // Vorab-Prüfung: Wenn Token ungültig (basierend auf expires_at), refreshen
// Wir vertrauen darauf, dass der Token in der .env aktuell ist (da du ihn gerade generiert hast). if (!client.isAccessTokenValid()) {
// Wir entfernen client.isAccessTokenValid(), da dies oft falsch negativ ist nach Neustart. console.log("⚠️ Access Token ist ungültig oder abgelaufen. Refresh wird durchgeführt.");
await doRefresh();
}
try { try {
// Versuch 1: Einfach machen! // API-Aufruf durchführen
const response = await client.makeApiCall(requestOptions); 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; const data = response.getJson ? response.getJson() : response.json;
if (data.fault && data.fault.error) { if (data.fault && data.fault.error) {
const errorCode = data.fault.error[0].code; 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') { if (errorCode === '3202' || errorCode === '3100') {
console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`); console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`);
await doRefresh(); await doRefresh();
return await client.makeApiCall(requestOptions); return await client.makeApiCall(requestOptions);
} }
// Anderen API-Fehler werfen (z.B. Validierung)
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`); throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
} }
// Erfolgreichen Aufruf: Tokens speichern (falls geändert)
saveTokens();
return response; return response;
} catch (e) { } 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); const isAuthError = e.response?.status === 401 || (e.authResponse && e.authResponse.response && e.authResponse.response.status === 401);
if (isAuthError) { if (isAuthError) {

View File

@ -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 // Start server and browser
async function startServer() { async function startServer() {
await initBrowser(); await initBrowser();