From 49aeff8cb659bb0129e5f58b06a73a6355c4b6e9 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 19 Feb 2026 21:27:03 -0600 Subject: [PATCH] update --- public/app.js | 184 +++++++++++++++++++++- public/payment-modal.js | 321 ++++++++++++++----------------------- server.js | 341 ++++++++++++++++++++++------------------ 3 files changed, 486 insertions(+), 360 deletions(-) diff --git a/public/app.js b/public/app.js index 71a2200..b73b6c9 100644 --- a/public/app.js +++ b/public/app.js @@ -216,6 +216,9 @@ async function loadCustomers() { } } +// --- 1. renderCustomers() — ERSETZE komplett --- +// Zeigt QBO-Status, Credit-Betrag und Downpayment-Button + function renderCustomers() { const tbody = document.getElementById('customers-list'); tbody.innerHTML = customers.map(customer => { @@ -225,25 +228,198 @@ function renderCustomers() { if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip; // QBO Status - const qboStatus = customer.qbo_id - ? `QBO ✓` - : ``; + 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 + ? `...` + : ''; return ` - ${customer.name} ${qboStatus} + ${customer.name} ${qboCol} ${creditSpan} ${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); + for (const cust of qboCustomers) { + const span = document.getElementById(`customer-credit-${cust.id}`); + if (!span) continue; + try { + const res = await fetch(`/api/qbo/customer-credit/${cust.qbo_id}`); + const data = await res.json(); + if (data.credit > 0) { + span.innerHTML = `Credit: $${data.credit.toFixed(2)}`; + } else { + span.textContent = ''; + } + } catch (e) { + span.textContent = ''; + } + } +} +// --- 3. Downpayment Dialog --- +async function openDownpaymentModal(customerId, customerQboId, customerName) { + // Load QBO data if needed + let bankAccounts = []; + let paymentMethods = []; + try { + const [accRes, pmRes] = await Promise.all([ + fetch('/api/qbo/accounts'), + fetch('/api/qbo/payment-methods') + ]); + if (accRes.ok) bankAccounts = await accRes.json(); + if (pmRes.ok) paymentMethods = await pmRes.json(); + } catch (e) { console.error('Error loading QBO data:', e); } + + 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 methodOptions = methods.map(p => ``).join(''); + const today = new Date().toISOString().split('T')[0]; + + let modal = document.getElementById('downpayment-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'downpayment-modal'; + modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` +
+
+

💰 Record Downpayment

+ +
+ +
+

