From 8643aebcfcabb30feb80ce93b87701930cb5969b Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Fri, 20 Feb 2026 10:53:20 -0600 Subject: [PATCH] update qbo sync --- public/invoice-view.js | 132 +++++++++++++++++++--------- server.js | 194 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 280 insertions(+), 46 deletions(-) diff --git a/public/invoice-view.js b/public/invoice-view.js index 1b504e4..b881948 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -1,5 +1,5 @@ -// invoice-view.js — ES Module v4 -// Fixes: No Paid for drafts, payment modal, UTC dates, persistent settings +// invoice-view.js — ES Module v5 +// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button let invoices = []; let filterCustomer = localStorage.getItem('inv_filterCustomer') || ''; @@ -9,7 +9,7 @@ let groupBy = localStorage.getItem('inv_groupBy') || 'none'; const OVERDUE_DAYS = 30; // ============================================================ -// Date Helpers — KEIN new Date('YYYY-MM-DD') wegen UTC-Bug! +// Date Helpers // ============================================================ function parseLocalDate(dateStr) { @@ -27,6 +27,13 @@ function formatDate(date) { return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`; } +function formatDateTime(isoStr) { + if (!isoStr) return 'Never'; + const d = new Date(isoStr); + return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) + + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); +} + function daysSince(date) { const d = parseLocalDate(date); if (!d) return 0; @@ -64,7 +71,12 @@ function getMonthName(i) { function isPaid(inv) { return !!inv.paid_date; } function isDraft(inv) { return !inv.qbo_id; } -function isOverdue(inv) { return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; } +function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; } +function isPartiallyPaid(inv) { + const amountPaid = parseFloat(inv.amount_paid) || 0; + const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid); + return !inv.paid_date && amountPaid > 0 && balance > 0; +} function saveSettings() { localStorage.setItem('inv_filterStatus', filterStatus); @@ -81,9 +93,19 @@ export async function loadInvoices() { const response = await fetch('/api/invoices'); invoices = await response.json(); renderInvoiceView(); + loadLastSync(); } catch (error) { console.error('Error loading invoices:', error); } } +async function loadLastSync() { + try { + const res = await fetch('/api/qbo/last-sync'); + const data = await res.json(); + const el = document.getElementById('last-sync-time'); + if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced'; + } catch (e) { /* ignore */ } +} + export function getInvoicesData() { return invoices; } // ============================================================ @@ -96,6 +118,7 @@ function getFilteredInvoices() { else if (filterStatus === 'paid') f = f.filter(i => isPaid(i)); else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i)); else if (filterStatus === 'draft') f = f.filter(i => isDraft(i) && !isPaid(i)); + else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i)); if (filterCustomer.trim()) { const s = filterCustomer.toLowerCase(); @@ -141,19 +164,25 @@ function renderInvoiceRow(invoice) { const paid = isPaid(invoice); const overdue = isOverdue(invoice); const draft = isDraft(invoice); + const amountPaid = parseFloat(invoice.amount_paid) || 0; + const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid); + const partial = isPartiallyPaid(invoice); const invNumDisplay = invoice.invoice_number ? invoice.invoice_number : `Draft`; + // Status Badge let statusBadge = ''; - const amountPaid = parseFloat(invoice.amount_paid) || 0; - const balance = parseFloat(invoice.balance) ?? (parseFloat(invoice.total) - amountPaid); - const isPartiallyPaid = !paid && amountPaid > 0 && balance > 0; - - if (paid) statusBadge = `Paid`; - else if (isPartiallyPaid) statusBadge = `Partial $${amountPaid.toFixed(2)}`; - else if (overdue) statusBadge = `Overdue`; + if (paid && invoice.payment_status === 'Deposited') { + statusBadge = `Deposited`; + } else if (paid) { + statusBadge = `Paid`; + } else if (partial) { + statusBadge = `Partial $${amountPaid.toFixed(2)}`; + } else if (overdue) { + statusBadge = `Overdue`; + } // Send Date let sendDateDisplay = '—'; @@ -169,14 +198,20 @@ function renderInvoiceRow(invoice) { } } - // --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) --- + // Amount column — show balance when partially paid + let amountDisplay; + if (partial) { + amountDisplay = `$${balance.toFixed(2)} $${parseFloat(invoice.total).toFixed(2)}`; + } else { + amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`; + } + + // --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del --- const editBtn = ``; - // QBO Button — Export oder Sync const customerHasQbo = !!invoice.customer_qbo_id; let qboBtn; if (hasQbo) { - // Already in QBO — show sync button + reset option qboBtn = ``; } else if (!customerHasQbo) { qboBtn = `QBO ⚠`; @@ -185,21 +220,19 @@ function renderInvoiceRow(invoice) { } const pdfBtn = draft - ? `PDF` + ? `PDF` : ``; const htmlBtn = ``; - // PAYMENT BUTTON — NUR wenn in QBO. Drafts bekommen KEINEN Button. + // Payment button — only for QBO invoices that are not fully paid let paidBtn = ''; - if (paid) { - paidBtn = ``; - } else if (hasQbo) { + if (!paid && hasQbo) { paidBtn = ``; } - // Kein Button für Drafts (!hasQbo && !paid) const delBtn = ``; - const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : ''; + + const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : ''; return ` @@ -208,12 +241,7 @@ function renderInvoiceRow(invoice) { ${formatDate(invoice.invoice_date)} ${sendDateDisplay} ${invoice.terms} - - ${isPartiallyPaid - ? `$${balance.toFixed(2)} $${parseFloat(invoice.total).toFixed(2)}` - : `$${parseFloat(invoice.total).toFixed(2)}` - } - + ${amountDisplay} ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn} @@ -285,6 +313,10 @@ function updateStatusButtons() { const unpaidCount = invoices.filter(i => !isPaid(i)).length; const ub = document.getElementById('unpaid-badge'); if (ub) ub.textContent = unpaidCount; + + const partialCount = invoices.filter(i => isPartiallyPaid(i)).length; + const pb = document.getElementById('partial-badge'); + if (pb) { pb.textContent = partialCount; pb.classList.toggle('hidden', partialCount === 0); } } // ============================================================ @@ -302,6 +334,9 @@ export function injectToolbar() { + + +
+ ... + + 0 invoices +
`; @@ -349,14 +393,14 @@ export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); } export async function exportToQBO(id) { - if (!confirm('Rechnung an QuickBooks Online senden?')) return; - if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...'); + if (!confirm('Export invoice to QuickBooks Online?')) return; + if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...'); try { const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' }); const d = await r.json(); if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); } else alert(`❌ ${d.error}`); - } catch (e) { alert('Netzwerkfehler.'); } + } catch (e) { alert('Network error.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } @@ -372,8 +416,23 @@ export async function syncToQBO(id) { finally { if (typeof hideSpinner === 'function') hideSpinner(); } } +export async function syncFromQBO() { + if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...'); + try { + const r = await fetch('/api/qbo/sync-payments', { method: 'POST' }); + const d = await r.json(); + if (r.ok) { + alert(`✅ ${d.message}`); + loadInvoices(); + } else { + alert(`❌ ${d.error}`); + } + } catch (e) { alert('Network error.'); } + finally { if (typeof hideSpinner === 'function') hideSpinner(); } +} + export async function resetQbo(id) { - if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return; + if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return; try { const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }); if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); } @@ -390,11 +449,6 @@ export async function markPaid(id) { } catch (e) { console.error(e); } } -export async function markUnpaid(id) { - try { const r = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (r.ok) loadInvoices(); } - catch (e) { console.error(e); } -} - export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); } export async function remove(id) { @@ -408,6 +462,6 @@ export async function remove(id) { // ============================================================ window.invoiceView = { - viewPDF, viewHTML, exportToQBO, syncToQBO, resetQbo, markPaid, markUnpaid, edit, remove, + viewPDF, viewHTML, exportToQBO, syncToQBO, syncFromQBO, resetQbo, markPaid, edit, remove, loadInvoices, renderInvoiceView, setStatus }; \ No newline at end of file diff --git a/server.js b/server.js index 15de2ed..f4ef03f 100644 --- a/server.js +++ b/server.js @@ -409,7 +409,6 @@ app.get('/api/invoices', async (req, res) => { LEFT JOIN customers c ON i.customer_id = c.id ORDER BY i.created_at DESC `); - // balance berechnen const rows = result.rows.map(r => ({ ...r, amount_paid: parseFloat(r.amount_paid) || 0, @@ -458,10 +457,7 @@ app.get('/api/invoices/:id', async (req, res) => { [id] ); - res.json({ - invoice: invoice, - items: itemsResult.rows - }); + res.json({ invoice, items: itemsResult.rows }); } catch (error) { console.error('Error fetching invoice:', error); res.status(500).json({ error: 'Error fetching invoice' }); @@ -469,8 +465,6 @@ app.get('/api/invoices/:id', async (req, res) => { }); - - app.post('/api/invoices', async (req, res) => { const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body; @@ -2062,6 +2056,192 @@ app.get('/api/qbo/labor-rate', async (req, res) => { } }); +// --- 3. Sync Payments from QBO --- +// Prüft alle offenen lokalen Invoices gegen QBO. +// Aktualisiert paid_date und payment_status (Paid/Deposited). +app.post('/api/qbo/sync-payments', async (req, res) => { + const dbClient = await pool.connect(); + try { + // Alle lokalen Invoices die in QBO sind aber noch nicht voll bezahlt + 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 + AND (i.paid_date IS NULL OR i.payment_status IS NULL OR i.payment_status != 'Deposited') + `); + + 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 = process.env.QBO_ENVIRONMENT === 'production' + ? 'https://quickbooks.api.intuit.com' + : 'https://sandbox-quickbooks.api.intuit.com'; + + // QBO Invoices in Batches laden (max 50 IDs pro Query) + 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; + + // Prüfe ob in QBO bezahlt/teilweise bezahlt + if (qboBalance === 0 && qboTotal > 0) { + // Voll bezahlt in QBO + // Prüfe ob "Deposited" — dafür müssen wir LinkedTxn prüfen + // Wenn Deposit vorhanden → Deposited, sonst → Paid + let status = 'Paid'; + + // LinkedTxn aus der Invoice prüfen + if (qboInv.LinkedTxn) { + // Lade die Payments um zu prüfen ob sie deposited sind + 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) { + // Hat DepositToAccount → wurde deposited + status = 'Deposited'; + } + } catch (e) { /* ignore */ } + } + } + } + + // paid_date setzen falls noch nicht + if (!localInv.paid_date) { + await dbClient.query( + 'UPDATE invoices SET paid_date = CURRENT_DATE, payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [status, localInv.id] + ); + } else { + await dbClient.query( + 'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [status, localInv.id] + ); + } + + // Fehlenden lokalen Payment-Eintrag erstellen wenn nötig + const qboPaid = qboTotal; + if (qboPaid > localPaid) { + const diff = qboPaid - localPaid; + // Einen generischen Payment-Eintrag für den Differenzbetrag + 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), 'Auto-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}: ${status} (QBO Balance: $${qboBalance})`); + updated++; + + } else if (qboBalance > 0 && qboBalance < qboTotal) { + // Teilweise bezahlt in QBO + const qboPaid = qboTotal - qboBalance; + if (qboPaid > localPaid) { + const diff = qboPaid - localPaid; + 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), 'Auto-synced partial 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] + ); + + await dbClient.query( + 'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + ['Partial', localInv.id] + ); + + console.log(` 📎 #${localInv.invoice_number}: Partial ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`); + updated++; + newPayments++; + } + } + } + + // Last sync timestamp speichern + 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(); + } +}); + + +// --- 4. Last sync timestamp --- +app.get('/api/qbo/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 }); + } +}); + // Start server and browser async function startServer() {