diff --git a/import_qbo_payment.js b/import_qbo_payment.js new file mode 100644 index 0000000..e237b8e --- /dev/null +++ b/import_qbo_payment.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// import_qbo_payment.js — Importiert ein spezifisches QBO Payment in die lokale DB +// +// Verwendung: +// node import_qbo_payment.js +// +// Beispiel: +// node import_qbo_payment.js 20616 +// +// Was passiert: +// 1. Payment aus QBO laden (inkl. LinkedTxn → verknüpfte Invoices) +// 2. Lokale Invoices anhand qbo_id finden +// 3. Payment in lokale payments-Tabelle schreiben +// 4. Invoices als bezahlt markieren (paid_date) + +require('dotenv').config(); +const { Pool } = require('pg'); +const { makeQboApiCall, getOAuthClient } = require('./qbo_helper'); + +const pool = new Pool({ + user: process.env.DB_USER || 'postgres', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'quotes_db', + password: process.env.DB_PASSWORD || 'postgres', + port: process.env.DB_PORT || 5432, +}); + +async function importPayment(qboPaymentId) { + 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'; + + console.log(`\n🔍 Lade QBO Payment ${qboPaymentId}...`); + + // 1. Payment aus QBO lesen + const response = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/payment/${qboPaymentId}`, + method: 'GET' + }); + + const data = response.getJson ? response.getJson() : response.json; + const payment = data.Payment; + + if (!payment) { + console.error('❌ Payment nicht gefunden in QBO.'); + process.exit(1); + } + + console.log(`✅ Payment gefunden:`); + console.log(` Datum: ${payment.TxnDate}`); + console.log(` Betrag: $${payment.TotalAmt}`); + console.log(` Referenz: ${payment.PaymentRefNum || '(keine)'}`); + console.log(` Kunde: ${payment.CustomerRef?.name || payment.CustomerRef?.value}`); + + // 2. Verknüpfte Invoices aus dem Payment extrahieren + const linkedInvoices = []; + if (payment.Line) { + for (const line of payment.Line) { + if (line.LinkedTxn) { + for (const txn of line.LinkedTxn) { + if (txn.TxnType === 'Invoice') { + linkedInvoices.push({ + qbo_invoice_id: txn.TxnId, + amount: line.Amount + }); + } + } + } + } + } + + console.log(` Verknüpfte Invoices: ${linkedInvoices.length}`); + linkedInvoices.forEach(li => { + console.log(` - QBO Invoice ID: ${li.qbo_invoice_id}, Amount: $${li.amount}`); + }); + + // 3. Lokale Invoices finden + const dbClient = await pool.connect(); + try { + await dbClient.query('BEGIN'); + + // Kunden-ID lokal finden + const customerResult = await dbClient.query( + 'SELECT id FROM customers WHERE qbo_id = $1', + [payment.CustomerRef?.value] + ); + const customerId = customerResult.rows[0]?.id || null; + + // PaymentMethod-Name aus QBO holen (optional) + let paymentMethodName = 'Unknown'; + if (payment.PaymentMethodRef?.value) { + try { + const pmRes = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/paymentmethod/${payment.PaymentMethodRef.value}`, + method: 'GET' + }); + const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json; + paymentMethodName = pmData.PaymentMethod?.Name || 'Unknown'; + } catch (e) { + console.log(' ⚠️ PaymentMethod konnte nicht geladen werden.'); + } + } + + // DepositTo-Account-Name aus QBO (optional) + let depositToName = ''; + if (payment.DepositToAccountRef?.value) { + try { + const accRes = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/account/${payment.DepositToAccountRef.value}`, + method: 'GET' + }); + const accData = accRes.getJson ? accRes.getJson() : accRes.json; + depositToName = accData.Account?.Name || ''; + } catch (e) { + console.log(' ⚠️ Account konnte nicht geladen werden.'); + } + } + + // Prüfen ob Payment schon importiert + const existing = await dbClient.query( + 'SELECT id FROM payments WHERE qbo_payment_id = $1', + [String(qboPaymentId)] + ); + if (existing.rows.length > 0) { + console.log(`\n⚠️ Payment ${qboPaymentId} wurde bereits importiert (lokale ID: ${existing.rows[0].id}).`); + console.log(' Übersprungen.'); + await dbClient.query('ROLLBACK'); + return; + } + + // Payment lokal anlegen + const paymentResult = 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 *`, + [payment.TxnDate, payment.PaymentRefNum || null, paymentMethodName, depositToName, payment.TotalAmt, customerId, String(qboPaymentId)] + ); + const localPaymentId = paymentResult.rows[0].id; + console.log(`\n💾 Lokales Payment erstellt: ID ${localPaymentId}`); + + // Verknüpfte Invoices lokal finden und markieren + let matchedCount = 0; + for (const li of linkedInvoices) { + const invResult = await dbClient.query( + 'SELECT id, invoice_number FROM invoices WHERE qbo_id = $1', + [li.qbo_invoice_id] + ); + + if (invResult.rows.length > 0) { + const localInv = invResult.rows[0]; + + await dbClient.query( + 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', + [localPaymentId, localInv.id, li.amount] + ); + await dbClient.query( + 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND paid_date IS NULL', + [payment.TxnDate, localInv.id] + ); + + console.log(` ✅ Invoice #${localInv.invoice_number || localInv.id} (QBO: ${li.qbo_invoice_id}) → bezahlt`); + matchedCount++; + } else { + console.log(` ⚠️ QBO Invoice ID ${li.qbo_invoice_id} nicht in lokaler DB gefunden.`); + } + } + + await dbClient.query('COMMIT'); + + console.log(`\n✅ Import abgeschlossen: ${matchedCount}/${linkedInvoices.length} Invoices verknüpft.`); + console.log(` Payment: Lokal ID ${localPaymentId}, QBO ID ${qboPaymentId}`); + console.log(` Methode: ${paymentMethodName}, Konto: ${depositToName}`); + + } catch (error) { + await dbClient.query('ROLLBACK').catch(() => {}); + console.error('❌ Fehler:', error); + } finally { + dbClient.release(); + await pool.end(); + } +} + +// --- Main --- +const qboPaymentId = process.argv[2]; +if (!qboPaymentId) { + console.log('Verwendung: node import_qbo_payment.js '); + console.log('Beispiel: node import_qbo_payment.js 20616'); + process.exit(1); +} + +importPayment(qboPaymentId).catch(err => { + console.error('Fatal:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/public/app.js b/public/app.js index 6e9c643..71a2200 100644 --- a/public/app.js +++ b/public/app.js @@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => { //loadInvoices(); setDefaultDate(); checkCurrentLogo(); - + loadLaborRate(); // *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) *** const savedTab = localStorage.getItem('activeTab') || 'quotes'; showTab(savedTab); @@ -219,30 +219,28 @@ async function loadCustomers() { function renderCustomers() { const tbody = document.getElementById('customers-list'); tbody.innerHTML = customers.map(customer => { - // Logik: Line 1-4 zusammenbauen - // filter(Boolean) entfernt null/undefined/leere Strings const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean); - - // City, State, Zip anhängen const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' '); - - // Alles zusammenfügen let fullAddress = lines.join(', '); - if (cityStateZip) { - fullAddress += (fullAddress ? ', ' : '') + cityStateZip; - } - + if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip; + + // QBO Status + const qboStatus = customer.qbo_id + ? `QBO ✓` + : ``; + return ` - ${customer.name} + + ${customer.name} ${qboStatus} + ${fullAddress || '-'} ${customer.account_number || '-'} - - `; + `; }).join(''); } @@ -351,7 +349,34 @@ async function deleteCustomer(id) { alert('Error deleting customer'); } } +async function exportCustomerToQbo(customerId) { + const customer = customers.find(c => c.id === customerId); + if (!customer) return; + if (!confirm(`Kunde "${customer.name}" nach QuickBooks Online exportieren?`)) return; + + showSpinner('Exportiere Kunde nach QBO...'); + + try { + const response = await fetch(`/api/customers/${customerId}/export-qbo`, { method: 'POST' }); + const result = await response.json(); + + if (response.ok) { + alert(`✅ Kunde "${result.name}" erfolgreich in QBO erstellt (ID: ${result.qbo_id}).`); + // Kunden-Liste neu laden + const custResponse = await fetch('/api/customers'); + customers = await custResponse.json(); + renderCustomers(); + } else { + alert(`❌ Fehler: ${result.error}`); + } + } catch (error) { + console.error('Error exporting customer:', error); + alert('Netzwerkfehler beim Export.'); + } finally { + hideSpinner(); + } +} // Quote Management async function loadQuotes() { try { @@ -916,7 +941,7 @@ function addInvoiceItem(item = null) {
- @@ -999,7 +1024,27 @@ function updateInvoiceItemPreview(itemDiv, itemId) { descPreview.textContent = preview || 'New item'; } } - +function handleTypeChange(selectEl, itemId) { + const itemDiv = selectEl.closest(`[id^=invoice-item-]`); + + // Wenn Labor gewählt und Rate leer → Labor Rate eintragen + if (selectEl.value === '5' && qboLaborRate) { + const rateInput = itemDiv.querySelector('[data-field="rate"]'); + if (rateInput && (!rateInput.value || rateInput.value === '0')) { + rateInput.value = qboLaborRate; + // Amount neu berechnen + const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); + const amountInput = itemDiv.querySelector('[data-field="amount"]'); + if (qtyInput.value) { + const qty = parseFloat(qtyInput.value) || 0; + amountInput.value = (qty * qboLaborRate).toFixed(2); + } + } + } + + updateInvoiceItemPreview(itemDiv, itemId); + updateInvoiceTotals(); +} function removeInvoiceItem(itemId) { document.getElementById(`invoice-item-${itemId}`).remove(); updateInvoiceTotals(); @@ -1261,4 +1306,54 @@ async function importFromQBO() { btn.disabled = false; } } +// ===================================================== +// 3. Labor Rate laden und in addInvoiceItem verwenden +// NEUE globale Variable + Lade-Funktion +// ===================================================== + +let qboLaborRate = null; // Wird beim Start geladen + +async function loadLaborRate() { + try { + const response = await fetch('/api/qbo/labor-rate'); + const data = await response.json(); + if (data.rate) { + qboLaborRate = data.rate; + console.log(`💰 Labor Rate geladen: $${qboLaborRate}`); + } + } catch (e) { + console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.'); + } +} +// ===================================================== +// 5. Spinner Funktionen — NEUE Funktionen hinzufügen +// Wird bei QBO-Operationen angezeigt +// ===================================================== + +function showSpinner(message = 'Bitte warten...') { + let overlay = document.getElementById('qbo-spinner'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'qbo-spinner'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;'; + document.body.appendChild(overlay); + } + overlay.innerHTML = ` +
+ + + + + ${message} +
`; + overlay.style.display = 'flex'; +} + +function hideSpinner() { + const overlay = document.getElementById('qbo-spinner'); + if (overlay) overlay.style.display = 'none'; +} + + + window.openInvoiceModal = openInvoiceModal; \ No newline at end of file diff --git a/public/invoice-view.js b/public/invoice-view.js index 792ce4c..110af3a 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -167,9 +167,16 @@ function renderInvoiceRow(invoice) { // --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) --- const editBtn = ``; - const qboBtn = hasQbo - ? `✓ QBO` - : ``; + // QBO Button — nur aktiv wenn Kunde eine qbo_id hat + const customerHasQbo = !!invoice.customer_qbo_id; + let qboBtn; + if (hasQbo) { + qboBtn = `✓ QBO`; + } else if (!customerHasQbo) { + qboBtn = `QBO ⚠`; + } else { + qboBtn = ``; + } const pdfBtn = draft ? `PDF` @@ -332,12 +339,14 @@ 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...'); 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.'); } + finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function resetQbo(id) { diff --git a/public/payment-modal.js b/public/payment-modal.js index b85a6f9..94172c8 100644 --- a/public/payment-modal.js +++ b/public/payment-modal.js @@ -233,6 +233,7 @@ async function submitPayment() { const submitBtn = document.getElementById('payment-submit-btn'); submitBtn.innerHTML = '⏳ Wird gesendet...'; submitBtn.disabled = true; + if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...'); try { const response = await fetch('/api/qbo/record-payment', { @@ -264,6 +265,7 @@ async function submitPayment() { } finally { submitBtn.innerHTML = '💰 Record Payment in QBO'; submitBtn.disabled = false; + if (typeof hideSpinner === 'function') hideSpinner(); } } diff --git a/server.js b/server.js index 48060eb..3c639cf 100644 --- a/server.js +++ b/server.js @@ -403,7 +403,7 @@ app.delete('/api/quotes/:id', async (req, res) => { app.get('/api/invoices', async (req, res) => { try { const result = await pool.query(` - SELECT i.*, c.name as customer_name + SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id ORDER BY i.created_at DESC @@ -1831,8 +1831,125 @@ app.get('/api/payments', async (req, res) => { } }); +// ===================================================== +// Neue Server Endpoints — In server.js einfügen +// 1. Customer QBO Export +// 2. Labor Rate aus QBO +// ===================================================== +// --- 1. Kunde nach QBO exportieren --- +app.post('/api/customers/:id/export-qbo', async (req, res) => { + const { id } = req.params; + + try { + const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); + if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' }); + + const customer = custResult.rows[0]; + + if (customer.qbo_id) { + return res.status(400).json({ error: `Kunde "${customer.name}" ist bereits in QBO (ID: ${customer.qbo_id}).` }); + } + + 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 Customer Objekt + const qboCustomer = { + DisplayName: customer.name, + CompanyName: customer.name, + BillAddr: {}, + PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined, + PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined, + // Taxable setzt man über TaxExemptionReasonId oder SalesTermRef + Taxable: customer.taxable !== false + }; + + // Adresse aufbauen + const addr = qboCustomer.BillAddr; + if (customer.line1) addr.Line1 = customer.line1; + if (customer.line2) addr.Line2 = customer.line2; + if (customer.line3) addr.Line3 = customer.line3; + if (customer.line4) addr.Line4 = customer.line4; + if (customer.city) addr.City = customer.city; + if (customer.state) addr.CountrySubDivisionCode = customer.state; + if (customer.zip_code) addr.PostalCode = customer.zip_code; + + // Kein leeres BillAddr senden + if (Object.keys(addr).length === 0) delete qboCustomer.BillAddr; + + console.log(`📤 Exportiere Kunde "${customer.name}" nach QBO...`); + + const response = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/customer`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(qboCustomer) + }); + + const data = response.getJson ? response.getJson() : response.json; + + if (data.Customer) { + const qboId = data.Customer.Id; + + // qbo_id lokal speichern + await pool.query( + 'UPDATE customers SET qbo_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [qboId, id] + ); + + console.log(`✅ Kunde "${customer.name}" in QBO erstellt: ID ${qboId}`); + res.json({ success: true, qbo_id: qboId, name: customer.name }); + } else { + console.error('❌ QBO Customer Fehler:', JSON.stringify(data)); + + // Spezieller Fehler: Name existiert schon in QBO + const errMsg = data.Fault?.Error?.[0]?.Message || JSON.stringify(data); + const errDetail = data.Fault?.Error?.[0]?.Detail || ''; + + res.status(500).json({ error: `QBO Fehler: ${errMsg}. ${errDetail}` }); + } + + } catch (error) { + console.error('❌ Customer Export Error:', error); + res.status(500).json({ error: 'Export fehlgeschlagen: ' + error.message }); + } +}); + + +// --- 2. Labor Rate aus QBO laden --- +// Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO +app.get('/api/qbo/labor-rate', async (req, res) => { + 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'; + + // Item ID 5 = Labor + 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); + // Nicht kritisch — Fallback auf Frontend-Default + res.json({ rate: null }); + } +}); + // Start server and browser async function startServer() {