diff --git a/public/app.js b/public/app.js index d35af27..15e0edc 100644 --- a/public/app.js +++ b/public/app.js @@ -1201,4 +1201,65 @@ async function checkQboOverdue() { btn.innerHTML = originalText; btn.disabled = false; } -} \ No newline at end of file +} + +async function importFromQBO() { + if (!confirm( + 'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' + + '• Bereits importierte werden übersprungen\n' + + '• Nur Kunden die lokal verknüpft sind\n\n' + + 'Fortfahren?' + )) return; + + const btn = document.querySelector('button[onclick="importFromQBO()"]'); + const resultDiv = document.getElementById('qbo-import-result'); + + const originalText = btn.innerHTML; + btn.innerHTML = '⏳ Importiere aus QBO...'; + btn.disabled = true; + resultDiv.classList.add('hidden'); + + try { + const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' }); + const result = await response.json(); + + resultDiv.classList.remove('hidden'); + + if (response.ok) { + let html = `
Import abgeschlossen
`; + html += `Import fehlgeschlagen
+${result.error}
+Netzwerkfehler beim Import.
++ Importiert alle unbezahlten Rechnungen aus QuickBooks Online in dein lokales System. +
+Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt, diff --git a/qbo_helper.js b/qbo_helper.js index 72102e7..bfb7df6 100644 --- a/qbo_helper.js +++ b/qbo_helper.js @@ -35,10 +35,18 @@ const getOAuthClient = () => { oauthClient.setToken(savedToken); console.log("✅ Gespeicherter Token aus qbo_token.json geladen."); } else { + // WICHTIG: intuit-oauth braucht ein VOLLSTÄNDIGES Token-Objekt! + // Nur access_token + refresh_token reicht NICHT — die Library + // prüft intern auf token_type, expires_in, createdAt etc. + // und wirft "The Refresh token is invalid" wenn die fehlen. const envToken = { + token_type: 'bearer', access_token: process.env.QBO_ACCESS_TOKEN || '', refresh_token: process.env.QBO_REFRESH_TOKEN || '', - realmId: process.env.QBO_REALM_ID + expires_in: 3600, + x_refresh_token_expires_in: 8726400, + realmId: process.env.QBO_REALM_ID, + createdAt: new Date().toISOString() }; if (envToken.refresh_token) { oauthClient.setToken(envToken); diff --git a/server.js b/server.js index 9957e10..5a5b40f 100644 --- a/server.js +++ b/server.js @@ -1354,6 +1354,186 @@ app.get('/api/qbo/status', (req, res) => { } }); +app.post('/api/qbo/import-unpaid', async (req, res) => { + const dbClient = await pool.connect(); + + try { + 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'; + + // 1. Alle unbezahlten Rechnungen aus QBO holen + // Balance > '0' = noch nicht vollständig bezahlt + // MAXRESULTS 1000 = sicherheitshalber hoch setzen + console.log('📥 QBO Import: Lade unbezahlte Rechnungen...'); + + const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000"; + const response = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, + method: 'GET' + }); + + const data = response.getJson ? response.getJson() : response.json; + const qboInvoices = data.QueryResponse?.Invoice || []; + + console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`); + + if (qboInvoices.length === 0) { + return res.json({ + success: true, + imported: 0, + skipped: 0, + skippedNoCustomer: 0, + message: 'Keine unbezahlten Rechnungen in QBO gefunden.' + }); + } + + // 2. Lokale Kunden laden (die mit QBO verknüpft sind) + const customersResult = await dbClient.query( + 'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL' + ); + const customerMap = new Map(); + customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c)); + + // 3. Bereits importierte QBO-Rechnungen ermitteln (nach qbo_id) + const existingResult = await dbClient.query( + 'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL' + ); + const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id)); + + // 4. Import durchführen + let imported = 0; + let skipped = 0; + let skippedNoCustomer = 0; + const skippedCustomerNames = []; + + await dbClient.query('BEGIN'); + + for (const qboInv of qboInvoices) { + const qboId = String(qboInv.Id); + + // Bereits importiert? → Überspringen + if (existingQboIds.has(qboId)) { + skipped++; + continue; + } + + // Kunde lokal vorhanden? + const customerQboId = String(qboInv.CustomerRef?.value || ''); + const localCustomer = customerMap.get(customerQboId); + + if (!localCustomer) { + skippedNoCustomer++; + const custName = qboInv.CustomerRef?.name || 'Unbekannt'; + if (!skippedCustomerNames.includes(custName)) { + skippedCustomerNames.push(custName); + } + continue; + } + + // Werte aus QBO-Rechnung extrahieren + const docNumber = qboInv.DocNumber || ''; + const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0]; + const syncToken = qboInv.SyncToken || ''; + + // Terms aus QBO mappen (SalesTermRef) + let terms = 'Net 30'; + if (qboInv.SalesTermRef?.name) { + terms = qboInv.SalesTermRef.name; + } + + // Tax: Prüfen ob TaxLine vorhanden + const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0; + const taxExempt = taxAmount === 0; + + // Subtotal berechnen (Total - Tax) + const total = parseFloat(qboInv.TotalAmt) || 0; + const subtotal = total - taxAmount; + const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25; + + // Memo als auth_code (falls vorhanden) + const authCode = qboInv.CustomerMemo?.value || ''; + + // Rechnung einfügen + const invoiceResult = await dbClient.query( + `INSERT INTO invoices + (invoice_number, customer_id, invoice_date, terms, auth_code, + tax_exempt, tax_rate, subtotal, tax_amount, total, + qbo_id, qbo_sync_token, qbo_doc_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id`, + [docNumber, localCustomer.id, txnDate, terms, authCode, + taxExempt, taxRate, subtotal, taxAmount, total, + qboId, syncToken, docNumber] + ); + + const localInvoiceId = invoiceResult.rows[0].id; + + // Line Items importieren + const lines = qboInv.Line || []; + let itemOrder = 0; + + for (const line of lines) { + // Nur SalesItemLineDetail-Zeilen (keine SubTotalLine etc.) + if (line.DetailType !== 'SalesItemLineDetail') continue; + + const detail = line.SalesItemLineDetail || {}; + const qty = String(detail.Qty || 1); + const rate = String(detail.UnitPrice || 0); + const amount = String(line.Amount || 0); + const description = line.Description || ''; + + // Item-Typ ermitteln (Labor=5, Parts=9) + const itemRefValue = detail.ItemRef?.value || '9'; + const itemRefName = (detail.ItemRef?.name || '').toLowerCase(); + let qboItemId = '9'; // Default: Parts + if (itemRefValue === '5' || itemRefName.includes('labor')) { + qboItemId = '5'; + } + + await dbClient.query( + `INSERT INTO invoice_items + (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId] + ); + itemOrder++; + } + + imported++; + console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`); + } + + await dbClient.query('COMMIT'); + + const message = [ + `${imported} Rechnungen importiert.`, + skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '', + skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : '' + ].filter(Boolean).join(' '); + + console.log(`📥 QBO Import abgeschlossen: ${message}`); + + res.json({ + success: true, + imported, + skipped, + skippedNoCustomer, + skippedCustomerNames, + message + }); + + } catch (error) { + await dbClient.query('ROLLBACK'); + console.error('❌ QBO Import Error:', error); + res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message }); + } finally { + dbClient.release(); + } +}); + // Start server and browser