From acb588425a0c29b4122206b6ddabbe6702a5126c Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 18 Feb 2026 09:39:06 -0600 Subject: [PATCH] payments - 1. version --- public/invoice-view-init.js | 8 +- public/invoice-view.js | 13 +- public/payment-modal.js | 356 ++++++++++++++++++++++++++++++++++++ server.js | 198 ++++++++++++++++++++ 4 files changed, 564 insertions(+), 11 deletions(-) create mode 100644 public/payment-modal.js diff --git a/public/invoice-view-init.js b/public/invoice-view-init.js index 44f85ea..6762660 100644 --- a/public/invoice-view-init.js +++ b/public/invoice-view-init.js @@ -3,8 +3,8 @@ // Importiert das Invoice-View Modul und verbindet es mit der bestehenden App. import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js'; +import './payment-modal.js'; -// Warte bis DOM fertig ist if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { @@ -12,14 +12,8 @@ if (document.readyState === 'loading') { } function init() { - // Toolbar injizieren injectToolbar(); - - // Globale Funktionen für app.js verfügbar machen - // (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird) window.loadInvoices = loadInvoices; window.renderInvoices = renderInvoiceView; - - // Initiales Laden loadInvoices(); } \ No newline at end of file diff --git a/public/invoice-view.js b/public/invoice-view.js index 9820e1e..0fb49a0 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -253,10 +253,15 @@ function renderInvoiceRow(invoice) { : ``; const htmlBtn = ``; - // Paid/Unpaid - const paidBtn = paid - ? `` - : ``; + // Paid/Unpaid — wenn in QBO: Payment-Modal öffnen, sonst lokal markieren + let paidBtn; + if (paid) { + paidBtn = ``; + } else if (hasQbo && window.paymentModal) { + paidBtn = ``; + } else { + paidBtn = ``; + } // Delete const delBtn = ``; diff --git a/public/payment-modal.js b/public/payment-modal.js new file mode 100644 index 0000000..ef6800f --- /dev/null +++ b/public/payment-modal.js @@ -0,0 +1,356 @@ +// payment-modal.js — ES Module für das Payment Recording Modal +// Ermöglicht: Auswahl mehrerer Rechnungen, Check/ACH, Deposit To Konto + +// ============================================================ +// State +// ============================================================ +let bankAccounts = []; +let paymentMethods = []; +let selectedInvoices = []; // Array of invoice objects +let isOpen = false; + +// Cache QBO reference data (nur einmal laden) +let dataLoaded = false; + +// ============================================================ +// Load QBO Reference Data +// ============================================================ + +async function loadQboData() { + if (dataLoaded) return; + + 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(); + + dataLoaded = true; + } catch (error) { + console.error('Error loading QBO reference data:', error); + } +} + +// ============================================================ +// Open / Close Modal +// ============================================================ + +export async function openPaymentModal(invoiceIds = []) { + // Lade QBO-Daten falls noch nicht geschehen + await loadQboData(); + + // Lade die ausgewählten Rechnungen + if (invoiceIds.length > 0) { + selectedInvoices = []; + for (const id of invoiceIds) { + try { + const res = await fetch(`/api/invoices/${id}`); + const data = await res.json(); + if (data.invoice) { + selectedInvoices.push(data.invoice); + } + } catch (e) { + console.error('Error loading invoice:', id, e); + } + } + } + + renderModal(); + document.getElementById('payment-modal').classList.add('active'); + isOpen = true; +} + +export function closePaymentModal() { + const modal = document.getElementById('payment-modal'); + if (modal) modal.classList.remove('active'); + isOpen = false; + selectedInvoices = []; +} + +// ============================================================ +// Add/Remove Invoices from selection +// ============================================================ + +export async function addInvoiceToPayment(invoiceId) { + if (selectedInvoices.find(inv => inv.id === invoiceId)) return; // already selected + + try { + const res = await fetch(`/api/invoices/${invoiceId}`); + const data = await res.json(); + if (data.invoice) { + // Validierung: Muss QBO-verknüpft sein + if (!data.invoice.qbo_id) { + alert('Diese Rechnung ist noch nicht in QBO. Bitte erst exportieren.'); + return; + } + // Validierung: Alle müssen zum selben Kunden gehören + if (selectedInvoices.length > 0 && data.invoice.customer_id !== selectedInvoices[0].customer_id) { + alert('Alle Rechnungen eines Payments müssen zum selben Kunden gehören.'); + return; + } + selectedInvoices.push(data.invoice); + renderInvoiceList(); + updateTotal(); + } + } catch (e) { + console.error('Error adding invoice:', e); + } +} + +function removeInvoiceFromPayment(invoiceId) { + selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId); + renderInvoiceList(); + updateTotal(); +} + +// ============================================================ +// Rendering +// ============================================================ + +function renderModal() { + let modal = document.getElementById('payment-modal'); + + if (!modal) { + modal = document.createElement('div'); + modal.id = 'payment-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + const accountOptions = bankAccounts.map(acc => + `` + ).join(''); + + const methodOptions = paymentMethods + .filter(pm => ['Check', 'ACH'].includes(pm.name) || pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach')) + .map(pm => ``) + .join(''); + + // Falls keine Filter-Treffer, alle anzeigen + const allMethodOptions = paymentMethods.map(pm => + `` + ).join(''); + + const today = new Date().toISOString().split('T')[0]; + + modal.innerHTML = ` + + `; + + renderInvoiceList(); + updateTotal(); +} + +function renderInvoiceList() { + const container = document.getElementById('payment-invoice-list'); + if (!container) return; + + if (selectedInvoices.length === 0) { + container.innerHTML = `
Keine Rechnungen ausgewählt
`; + return; + } + + container.innerHTML = selectedInvoices.map(inv => ` +
+
+ #${inv.invoice_number || 'Draft'} + ${inv.customer_name || ''} +
+
+ $${parseFloat(inv.total).toFixed(2)} + +
+
+ `).join(''); +} + +function updateTotal() { + const totalEl = document.getElementById('payment-total'); + if (!totalEl) return; + + const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0); + totalEl.textContent = `$${total.toFixed(2)}`; + + // Submit-Button deaktivieren wenn keine Rechnungen + const submitBtn = document.getElementById('payment-submit-btn'); + if (submitBtn) { + submitBtn.disabled = selectedInvoices.length === 0; + submitBtn.classList.toggle('opacity-50', selectedInvoices.length === 0); + } +} + +// ============================================================ +// Submit Payment +// ============================================================ + +async function submitPayment() { + if (selectedInvoices.length === 0) { + alert('Bitte mindestens eine Rechnung auswählen.'); + return; + } + + const paymentDate = document.getElementById('payment-date').value; + const reference = document.getElementById('payment-reference').value; + const methodId = document.getElementById('payment-method').value; + const depositToId = document.getElementById('payment-deposit-to').value; + + if (!paymentDate) { + alert('Bitte ein Zahlungsdatum angeben.'); + return; + } + + if (!methodId || !depositToId) { + alert('Bitte Payment Method und Deposit To auswählen.'); + return; + } + + const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0); + const invoiceNums = selectedInvoices.map(i => i.invoice_number || `ID:${i.id}`).join(', '); + + if (!confirm(`Payment von $${total.toFixed(2)} für Rechnung(en) ${invoiceNums} an QBO senden?`)) { + return; + } + + const submitBtn = document.getElementById('payment-submit-btn'); + const origText = submitBtn.innerHTML; + submitBtn.innerHTML = '⏳ Wird gesendet...'; + submitBtn.disabled = true; + + try { + const response = await fetch('/api/qbo/record-payment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invoice_ids: selectedInvoices.map(inv => inv.id), + payment_date: paymentDate, + reference_number: reference, + payment_method_id: methodId, + deposit_to_account_id: depositToId + }) + }); + + const result = await response.json(); + + if (response.ok) { + alert(`✅ ${result.message}`); + closePaymentModal(); + // Invoice-Liste aktualisieren + if (window.invoiceView) { + window.invoiceView.loadInvoices(); + } else if (typeof window.loadInvoices === 'function') { + window.loadInvoices(); + } + } else { + alert(`❌ Fehler: ${result.error}`); + } + } catch (error) { + console.error('Payment error:', error); + alert('Netzwerkfehler beim Payment.'); + } finally { + submitBtn.innerHTML = origText; + submitBtn.disabled = false; + } +} + +// ============================================================ +// Helper: Add by ID from input field +// ============================================================ + +async function addInvoiceById() { + const input = document.getElementById('payment-add-invoice-id'); + const id = parseInt(input.value); + if (!id) return; + + await addInvoiceToPayment(id); + input.value = ''; +} + +// ============================================================ +// Expose to window +// ============================================================ + +window.paymentModal = { + open: openPaymentModal, + close: closePaymentModal, + submit: submitPayment, + addInvoice: addInvoiceToPayment, + removeInvoice: removeInvoiceFromPayment, + addById: addInvoiceById +}; \ No newline at end of file diff --git a/server.js b/server.js index 7c62fca..6aa99f6 100644 --- a/server.js +++ b/server.js @@ -1611,6 +1611,204 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => { } }); +// ===================================================== +// QBO PAYMENT RECORDING - Server Endpoints +// In server.js einfügen (z.B. nach dem /api/qbo/import-unpaid Endpoint) +// ===================================================== + + +// --- 1. Bank-Konten aus QBO laden (für "Deposit To" Dropdown) --- +app.get('/api/qbo/accounts', 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'; + + // Nur Bank-Konten abfragen + 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; + const accounts = (data.QueryResponse?.Account || []).map(acc => ({ + id: acc.Id, + name: acc.Name, + fullName: acc.FullyQualifiedName || acc.Name + })); + + res.json(accounts); + } catch (error) { + console.error('Error fetching QBO accounts:', error); + res.status(500).json({ error: 'Error fetching bank accounts: ' + error.message }); + } +}); + + +// --- 2. Payment Methods aus QBO laden (für Check/ACH Dropdown) --- +app.get('/api/qbo/payment-methods', 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'; + + 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; + const methods = (data.QueryResponse?.PaymentMethod || []).map(pm => ({ + id: pm.Id, + name: pm.Name + })); + + res.json(methods); + } catch (error) { + console.error('Error fetching payment methods:', error); + res.status(500).json({ error: 'Error fetching payment methods: ' + error.message }); + } +}); + + +// --- 3. Payment in QBO erstellen (ein Check für 1..n Invoices) --- +app.post('/api/qbo/record-payment', async (req, res) => { + const { + invoice_ids, // Array von lokalen Invoice IDs + payment_date, // 'YYYY-MM-DD' + reference_number, // Check-Nummer oder ACH-Referenz + payment_method_id, // QBO PaymentMethod ID + deposit_to_account_id // QBO Bank Account ID + } = req.body; + + if (!invoice_ids || invoice_ids.length === 0) { + return res.status(400).json({ error: 'Keine Rechnungen ausgewählt.' }); + } + + 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'; + + // Lokale Invoices laden + const invoicesResult = 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)`, + [invoice_ids] + ); + + const invoicesData = invoicesResult.rows; + + // Validierung: Alle müssen eine qbo_id haben (schon in QBO) + const notInQbo = invoicesData.filter(inv => !inv.qbo_id); + if (notInQbo.length > 0) { + return res.status(400).json({ + error: `Folgende Rechnungen sind noch nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}` + }); + } + + // Validierung: Alle müssen denselben Kunden haben + const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))]; + if (customerIds.length > 1) { + return res.status(400).json({ + error: 'Alle Rechnungen eines Payments müssen zum selben Kunden gehören.' + }); + } + + const customerQboId = customerIds[0]; + + // Gesamtbetrag berechnen + const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0); + + // QBO Payment Objekt bauen + const payment = { + CustomerRef: { + value: customerQboId + }, + TotalAmt: totalAmount, + TxnDate: payment_date, + PaymentRefNum: reference_number || '', + PaymentMethodRef: { + value: payment_method_id + }, + DepositToAccountRef: { + value: deposit_to_account_id + }, + Line: invoicesData.map(inv => ({ + Amount: parseFloat(inv.total), + LinkedTxn: [{ + TxnId: inv.qbo_id, + TxnType: 'Invoice' + }] + })) + }; + + console.log(`💰 Erstelle QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en), Kunde: ${invoicesData[0].customer_name}`); + + // Payment an QBO senden + const response = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/payment`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payment) + }); + + const data = response.getJson ? response.getJson() : response.json; + + if (data.Payment) { + const qboPaymentId = data.Payment.Id; + console.log(`✅ QBO Payment erstellt: ID ${qboPaymentId}`); + + // Lokale Invoices als bezahlt markieren + await dbClient.query('BEGIN'); + + for (const inv of invoicesData) { + await dbClient.query( + `UPDATE invoices + SET paid_date = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [payment_date, inv.id] + ); + } + + await dbClient.query('COMMIT'); + + res.json({ + success: true, + qbo_payment_id: qboPaymentId, + total: totalAmount, + invoices_paid: invoicesData.length, + message: `Payment $${totalAmount.toFixed(2)} erfolgreich in QBO erfasst (ID: ${qboPaymentId}).` + }); + } else { + console.error('❌ QBO Payment Fehler:', JSON.stringify(data)); + res.status(500).json({ + error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.fault?.error?.[0]?.message || data) + }); + } + + } catch (error) { + await dbClient.query('ROLLBACK').catch(() => {}); + console.error('❌ Payment Error:', error); + res.status(500).json({ error: 'Payment fehlgeschlagen: ' + error.message }); + } finally { + dbClient.release(); + } +}); + +