+ Customer: ${customerName}
+ This will record an unapplied payment (credit) on the customer's QBO account. +

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
`; + + modal.classList.add('active'); + document.getElementById('dp-amount').focus(); } +function closeDownpaymentModal() { + const modal = document.getElementById('downpayment-modal'); + if (modal) modal.classList.remove('active'); +} +async function submitDownpayment(customerId, customerQboId) { + const amount = parseFloat(document.getElementById('dp-amount').value); + const date = document.getElementById('dp-date').value; + const ref = document.getElementById('dp-reference').value; + const methodSelect = document.getElementById('dp-method'); + const depositSelect = document.getElementById('dp-deposit'); + + if (!amount || amount <= 0) { alert('Please enter an amount.'); return; } + if (!date || !methodSelect.value || !depositSelect.value) { alert('Please fill in all fields.'); return; } + + if (!confirm(`Record downpayment of $${amount.toFixed(2)}?`)) return; + + const btn = document.getElementById('dp-submit-btn'); + btn.innerHTML = '⏳ Processing...'; + btn.disabled = true; + if (typeof showSpinner === 'function') showSpinner('Recording downpayment in QBO...'); + + try { + const response = await fetch('/api/qbo/record-downpayment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customer_id: customerId, + customer_qbo_id: customerQboId, + amount: amount, + payment_date: date, + reference_number: ref, + payment_method_id: methodSelect.value, + payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '', + deposit_to_account_id: depositSelect.value, + deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || '' + }) + }); + const result = await response.json(); + if (response.ok) { + alert(`✅ ${result.message}`); + closeDownpaymentModal(); + renderCustomers(); // Refresh credit display + } else { + alert(`❌ Error: ${result.error}`); + } + } catch (e) { + alert('Network error.'); + } finally { + btn.innerHTML = '💰 Record Downpayment'; + btn.disabled = false; + if (typeof hideSpinner === 'function') hideSpinner(); + } +} function openCustomerModal(customerId = null) { currentCustomerId = customerId; const modal = document.getElementById('customer-modal'); diff --git a/public/payment-modal.js b/public/payment-modal.js index 0682252..1409b35 100644 --- a/public/payment-modal.js +++ b/public/payment-modal.js @@ -1,11 +1,12 @@ -// payment-modal.js — ES Module v2 -// Supports: Multi-invoice, partial payments, unapplied (downpayments), editable amounts +// payment-modal.js — ES Module v3 +// Invoice payments only: multi-invoice, partial, editable amounts +// Downpayment is handled separately in customer view let bankAccounts = []; let paymentMethods = []; let selectedInvoices = []; // { invoice, payAmount } +let customerCredit = 0; // Unapplied credit from QBO let dataLoaded = false; -let paymentMode = 'invoice'; // 'invoice' | 'unapplied' // ============================================================ // Load QBO Data @@ -28,10 +29,10 @@ async function loadQboData() { // Open / Close // ============================================================ -export async function openPaymentModal(invoiceIds = [], mode = 'invoice') { +export async function openPaymentModal(invoiceIds = []) { await loadQboData(); - paymentMode = mode; selectedInvoices = []; + customerCredit = 0; for (const id of invoiceIds) { try { @@ -46,19 +47,28 @@ export async function openPaymentModal(invoiceIds = [], mode = 'invoice') { } 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'); } -export function openDownpaymentModal() { - openPaymentModal([], 'unapplied'); -} - export function closePaymentModal() { const modal = document.getElementById('payment-modal'); if (modal) modal.classList.remove('active'); selectedInvoices = []; + customerCredit = 0; } // ============================================================ @@ -70,43 +80,23 @@ async function addInvoiceById() { const searchVal = input.value.trim(); if (!searchVal) return; - // Suche nach Invoice-Nummer oder ID try { const res = await fetch('/api/invoices'); const allInvoices = await res.json(); - const match = allInvoices.find(inv => - String(inv.id) === searchVal || - String(inv.invoice_number) === searchVal + String(inv.id) === searchVal || String(inv.invoice_number) === searchVal ); - if (!match) { - alert(`Keine Rechnung mit Nr/ID "${searchVal}" gefunden.`); - return; - } - - // Validierungen - if (!match.qbo_id) { - alert('Diese Rechnung ist noch nicht in QBO.'); - return; - } - if (match.paid_date) { - alert('Diese Rechnung ist bereits bezahlt.'); - return; - } - if (selectedInvoices.find(si => si.invoice.id === match.id)) { - alert('Diese Rechnung ist bereits in der Liste.'); - return; - } + if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; } + if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; } + if (match.paid_date) { alert('This invoice is already paid.'); return; } + if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; } if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) { - alert('Alle Rechnungen müssen zum selben Kunden gehören.'); - return; + alert('All invoices must belong to the same customer.'); return; } - // Details laden const detailRes = await fetch(`/api/invoices/${match.id}`); const detailData = await detailRes.json(); - selectedInvoices.push({ invoice: detailData.invoice, payAmount: parseFloat(detailData.invoice.total) @@ -117,7 +107,7 @@ async function addInvoiceById() { input.value = ''; } catch (e) { console.error('Error adding invoice:', e); - alert('Fehler beim Suchen.'); + alert('Error searching for invoice.'); } } @@ -130,10 +120,9 @@ function removeInvoice(invoiceId) { function updatePayAmount(invoiceId, newAmount) { const si = selectedInvoices.find(s => s.invoice.id === invoiceId); if (si) { - const val = parseFloat(newAmount) || 0; - const max = parseFloat(si.invoice.total); - si.payAmount = Math.min(val, max); // Nicht mehr als Total + si.payAmount = Math.max(0, parseFloat(newAmount) || 0); } + renderInvoiceList(); updateTotal(); } @@ -155,26 +144,28 @@ function renderModalContent() { const modal = document.getElementById('payment-modal'); if (!modal) return; - const accountOptions = bankAccounts.map(acc => - `` - ).join(''); - - const filteredMethods = paymentMethods.filter(pm => - pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach') - ); - const methodsToShow = filteredMethods.length > 0 ? filteredMethods : paymentMethods; - const methodOptions = methodsToShow.map(pm => - `` - ).join(''); - + 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 methodOptions = methods.map(p => ``).join(''); const today = new Date().toISOString().split('T')[0]; - const isUnapplied = paymentMode === 'unapplied'; - const title = isUnapplied ? '💰 Record Downpayment' : '💰 Record Payment'; + + // 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 = `
-

