diff --git a/public/app.js b/public/app.js index b73b6c9..549eb25 100644 --- a/public/app.js +++ b/public/app.js @@ -216,8 +216,10 @@ async function loadCustomers() { } } -// --- 1. renderCustomers() — ERSETZE komplett --- -// Zeigt QBO-Status, Credit-Betrag und Downpayment-Button +// ===================================================== +// 1. renderCustomers() — ERSETZE komplett +// Zeigt QBO-Status und Export-Button in der Kundenliste +// ===================================================== function renderCustomers() { const tbody = document.getElementById('customers-list'); @@ -228,41 +230,25 @@ function renderCustomers() { if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip; // QBO Status - let qboCol; - if (customer.qbo_id) { - qboCol = `QBO ✓`; - } else { - qboCol = ``; - } - - // Downpayment button (only if in QBO) - const downpayBtn = customer.qbo_id - ? `` - : ''; - - // Credit placeholder (loaded async) - const creditSpan = customer.qbo_id - ? `...` - : ''; + const qboStatus = customer.qbo_id + ? `QBO ✓` + : ``; return ` - ${customer.name} ${qboCol} ${creditSpan} + ${customer.name} ${qboStatus} ${fullAddress || '-'} ${customer.account_number || '-'} - ${downpayBtn} `; }).join(''); - - // Load credits async for QBO customers - loadCustomerCredits(); } + // --- 2. Credits async laden --- async function loadCustomerCredits() { const qboCustomers = customers.filter(c => c.qbo_id); diff --git a/public/invoice-view.js b/public/invoice-view.js index 110af3a..8d19d4c 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -167,13 +167,14 @@ function renderInvoiceRow(invoice) { // --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) --- const editBtn = ``; - // QBO Button — nur aktiv wenn Kunde eine qbo_id hat + // QBO Button — Export oder Sync const customerHasQbo = !!invoice.customer_qbo_id; let qboBtn; if (hasQbo) { - qboBtn = `✓ QBO`; + // Already in QBO — show sync button + reset option + qboBtn = ``; } else if (!customerHasQbo) { - qboBtn = `QBO ⚠`; + qboBtn = `QBO ⚠`; } else { qboBtn = ``; } @@ -349,6 +350,18 @@ export async function exportToQBO(id) { finally { if (typeof hideSpinner === 'function') hideSpinner(); } } +export async function syncToQBO(id) { + if (!confirm('Sync changes to QuickBooks Online?')) return; + if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...'); + try { + const r = await fetch(`/api/invoices/${id}/update-qbo`, { 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; try { @@ -385,6 +398,6 @@ export async function remove(id) { // ============================================================ window.invoiceView = { - viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove, + viewPDF, viewHTML, exportToQBO, syncToQBO, resetQbo, markPaid, markUnpaid, edit, remove, loadInvoices, renderInvoiceView, setStatus }; \ No newline at end of file diff --git a/public/payment-modal.js b/public/payment-modal.js index 1409b35..f2e36c0 100644 --- a/public/payment-modal.js +++ b/public/payment-modal.js @@ -1,11 +1,10 @@ -// payment-modal.js — ES Module v3 -// Invoice payments only: multi-invoice, partial, editable amounts -// Downpayment is handled separately in customer view +// payment-modal.js — ES Module v3 (clean) +// Invoice payments: multi-invoice, partial, overpay +// No downpayment functionality let bankAccounts = []; let paymentMethods = []; let selectedInvoices = []; // { invoice, payAmount } -let customerCredit = 0; // Unapplied credit from QBO let dataLoaded = false; // ============================================================ @@ -32,7 +31,6 @@ async function loadQboData() { export async function openPaymentModal(invoiceIds = []) { await loadQboData(); selectedInvoices = []; - customerCredit = 0; for (const id of invoiceIds) { try { @@ -47,18 +45,6 @@ export async function openPaymentModal(invoiceIds = []) { } catch (e) { console.error('Error loading invoice:', id, e); } } - // Check customer credit if we have invoices - if (selectedInvoices.length > 0) { - const custQboId = selectedInvoices[0].invoice.customer_qbo_id; - if (custQboId) { - try { - const res = await fetch(`/api/qbo/customer-credit/${custQboId}`); - const data = await res.json(); - customerCredit = data.credit || 0; - } catch (e) { /* ignore */ } - } - } - ensureModalElement(); renderModalContent(); document.getElementById('payment-modal').classList.add('active'); @@ -68,7 +54,6 @@ export function closePaymentModal() { const modal = document.getElementById('payment-modal'); if (modal) modal.classList.remove('active'); selectedInvoices = []; - customerCredit = 0; } // ============================================================ @@ -146,22 +131,10 @@ function renderModalContent() { const accountOptions = bankAccounts.map(a => ``).join(''); const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name)); - const methods = (filtered.length > 0 ? filtered : paymentMethods); + const methods = filtered.length > 0 ? filtered : paymentMethods; const methodOptions = methods.map(p => ``).join(''); const today = new Date().toISOString().split('T')[0]; - // Credit banner - let creditBanner = ''; - if (customerCredit > 0) { - creditBanner = ` -
-

