This commit is contained in:
2026-02-19 21:27:03 -06:00
parent 171450400a
commit 49aeff8cb6
3 changed files with 486 additions and 360 deletions

View File

@@ -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
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
: `<button onclick="exportCustomerToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Kunde nach QBO exportieren">QBO Export</button>`;
let qboCol;
if (customer.qbo_id) {
qboCol = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`;
} else {
qboCol = `<button onclick="exportCustomerToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer">QBO Export</button>`;
}
// Downpayment button (only if in QBO)
const downpayBtn = customer.qbo_id
? `<button onclick="openDownpaymentModal(${customer.id}, '${customer.qbo_id}', '${customer.name.replace(/'/g, "\\'")}')" class="text-emerald-600 hover:text-emerald-800">Downpayment</button>`
: '';
// Credit placeholder (loaded async)
const creditSpan = customer.qbo_id
? `<span id="customer-credit-${customer.id}" class="text-xs text-gray-400">...</span>`
: '';
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.name} ${qboStatus}
${customer.name} ${qboCol} ${creditSpan}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
${downpayBtn}
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>`;
}).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 = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Credit: $${data.credit.toFixed(2)}</span>`;
} 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 => `<option value="${a.id}">${a.name}</option>`).join('');
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
const methods = filtered.length > 0 ? filtered : paymentMethods;
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).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 = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-lg mx-auto p-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-800">💰 Record Downpayment</h2>
<button onclick="closeDownpaymentModal()" 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>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p class="text-sm text-blue-800">
<strong>Customer:</strong> ${customerName}<br>
This will record an unapplied payment (credit) on the customer's QBO account.
</p>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Amount</label>
<input type="number" id="dp-amount" step="0.01" min="0.01" placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 text-lg font-semibold">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
<input type="date" id="dp-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 #</label>
<input type="text" id="dp-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="dp-method" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white">${methodOptions}</select>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
<select id="dp-deposit" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white">${accountOptions}</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeDownpaymentModal()" class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
<button onclick="submitDownpayment(${customerId}, '${customerQboId}')" id="dp-submit-btn"
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
💰 Record Downpayment
</button>
</div>
</div>`;
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');

View File

@@ -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 =>
`<option value="${acc.id}">${acc.name}</option>`
).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 =>
`<option value="${pm.id}">${pm.name}</option>`
).join('');
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
const methods = (filtered.length > 0 ? filtered : paymentMethods);
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).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 = `
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
<p class="text-sm text-green-800">
💰 <strong>Customer has $${customerCredit.toFixed(2)} unapplied credit.</strong>
This can be applied in QBO when processing the payment.
</p>
</div>`;
}
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-800">${title}</h2>
<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" />
@@ -182,19 +173,20 @@ function renderModalContent() {
</button>
</div>
<!-- Mode Tabs -->
<div class="flex gap-2 mb-4">
<button onclick="window.paymentModal.switchMode('invoice')"
class="px-4 py-2 rounded-md text-sm font-medium ${!isUnapplied ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
Against Invoice(s)
</button>
<button onclick="window.paymentModal.switchMode('unapplied')"
class="px-4 py-2 rounded-md text-sm font-medium ${isUnapplied ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
Downpayment (Unapplied)
</button>
</div>
${creditBanner}
${isUnapplied ? renderUnappliedSection() : renderInvoiceSection()}
<!-- Invoice List -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
<div class="mt-2 flex items-center gap-2">
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
<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">
@@ -230,6 +222,7 @@ function renderModalContent() {
<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 id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
</div>
<!-- Actions -->
@@ -243,62 +236,16 @@ function renderModalContent() {
</div>
</div>`;
if (!isUnapplied) {
renderInvoiceList();
}
renderInvoiceList();
updateTotal();
}
function renderInvoiceSection() {
return `
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
<div class="mt-2 flex items-center gap-2">
<input type="text" id="payment-add-invoice-id" placeholder="Invoice # oder ID..."
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
<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>`;
}
function renderUnappliedSection() {
return `
<div class="mb-6">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p class="text-sm text-yellow-800">
<strong>Downpayment / Vorabzahlung:</strong> Das Geld wird als Kundenguthaben in QBO verbucht
und kann später einer Rechnung zugeordnet werden.
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
<select id="payment-customer" class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm">
<option value="">-- Kunde wählen --</option>
${(window.customers || []).filter(c => c.qbo_id).map(c =>
`<option value="${c.id}" data-qbo-id="${c.qbo_id}">${c.name}</option>`
).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Amount</label>
<input type="number" id="payment-unapplied-amount" step="0.01" min="0" placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
oninput="window.paymentModal.updateTotal()">
</div>
</div>
</div>`;
}
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 — bitte unten hinzufügen</div>`;
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
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 `
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
<div class="flex-1">
<div class="flex-1 min-w-0">
<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>
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
${isPartial ? '<span class="text-xs text-yellow-600 ml-2 font-semibold">Partial</span>' : ''}
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-gray-500 text-sm">$</span>
<input type="number" step="0.01" min="0.01" max="${total}"
<input type="number" step="0.01" min="0.01"
value="${si.payAmount.toFixed(2)}"
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
class="w-28 px-2 py-1 border border-gray-300 rounded text-sm text-right font-semibold
${isPartial ? 'bg-yellow-50 border-yellow-300' : ''}">
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'}">
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
</div>
@@ -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)} overpaymentcustomer 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
};