/** * QBO Routes * Handles QBO OAuth, sync, and data operations */ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo'); const { makeQboApiCall } = require('../../qbo_helper'); // GET QBO status router.get('/status', (req, res) => { try { const client = getOAuthClient(); const token = client.getToken(); const hasToken = !!(token && token.refresh_token); res.json({ connected: hasToken, realmId: token?.realmId || null }); } catch (e) { res.json({ connected: false }); } }); // GET auth URL - redirects to Intuit router.get('/auth', (req, res) => { const client = getOAuthClient(); const authUri = client.authorizeUri({ scope: [require('../config/qbo').OAuthClient.scopes.Accounting], state: 'intuit-qbo-auth' }); console.log('🔗 Redirecting to QBO Authorization:', authUri); res.redirect(authUri); }); // OAuth callback router.get('/auth/callback', async (req, res) => { const client = getOAuthClient(); try { const authResponse = await client.createToken(req.url); console.log('✅ QBO Authorization erfolgreich!'); saveTokens(); res.redirect('/#settings'); } catch (e) { console.error('❌ QBO Authorization fehlgeschlagen:', e); res.status(500).send(`

QBO Authorization Failed

${e.message || e}

Zurück zur App `); } }); // GET bank accounts from QBO router.get('/accounts', async (req, res) => { try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET payment methods from QBO router.get('/payment-methods', async (req, res) => { try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const query = "SELECT * FROM PaymentMethod WHERE Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name }))); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET labor rate from QBO router.get('/labor-rate', async (req, res) => { try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/item/5`, method: 'GET' }); const data = response.getJson ? response.getJson() : response.json; const rate = data.Item?.UnitPrice || null; console.log(`💰 QBO Labor Rate: $${rate}`); res.json({ rate }); } catch (error) { console.error('Error fetching labor rate:', error); res.json({ rate: null }); } }); // GET last sync timestamp router.get('/last-sync', async (req, res) => { try { const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'"); res.json({ last_sync: result.rows[0]?.value || null }); } catch (error) { res.json({ last_sync: null }); } }); // GET overdue invoices from QBO router.get('/overdue', async (req, res) => { try { 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}...`); 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 = getQboBaseUrl(); 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 }); } }); // POST import unpaid invoices from QBO router.post('/import-unpaid', async (req, res) => { const dbClient = await pool.connect(); try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); 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.' }); } // Load local customers 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)); // Get already imported QBO invoices 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)); 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); if (existingQboIds.has(qboId)) { skipped++; continue; } 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; } const docNumber = qboInv.DocNumber || ''; const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0]; const syncToken = qboInv.SyncToken || ''; let terms = 'Net 30'; if (qboInv.SalesTermRef?.name) { terms = qboInv.SalesTermRef.name; } const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0; const taxExempt = taxAmount === 0; const total = parseFloat(qboInv.TotalAmt) || 0; const subtotal = total - taxAmount; const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25; const authCode = qboInv.CustomerMemo?.value || ''; 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; const lines = qboInv.Line || []; let itemOrder = 0; for (const line of lines) { 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 || ''; const itemRefValue = detail.ItemRef?.value || '9'; const itemRefName = (detail.ItemRef?.name || '').toLowerCase(); let qboItemId = '9'; 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(); } }); // POST record payment in QBO router.post('/record-payment', async (req, res) => { const { invoice_payments, payment_date, reference_number, payment_method_id, payment_method_name, deposit_to_account_id, deposit_to_account_name } = req.body; if (!invoice_payments || invoice_payments.length === 0) { return res.status(400).json({ error: 'No invoices selected.' }); } const dbClient = await pool.connect(); try { const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const ids = invoice_payments.map(ip => ip.invoice_id); const result = await dbClient.query( `SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id WHERE i.id = ANY($1)`, [ids] ); const invoicesData = result.rows; const notInQbo = invoicesData.filter(inv => !inv.qbo_id); if (notInQbo.length > 0) { return res.status(400).json({ error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}` }); } const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))]; if (custIds.length > 1) { return res.status(400).json({ error: 'All invoices must belong to the same customer.' }); } const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)])); const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0); const qboPayment = { CustomerRef: { value: custIds[0] }, TotalAmt: totalAmt, TxnDate: payment_date, PaymentRefNum: reference_number || '', PaymentMethodRef: { value: payment_method_id }, DepositToAccountRef: { value: deposit_to_account_id }, Line: invoicesData.map(inv => ({ Amount: paymentMap.get(inv.id) || parseFloat(inv.total), LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }] })) }; console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`); const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/payment`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(qboPayment) }); const data = response.getJson ? response.getJson() : response.json; if (!data.Payment) { return res.status(500).json({ error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)) }); } const qboPaymentId = data.Payment.Id; console.log(`✅ QBO Payment ID: ${qboPaymentId}`); await dbClient.query('BEGIN'); const payResult = await dbClient.query( `INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, [payment_date, reference_number || null, payment_method_name || 'Check', deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId] ); const localPaymentId = payResult.rows[0].id; for (const ip of invoice_payments) { const payAmt = parseFloat(ip.amount); const inv = invoicesData.find(i => i.id === ip.invoice_id); const invTotal = inv ? parseFloat(inv.total) : 0; await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [localPaymentId, ip.invoice_id, payAmt] ); if (payAmt >= invTotal) { await dbClient.query( 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [payment_date, ip.invoice_id] ); } } await dbClient.query('COMMIT'); res.json({ success: true, payment_id: localPaymentId, qbo_payment_id: qboPaymentId, total: totalAmt, invoices_paid: invoice_payments.length, message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).` }); } catch (error) { await dbClient.query('ROLLBACK').catch(() => {}); console.error('❌ Payment Error:', error); res.status(500).json({ error: 'Payment failed: ' + error.message }); } finally { dbClient.release(); } }); // POST sync payments from QBO router.post('/sync-payments', async (req, res) => { const dbClient = await pool.connect(); try { const openResult = await dbClient.query(` SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status, COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid FROM invoices i WHERE i.qbo_id IS NOT NULL `); const openInvoices = openResult.rows; if (openInvoices.length === 0) { await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]); return res.json({ synced: 0, message: 'All invoices up to date.' }); } const oauthClient = getOAuthClient(); const companyId = oauthClient.getToken().realmId; const baseUrl = getQboBaseUrl(); const batchSize = 50; const qboInvoices = new Map(); for (let i = 0; i < openInvoices.length; i += batchSize) { 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)); } console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`); let updated = 0; let newPayments = 0; await dbClient.query('BEGIN'); for (const localInv of openInvoices) { const qboInv = qboInvoices.get(localInv.qbo_id); if (!qboInv) continue; const qboBalance = parseFloat(qboInv.Balance) || 0; const qboTotal = parseFloat(qboInv.TotalAmt) || 0; const localPaid = parseFloat(localInv.local_paid) || 0; if (qboBalance === 0 && qboTotal > 0) { const UNDEPOSITED_FUNDS_ID = '221'; 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 */ } } } } const needsUpdate = !localInv.paid_date || localInv.payment_status !== status; if (needsUpdate) { await dbClient.query( `UPDATE invoices SET paid_date = COALESCE(paid_date, CURRENT_DATE), payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [status, localInv.id] ); updated++; console.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) RETURNING id`, [diff, localInv.id] ); await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [payResult.rows[0].id, localInv.id, diff] ); newPayments++; console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`); } } else if (qboBalance > 0 && qboBalance < qboTotal) { const qboPaid = qboTotal - qboBalance; const diff = qboPaid - localPaid; const needsUpdate = localInv.payment_status !== 'Partial'; if (needsUpdate) { await dbClient.query( 'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', ['Partial', localInv.id] ); updated++; } 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) RETURNING id`, [diff, localInv.id] ); await dbClient.query( 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [payResult.rows[0].id, localInv.id, diff] ); newPayments++; console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`); } } } await dbClient.query(` INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1) ON CONFLICT (key) DO UPDATE SET value = $1 `, [new Date().toISOString()]); await dbClient.query('COMMIT'); console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`); res.json({ synced: updated, new_payments: newPayments, total_checked: openInvoices.length, message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.` }); } catch (error) { await dbClient.query('ROLLBACK').catch(() => {}); console.error('❌ Sync Error:', error); res.status(500).json({ error: 'Sync failed: ' + error.message }); } finally { dbClient.release(); } }); module.exports = router;