payments - 1. version

This commit is contained in:
Andreas Knuth 2026-02-18 09:39:06 -06:00
parent 2bb304babe
commit acb588425a
4 changed files with 564 additions and 11 deletions

View File

@ -3,8 +3,8 @@
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App. // Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js'; import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
import './payment-modal.js';
// Warte bis DOM fertig ist
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
@ -12,14 +12,8 @@ if (document.readyState === 'loading') {
} }
function init() { function init() {
// Toolbar injizieren
injectToolbar(); injectToolbar();
// Globale Funktionen für app.js verfügbar machen
// (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird)
window.loadInvoices = loadInvoices; window.loadInvoices = loadInvoices;
window.renderInvoices = renderInvoiceView; window.renderInvoices = renderInvoiceView;
// Initiales Laden
loadInvoices(); loadInvoices();
} }

View File

@ -253,10 +253,15 @@ function renderInvoiceRow(invoice) {
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`; : `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`; const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
// Paid/Unpaid // Paid/Unpaid — wenn in QBO: Payment-Modal öffnen, sonst lokal markieren
const paidBtn = paid let paidBtn;
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>` if (paid) {
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`; paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
} else if (hasQbo && window.paymentModal) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
} else {
paidBtn = `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid (local)">💰 Paid</button>`;
}
// Delete // Delete
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`; const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;

356
public/payment-modal.js Normal file
View File

@ -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 =>
`<option value="${acc.id}">${acc.name}</option>`
).join('');
const methodOptions = paymentMethods
.filter(pm => ['Check', 'ACH'].includes(pm.name) || pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach'))
.map(pm => `<option value="${pm.id}">${pm.name}</option>`)
.join('');
// Falls keine Filter-Treffer, alle anzeigen
const allMethodOptions = paymentMethods.map(pm =>
`<option value="${pm.id}">${pm.name}</option>`
).join('');
const today = new Date().toISOString().split('T')[0];
modal.innerHTML = `
<div class="modal-content" style="max-width: 700px;">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Selected Invoices -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices to pay</label>
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto">
<!-- Wird dynamisch gefüllt -->
</div>
<div class="mt-2 flex items-center gap-2">
<input type="number" id="payment-add-invoice-id" placeholder="Invoice ID hinzufügen..."
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<button onclick="window.paymentModal.addById()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">
+ Add
</button>
</div>
</div>
<!-- Payment Details -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
<input type="date" id="payment-date" value="${today}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select id="payment-method"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
${methodOptions || allMethodOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
<select id="payment-deposit-to"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
${accountOptions}
</select>
</div>
</div>
<!-- Total -->
<div class="bg-gray-50 p-4 rounded-lg mb-6">
<div class="flex justify-between items-center">
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button onclick="window.paymentModal.close()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
Cancel
</button>
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
💰 Record Payment in QBO
</button>
</div>
</div>
`;
renderInvoiceList();
updateTotal();
}
function renderInvoiceList() {
const container = document.getElementById('payment-invoice-list');
if (!container) return;
if (selectedInvoices.length === 0) {
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">Keine Rechnungen ausgewählt</div>`;
return;
}
container.innerHTML = selectedInvoices.map(inv => `
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100 last:border-0 hover:bg-gray-50">
<div>
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
<span class="text-gray-500 text-sm ml-2">${inv.customer_name || ''}</span>
</div>
<div class="flex items-center gap-3">
<span class="font-semibold text-gray-900">$${parseFloat(inv.total).toFixed(2)}</span>
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
class="text-red-400 hover:text-red-600 text-sm"></button>
</div>
</div>
`).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
};

198
server.js
View File

@ -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();
}
});