- 💰 Customer has $${customerCredit.toFixed(2)} unapplied credit. - This can be applied in QBO when processing the payment. -

-
`; - } - modal.innerHTML = `
@@ -173,8 +146,6 @@ function renderModalContent() {
- ${creditBanner} -
@@ -285,13 +256,11 @@ function updateTotal() { const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0); const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0); - totalEl.textContent = `$${payTotal.toFixed(2)}`; if (noteEl) { if (payTotal > invTotal && invTotal > 0) { - const overpay = payTotal - invTotal; - noteEl.textContent = `⚠️ Overpayment of $${overpay.toFixed(2)} will be stored as customer credit in QBO.`; + noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`; noteEl.classList.remove('hidden'); } else { noteEl.classList.add('hidden'); @@ -336,7 +305,6 @@ async function submitPayment() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - mode: 'invoice', invoice_payments: selectedInvoices.map(si => ({ invoice_id: si.invoice.id, amount: si.payAmount diff --git a/server.js b/server.js index 166887b..e18b532 100644 --- a/server.js +++ b/server.js @@ -1660,41 +1660,8 @@ app.get('/api/qbo/payment-methods', async (req, res) => { } }); -// --- Customer Credit (unapplied payments) --- -app.get('/api/qbo/customer-credit/:qboCustomerId', async (req, res) => { - try { - const { qboCustomerId } = req.params; - 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'; - const query = `SELECT * FROM Payment WHERE CustomerRef = '${qboCustomerId}' AND UnappliedAmt > '0'`; - const response = await makeQboApiCall({ - url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, - method: 'GET' - }); - const data = response.getJson ? response.getJson() : response.json; - const payments = data.QueryResponse?.Payment || []; - - const totalCredit = payments.reduce((sum, p) => sum + (parseFloat(p.UnappliedAmt) || 0), 0); - const details = payments.map(p => ({ - qbo_id: p.Id, - date: p.TxnDate, - total: p.TotalAmt, - unapplied: p.UnappliedAmt, - ref: p.PaymentRefNum || '' - })); - - res.json({ credit: totalCredit, payments: details }); - } catch (error) { - console.error('Error fetching customer credit:', error); - res.json({ credit: 0, payments: [] }); - } -}); - -// --- Record Payment (against invoices: normal, partial, multi, overpay) --- +// --- Record Payment (against invoices) --- app.post('/api/qbo/record-payment', async (req, res) => { const { invoice_payments, // [{ invoice_id, amount }] @@ -1772,7 +1739,6 @@ app.post('/api/qbo/record-payment', async (req, res) => { const qboPaymentId = data.Payment.Id; console.log(`✅ QBO Payment ID: ${qboPaymentId}`); - // Local DB 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) @@ -1791,7 +1757,6 @@ app.post('/api/qbo/record-payment', async (req, res) => { 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', [localPaymentId, ip.invoice_id, payAmt] ); - // Mark paid only if fully covered if (payAmt >= invTotal) { await dbClient.query( 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', @@ -1818,75 +1783,136 @@ app.post('/api/qbo/record-payment', async (req, res) => { } }); -// --- Record Downpayment (unapplied, from customer view) --- -app.post('/api/qbo/record-downpayment', async (req, res) => { - const { - customer_id, // Local customer ID - customer_qbo_id, // QBO customer ID - amount, - payment_date, - reference_number, - payment_method_id, - payment_method_name, - deposit_to_account_id, - deposit_to_account_name - } = req.body; +// ===================================================== +// QBO INVOICE UPDATE — Sync local changes to QBO +// ===================================================== +// Aktualisiert eine bereits exportierte Invoice in QBO. +// Benötigt qbo_id + qbo_sync_token (Optimistic Locking). +// Sendet alle Items neu (QBO ersetzt die Line-Items komplett). - if (!customer_qbo_id || !amount || amount <= 0) { - return res.status(400).json({ error: 'Customer and amount required.' }); - } +app.post('/api/invoices/:id/update-qbo', async (req, res) => { + const { id } = req.params; + const QBO_LABOR_ID = '5'; + const QBO_PARTS_ID = '9'; + const dbClient = await pool.connect(); try { + // 1. Lokale Rechnung + Items laden + const invoiceRes = await dbClient.query(` + SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + WHERE i.id = $1 + `, [id]); + + if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); + const invoice = invoiceRes.rows[0]; + + if (!invoice.qbo_id) { + return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' }); + } + if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') { + return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' }); + } + + const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]); + const items = itemsRes.rows; + + // 2. QBO vorbereiten 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'; - const qboPayment = { - CustomerRef: { value: customer_qbo_id }, - TotalAmt: parseFloat(amount), - TxnDate: payment_date, - PaymentRefNum: reference_number || '', - PaymentMethodRef: { value: payment_method_id }, - DepositToAccountRef: { value: deposit_to_account_id } - // No Line[] → unapplied payment - }; - - console.log(`💰 Downpayment: $${amount} for customer QBO ${customer_qbo_id}`); - - const response = await makeQboApiCall({ - url: `${baseUrl}/v3/company/${companyId}/payment`, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(qboPayment) + // 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen + console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`); + const currentQboRes = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, + method: 'GET' }); - const data = response.getJson ? response.getJson() : response.json; + const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json; + const currentQboInvoice = currentQboData.Invoice; - if (!data.Payment) { - return res.status(500).json({ - error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)) - }); + if (!currentQboInvoice) { + return res.status(500).json({ error: 'Could not load current invoice from QBO.' }); } - const qboPaymentId = data.Payment.Id; + const currentSyncToken = currentQboInvoice.SyncToken; + console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`); - // Local DB - await pool.query( - `INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [payment_date, reference_number || null, payment_method_name || 'Check', - deposit_to_account_name || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)'] + // 4. Line Items bauen + const lineItems = items.map(item => { + const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0; + const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0; + const itemRefId = item.qbo_item_id || QBO_PARTS_ID; + const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts"; + + return { + "DetailType": "SalesItemLineDetail", + "Amount": amount, + "Description": item.description, + "SalesItemLineDetail": { + "ItemRef": { "value": itemRefId, "name": itemRefName }, + "UnitPrice": rate, + "Qty": parseFloat(item.quantity) || 1 + } + }; + }); + + // 5. QBO Update Payload — sparse update + // Id + SyncToken sind Pflicht. Alles was mitgesendet wird, wird aktualisiert. + const updatePayload = { + "Id": invoice.qbo_id, + "SyncToken": currentSyncToken, + "sparse": true, + "Line": lineItems, + "CustomerRef": { "value": invoice.customer_qbo_id }, + "TxnDate": invoice.invoice_date.toISOString().split('T')[0], + "CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" } + }; + + console.log(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`); + + const updateResponse = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/invoice`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatePayload) + }); + + const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json; + const updatedInvoice = updateData.Invoice || updateData; + + if (!updatedInvoice.Id) { + console.error("QBO Update Response:", JSON.stringify(updateData, null, 2)); + throw new Error("QBO did not return an updated invoice."); + } + + console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`); + + // 6. Neuen SyncToken lokal speichern + await dbClient.query( + 'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [updatedInvoice.SyncToken, id] ); res.json({ success: true, - qbo_payment_id: qboPaymentId, - message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).` + qbo_id: updatedInvoice.Id, + sync_token: updatedInvoice.SyncToken, + message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.` }); + } catch (error) { - console.error('❌ Downpayment Error:', error); - res.status(500).json({ error: 'Downpayment failed: ' + error.message }); + console.error("QBO Update Error:", error); + let errorDetails = error.message; + if (error.response?.data?.Fault?.Error?.[0]) { + errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail; + } + res.status(500).json({ error: "QBO Update failed: " + errorDetails }); + } finally { + dbClient.release(); } });