${title}

+

💰 Record Payment

- -
- - -
+ ${creditBanner} - ${isUnapplied ? renderUnappliedSection() : renderInvoiceSection()} + +
+ +
+
+ + +
+
@@ -230,6 +222,7 @@ function renderModalContent() { Total Payment: $0.00
+
@@ -243,62 +236,16 @@ function renderModalContent() { `; - if (!isUnapplied) { - renderInvoiceList(); - } + renderInvoiceList(); updateTotal(); } -function renderInvoiceSection() { - return ` -
- -
-
- - -
-
`; -} - -function renderUnappliedSection() { - return ` -
-
-

- Downpayment / Vorabzahlung: Das Geld wird als Kundenguthaben in QBO verbucht - und kann später einer Rechnung zugeordnet werden. -

-
-
-
- - -
-
- - -
-
-
`; -} - function renderInvoiceList() { const container = document.getElementById('payment-invoice-list'); if (!container) return; if (selectedInvoices.length === 0) { - container.innerHTML = `
Keine Rechnungen — bitte unten hinzufügen
`; + container.innerHTML = `
No invoices selected — add below
`; return; } @@ -306,22 +253,24 @@ function renderInvoiceList() { const inv = si.invoice; const total = parseFloat(inv.total); const isPartial = si.payAmount < total; + const isOver = si.payAmount > total; return `
-
+
#${inv.invoice_number || 'Draft'} - ${inv.customer_name || ''} + ${inv.customer_name || ''} (Total: $${total.toFixed(2)}) - ${isPartial ? 'Partial' : ''} + ${isPartial ? 'Partial' : ''} + ${isOver ? 'Overpay' : ''}
-
+
$ - + class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold + ${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
@@ -331,17 +280,23 @@ function renderInvoiceList() { function updateTotal() { const totalEl = document.getElementById('payment-total'); + const noteEl = document.getElementById('payment-overpay-note'); if (!totalEl) return; - let total = 0; - if (paymentMode === 'unapplied') { - const amountInput = document.getElementById('payment-unapplied-amount'); - total = parseFloat(amountInput?.value) || 0; - } else { - total = selectedInvoices.reduce((sum, si) => sum + si.payAmount, 0); - } + const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0); + const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0); - totalEl.textContent = `$${total.toFixed(2)}`; + 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.classList.remove('hidden'); + } else { + noteEl.classList.add('hidden'); + } + } } // ============================================================ @@ -349,97 +304,62 @@ function updateTotal() { // ============================================================ async function submitPayment() { + if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; } + const paymentDate = document.getElementById('payment-date').value; const reference = document.getElementById('payment-reference').value; const methodSelect = document.getElementById('payment-method'); const depositSelect = document.getElementById('payment-deposit-to'); - const methodId = methodSelect.value; - const methodName = methodSelect.options[methodSelect.selectedIndex]?.text || ''; - const depositToId = depositSelect.value; - const depositToName = depositSelect.options[depositSelect.selectedIndex]?.text || ''; - if (!paymentDate || !methodId || !depositToId) { - alert('Bitte alle Felder ausfüllen.'); - return; + if (!paymentDate || !methodSelect.value || !depositSelect.value) { + alert('Please fill in all fields.'); return; } - let body; + const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0); + const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0); + const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', '); + const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total)); + const hasOverpay = total > invTotal; - if (paymentMode === 'unapplied') { - // --- Downpayment --- - const custSelect = document.getElementById('payment-customer'); - const customerId = custSelect?.value; - const customerQboId = custSelect?.selectedOptions[0]?.getAttribute('data-qbo-id'); - const amount = parseFloat(document.getElementById('payment-unapplied-amount')?.value) || 0; - - if (!customerId || !customerQboId) { alert('Bitte Kunde wählen.'); return; } - if (amount <= 0) { alert('Bitte Betrag eingeben.'); return; } - - body = { - mode: 'unapplied', - customer_id: parseInt(customerId), - customer_qbo_id: customerQboId, - total_amount: amount, - payment_date: paymentDate, - reference_number: reference, - payment_method_id: methodId, - payment_method_name: methodName, - deposit_to_account_id: depositToId, - deposit_to_account_name: depositToName - }; - - if (!confirm(`Downpayment $${amount.toFixed(2)} an QBO senden?`)) return; - - } else { - // --- Normal / Partial / Multi --- - if (selectedInvoices.length === 0) { alert('Bitte Rechnungen hinzufügen.'); return; } - - const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0); - const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', '); - const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total)); - - let msg = `Payment $${total.toFixed(2)} für ${nums} an QBO senden?`; - if (hasPartial) msg += '\n⚠️ Enthält Teilzahlung(en).'; - if (!confirm(msg)) return; - - body = { - mode: 'invoice', - invoice_payments: selectedInvoices.map(si => ({ - invoice_id: si.invoice.id, - amount: si.payAmount - })), - payment_date: paymentDate, - reference_number: reference, - payment_method_id: methodId, - payment_method_name: methodName, - deposit_to_account_id: depositToId, - deposit_to_account_name: depositToName - }; - } + let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`; + if (hasPartial) msg += '\n⚠️ Contains partial payment(s).'; + if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`; + if (!confirm(msg)) return; const submitBtn = document.getElementById('payment-submit-btn'); - submitBtn.innerHTML = '⏳ Wird gesendet...'; + submitBtn.innerHTML = '⏳ Processing...'; submitBtn.disabled = true; - if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...'); + if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...'); try { const response = await fetch('/api/qbo/record-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify({ + mode: 'invoice', + invoice_payments: selectedInvoices.map(si => ({ + invoice_id: si.invoice.id, + amount: si.payAmount + })), + payment_date: paymentDate, + reference_number: reference, + payment_method_id: methodSelect.value, + payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '', + deposit_to_account_id: depositSelect.value, + deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || '' + }) }); const result = await response.json(); - if (response.ok) { alert(`✅ ${result.message}`); closePaymentModal(); if (window.invoiceView) window.invoiceView.loadInvoices(); } else { - alert(`❌ Fehler: ${result.error}`); + alert(`❌ Error: ${result.error}`); } - } catch (error) { - console.error('Payment error:', error); - alert('Netzwerkfehler.'); + } catch (e) { + console.error('Payment error:', e); + alert('Network error.'); } finally { submitBtn.innerHTML = '💰 Record Payment in QBO'; submitBtn.disabled = false; @@ -447,23 +367,16 @@ async function submitPayment() { } } -function switchMode(mode) { - paymentMode = mode; - renderModalContent(); -} - // ============================================================ // Expose // ============================================================ window.paymentModal = { open: openPaymentModal, - openDownpayment: openDownpaymentModal, close: closePaymentModal, submit: submitPayment, addById: addInvoiceById, removeInvoice: removeInvoice, updateAmount: updatePayAmount, - updateTotal: updateTotal, - switchMode: switchMode + updateTotal: updateTotal }; \ No newline at end of file diff --git a/server.js b/server.js index b8a717f..166887b 100644 --- a/server.js +++ b/server.js @@ -1612,12 +1612,14 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => { }); // ===================================================== -// QBO PAYMENT ENDPOINTS v2 — In server.js einfügen -// Supports: multi-invoice, partial, unapplied (downpayment) +// QBO PAYMENT ENDPOINTS v3 — In server.js einfügen +// - Invoice payments (multi, partial, overpay) +// - Downpayment (separate endpoint, called from customer view) +// - Customer credit query // ===================================================== -// --- Bank-Konten aus QBO (für Deposit To) --- +// --- Bank-Konten aus QBO --- app.get('/api/qbo/accounts', async (req, res) => { try { const oauthClient = getOAuthClient(); @@ -1625,7 +1627,6 @@ app.get('/api/qbo/accounts', async (req, res) => { const baseUrl = process.env.QBO_ENVIRONMENT === 'production' ? 'https://quickbooks.api.intuit.com' : 'https://sandbox-quickbooks.api.intuit.com'; - const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, @@ -1634,7 +1635,6 @@ app.get('/api/qbo/accounts', async (req, res) => { const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name }))); } catch (error) { - console.error('Error fetching QBO accounts:', error); res.status(500).json({ error: error.message }); } }); @@ -1648,7 +1648,6 @@ app.get('/api/qbo/payment-methods', async (req, res) => { const baseUrl = process.env.QBO_ENVIRONMENT === 'production' ? 'https://quickbooks.api.intuit.com' : 'https://sandbox-quickbooks.api.intuit.com'; - const query = "SELECT * FROM PaymentMethod WHERE Active = true"; const response = await makeQboApiCall({ url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`, @@ -1657,23 +1656,48 @@ app.get('/api/qbo/payment-methods', async (req, res) => { const data = response.getJson ? response.getJson() : response.json; res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name }))); } catch (error) { - console.error('Error fetching payment methods:', error); res.status(500).json({ error: error.message }); } }); +// --- 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'; -// --- Record Payment (multi-invoice, partial, unapplied) --- + 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) --- app.post('/api/qbo/record-payment', async (req, res) => { const { - mode, // 'invoice' | 'unapplied' - // Mode 'invoice': invoice_payments, // [{ invoice_id, amount }] - // Mode 'unapplied': - customer_id, // Lokale Kunden-ID - customer_qbo_id, // QBO Customer ID - total_amount, // Betrag - // Gemeinsam: payment_date, reference_number, payment_method_id, @@ -1682,7 +1706,135 @@ app.post('/api/qbo/record-payment', async (req, res) => { 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 = process.env.QBO_ENVIRONMENT === 'production' + ? 'https://quickbooks.api.intuit.com' + : 'https://sandbox-quickbooks.api.intuit.com'; + + 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}`); + + // 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) + 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] + ); + // 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', + [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(); + } +}); + +// --- 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; + + if (!customer_qbo_id || !amount || amount <= 0) { + return res.status(400).json({ error: 'Customer and amount required.' }); + } try { const oauthClient = getOAuthClient(); @@ -1691,184 +1843,69 @@ app.post('/api/qbo/record-payment', async (req, res) => { ? 'https://quickbooks.api.intuit.com' : 'https://sandbox-quickbooks.api.intuit.com'; - let qboPayment; - let localCustomerId; - let totalAmt; - let invoicesData = []; + 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 + }; - if (mode === 'unapplied') { - // ---- DOWNPAYMENT: kein LinkedTxn ---- - if (!customer_qbo_id || !total_amount) { - return res.status(400).json({ error: 'Kunde und Betrag erforderlich.' }); - } + console.log(`💰 Downpayment: $${amount} for customer QBO ${customer_qbo_id}`); - localCustomerId = customer_id; - totalAmt = parseFloat(total_amount); - - qboPayment = { - CustomerRef: { value: customer_qbo_id }, - TotalAmt: totalAmt, - TxnDate: payment_date, - PaymentRefNum: reference_number || '', - PaymentMethodRef: { value: payment_method_id }, - DepositToAccountRef: { value: deposit_to_account_id } - // Kein Line[] → Unapplied Payment - }; - - console.log(`💰 Downpayment: $${totalAmt.toFixed(2)} für Kunde QBO ${customer_qbo_id}`); - - } else { - // ---- INVOICE PAYMENT (normal, partial, multi) ---- - if (!invoice_payments || invoice_payments.length === 0) { - return res.status(400).json({ error: 'Keine Rechnungen ausgewählt.' }); - } - - 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] - ); - invoicesData = result.rows; - - // Validierung - const notInQbo = invoicesData.filter(inv => !inv.qbo_id); - if (notInQbo.length > 0) { - return res.status(400).json({ - error: `Nicht 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: 'Alle Rechnungen müssen zum selben Kunden gehören.' }); - } - - localCustomerId = invoicesData[0].customer_id; - - // Beträge zuordnen - const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)])); - totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0); - - 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' }] - })) - }; - - const hasPartial = invoicesData.some(inv => { - const payAmt = paymentMap.get(inv.id) || 0; - return payAmt < parseFloat(inv.total); - }); - - console.log(`💰 Payment: $${totalAmt.toFixed(2)} für ${invoicesData.length} Rechnung(en)${hasPartial ? ' (Teilzahlung)' : ''}`); - } - - // --- QBO senden --- 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) { - console.error('❌ QBO Payment Fehler:', JSON.stringify(data)); return res.status(500).json({ - error: 'QBO Fehler: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)) + error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)) }); } const qboPaymentId = data.Payment.Id; - console.log(`✅ QBO Payment ID: ${qboPaymentId}`); - // --- Lokal speichern --- - 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`, + // 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 || '', totalAmt, localCustomerId, qboPaymentId] + deposit_to_account_name || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)'] ); - const localPaymentId = payResult.rows[0].id; - // Invoices verknüpfen + als bezahlt markieren - if (mode !== 'unapplied' && invoice_payments) { - 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; - const isFullyPaid = payAmt >= invTotal; - - await dbClient.query( - 'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)', - [localPaymentId, ip.invoice_id, payAmt] - ); - - if (isFullyPaid) { - // Voll bezahlt → paid_date setzen - await dbClient.query( - 'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', - [payment_date, ip.invoice_id] - ); - } - // Teilzahlung → paid_date bleibt NULL (Rechnung noch offen) - } - } - - await dbClient.query('COMMIT'); - - const modeLabel = mode === 'unapplied' ? 'Downpayment' : 'Payment'; res.json({ success: true, - payment_id: localPaymentId, qbo_payment_id: qboPaymentId, - total: totalAmt, - invoices_paid: mode === 'unapplied' ? 0 : invoice_payments.length, - message: `${modeLabel} $${totalAmt.toFixed(2)} erfasst (QBO: ${qboPaymentId}).` + message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).` }); - } catch (error) { - await dbClient.query('ROLLBACK').catch(() => {}); - console.error('❌ Payment Error:', error); - res.status(500).json({ error: 'Payment fehlgeschlagen: ' + error.message }); - } finally { - dbClient.release(); + console.error('❌ Downpayment Error:', error); + res.status(500).json({ error: 'Downpayment failed: ' + error.message }); } }); - -// --- Lokale Payments auflisten --- +// --- List local payments --- app.get('/api/payments', async (req, res) => { try { const result = await pool.query(` SELECT p.*, c.name as customer_name, COALESCE(json_agg(json_build_object( - 'invoice_id', pi.invoice_id, - 'amount', pi.amount, - 'invoice_number', i.invoice_number + 'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number )) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices FROM payments p LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN payment_invoices pi ON pi.payment_id = p.id LEFT JOIN invoices i ON i.id = pi.invoice_id - GROUP BY p.id, c.name - ORDER BY p.payment_date DESC + GROUP BY p.id, c.name ORDER BY p.payment_date DESC `); res.json(result.rows); } catch (error) { - console.error('Error fetching payments:', error); res.status(500).json({ error: 'Error fetching payments' }); } });