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 = `
+
+
+
💰 Record Payment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Payment:
+ $0.00
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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();
+ }
+});
+
+