update
This commit is contained in:
parent
171450400a
commit
49aeff8cb6
184
public/app.js
184
public/app.js
|
|
@ -216,6 +216,9 @@ async function loadCustomers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 1. renderCustomers() — ERSETZE komplett ---
|
||||||
|
// Zeigt QBO-Status, Credit-Betrag und Downpayment-Button
|
||||||
|
|
||||||
function renderCustomers() {
|
function renderCustomers() {
|
||||||
const tbody = document.getElementById('customers-list');
|
const tbody = document.getElementById('customers-list');
|
||||||
tbody.innerHTML = customers.map(customer => {
|
tbody.innerHTML = customers.map(customer => {
|
||||||
|
|
@ -225,25 +228,198 @@ function renderCustomers() {
|
||||||
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||||
|
|
||||||
// QBO Status
|
// QBO Status
|
||||||
const qboStatus = customer.qbo_id
|
let 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>`
|
if (customer.qbo_id) {
|
||||||
: `<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>`;
|
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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
${customer.name} ${qboStatus}
|
${customer.name} ${qboCol} ${creditSpan}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</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 text-gray-500">${customer.account_number || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<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>
|
<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>
|
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).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) {
|
function openCustomerModal(customerId = null) {
|
||||||
currentCustomerId = customerId;
|
currentCustomerId = customerId;
|
||||||
const modal = document.getElementById('customer-modal');
|
const modal = document.getElementById('customer-modal');
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// payment-modal.js — ES Module v2
|
// payment-modal.js — ES Module v3
|
||||||
// Supports: Multi-invoice, partial payments, unapplied (downpayments), editable amounts
|
// Invoice payments only: multi-invoice, partial, editable amounts
|
||||||
|
// Downpayment is handled separately in customer view
|
||||||
|
|
||||||
let bankAccounts = [];
|
let bankAccounts = [];
|
||||||
let paymentMethods = [];
|
let paymentMethods = [];
|
||||||
let selectedInvoices = []; // { invoice, payAmount }
|
let selectedInvoices = []; // { invoice, payAmount }
|
||||||
|
let customerCredit = 0; // Unapplied credit from QBO
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
let paymentMode = 'invoice'; // 'invoice' | 'unapplied'
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Load QBO Data
|
// Load QBO Data
|
||||||
|
|
@ -28,10 +29,10 @@ async function loadQboData() {
|
||||||
// Open / Close
|
// Open / Close
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function openPaymentModal(invoiceIds = [], mode = 'invoice') {
|
export async function openPaymentModal(invoiceIds = []) {
|
||||||
await loadQboData();
|
await loadQboData();
|
||||||
paymentMode = mode;
|
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
|
customerCredit = 0;
|
||||||
|
|
||||||
for (const id of invoiceIds) {
|
for (const id of invoiceIds) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -46,19 +47,28 @@ export async function openPaymentModal(invoiceIds = [], mode = 'invoice') {
|
||||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
} 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();
|
ensureModalElement();
|
||||||
renderModalContent();
|
renderModalContent();
|
||||||
document.getElementById('payment-modal').classList.add('active');
|
document.getElementById('payment-modal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openDownpaymentModal() {
|
|
||||||
openPaymentModal([], 'unapplied');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closePaymentModal() {
|
export function closePaymentModal() {
|
||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
if (modal) modal.classList.remove('active');
|
if (modal) modal.classList.remove('active');
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
|
customerCredit = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -70,43 +80,23 @@ async function addInvoiceById() {
|
||||||
const searchVal = input.value.trim();
|
const searchVal = input.value.trim();
|
||||||
if (!searchVal) return;
|
if (!searchVal) return;
|
||||||
|
|
||||||
// Suche nach Invoice-Nummer oder ID
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/invoices');
|
const res = await fetch('/api/invoices');
|
||||||
const allInvoices = await res.json();
|
const allInvoices = await res.json();
|
||||||
|
|
||||||
const match = allInvoices.find(inv =>
|
const match = allInvoices.find(inv =>
|
||||||
String(inv.id) === searchVal ||
|
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
|
||||||
String(inv.invoice_number) === searchVal
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
|
||||||
alert(`Keine Rechnung mit Nr/ID "${searchVal}" gefunden.`);
|
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
|
||||||
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; }
|
||||||
|
|
||||||
// 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 (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||||
alert('Alle Rechnungen müssen zum selben Kunden gehören.');
|
alert('All invoices must belong to the same customer.'); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Details laden
|
|
||||||
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||||||
const detailData = await detailRes.json();
|
const detailData = await detailRes.json();
|
||||||
|
|
||||||
selectedInvoices.push({
|
selectedInvoices.push({
|
||||||
invoice: detailData.invoice,
|
invoice: detailData.invoice,
|
||||||
payAmount: parseFloat(detailData.invoice.total)
|
payAmount: parseFloat(detailData.invoice.total)
|
||||||
|
|
@ -117,7 +107,7 @@ async function addInvoiceById() {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error adding invoice:', 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) {
|
function updatePayAmount(invoiceId, newAmount) {
|
||||||
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||||||
if (si) {
|
if (si) {
|
||||||
const val = parseFloat(newAmount) || 0;
|
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
|
||||||
const max = parseFloat(si.invoice.total);
|
|
||||||
si.payAmount = Math.min(val, max); // Nicht mehr als Total
|
|
||||||
}
|
}
|
||||||
|
renderInvoiceList();
|
||||||
updateTotal();
|
updateTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,26 +144,28 @@ function renderModalContent() {
|
||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
const accountOptions = bankAccounts.map(acc =>
|
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||||||
`<option value="${acc.id}">${acc.name}</option>`
|
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
||||||
).join('');
|
const methods = (filtered.length > 0 ? filtered : paymentMethods);
|
||||||
|
const methodOptions = methods.map(p => `<option value="${p.id}">${p.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 today = new Date().toISOString().split('T')[0];
|
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 = `
|
modal.innerHTML = `
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
<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">
|
<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">
|
<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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|
@ -182,19 +173,20 @@ function renderModalContent() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mode Tabs -->
|
${creditBanner}
|
||||||
<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>
|
|
||||||
|
|
||||||
${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 -->
|
<!-- Payment Details -->
|
||||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
<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 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>
|
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
@ -243,62 +236,16 @@ function renderModalContent() {
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (!isUnapplied) {
|
|
||||||
renderInvoiceList();
|
renderInvoiceList();
|
||||||
}
|
|
||||||
updateTotal();
|
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() {
|
function renderInvoiceList() {
|
||||||
const container = document.getElementById('payment-invoice-list');
|
const container = document.getElementById('payment-invoice-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (selectedInvoices.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,22 +253,24 @@ function renderInvoiceList() {
|
||||||
const inv = si.invoice;
|
const inv = si.invoice;
|
||||||
const total = parseFloat(inv.total);
|
const total = parseFloat(inv.total);
|
||||||
const isPartial = si.payAmount < total;
|
const isPartial = si.payAmount < total;
|
||||||
|
const isOver = si.payAmount > total;
|
||||||
|
|
||||||
return `
|
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 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="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>
|
<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>
|
||||||
<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>
|
<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)}"
|
value="${si.payAmount.toFixed(2)}"
|
||||||
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
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
|
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
|
||||||
${isPartial ? 'bg-yellow-50 border-yellow-300' : ''}">
|
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
|
||||||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,17 +280,23 @@ function renderInvoiceList() {
|
||||||
|
|
||||||
function updateTotal() {
|
function updateTotal() {
|
||||||
const totalEl = document.getElementById('payment-total');
|
const totalEl = document.getElementById('payment-total');
|
||||||
|
const noteEl = document.getElementById('payment-overpay-note');
|
||||||
if (!totalEl) return;
|
if (!totalEl) return;
|
||||||
|
|
||||||
let total = 0;
|
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||||
if (paymentMode === 'unapplied') {
|
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||||
const amountInput = document.getElementById('payment-unapplied-amount');
|
|
||||||
total = parseFloat(amountInput?.value) || 0;
|
|
||||||
} else {
|
|
||||||
total = selectedInvoices.reduce((sum, si) => sum + si.payAmount, 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,60 +304,38 @@ function updateTotal() {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function submitPayment() {
|
async function submitPayment() {
|
||||||
|
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
|
||||||
|
|
||||||
const paymentDate = document.getElementById('payment-date').value;
|
const paymentDate = document.getElementById('payment-date').value;
|
||||||
const reference = document.getElementById('payment-reference').value;
|
const reference = document.getElementById('payment-reference').value;
|
||||||
const methodSelect = document.getElementById('payment-method');
|
const methodSelect = document.getElementById('payment-method');
|
||||||
const depositSelect = document.getElementById('payment-deposit-to');
|
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) {
|
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
|
||||||
alert('Bitte alle Felder ausfüllen.');
|
alert('Please fill in all fields.'); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body;
|
|
||||||
|
|
||||||
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 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 nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||||||
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||||||
|
const hasOverpay = total > invTotal;
|
||||||
|
|
||||||
let msg = `Payment $${total.toFixed(2)} für ${nums} an QBO senden?`;
|
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
|
||||||
if (hasPartial) msg += '\n⚠️ Enthält Teilzahlung(en).';
|
if (hasPartial) msg += '\n⚠️ Contains partial payment(s).';
|
||||||
|
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
|
||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
body = {
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
|
submitBtn.innerHTML = '⏳ Processing...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
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({
|
||||||
mode: 'invoice',
|
mode: 'invoice',
|
||||||
invoice_payments: selectedInvoices.map(si => ({
|
invoice_payments: selectedInvoices.map(si => ({
|
||||||
invoice_id: si.invoice.id,
|
invoice_id: si.invoice.id,
|
||||||
|
|
@ -410,36 +343,23 @@ async function submitPayment() {
|
||||||
})),
|
})),
|
||||||
payment_date: paymentDate,
|
payment_date: paymentDate,
|
||||||
reference_number: reference,
|
reference_number: reference,
|
||||||
payment_method_id: methodId,
|
payment_method_id: methodSelect.value,
|
||||||
payment_method_name: methodName,
|
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
|
||||||
deposit_to_account_id: depositToId,
|
deposit_to_account_id: depositSelect.value,
|
||||||
deposit_to_account_name: depositToName
|
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
|
||||||
};
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const submitBtn = document.getElementById('payment-submit-btn');
|
|
||||||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/qbo/record-payment', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert(`✅ ${result.message}`);
|
alert(`✅ ${result.message}`);
|
||||||
closePaymentModal();
|
closePaymentModal();
|
||||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
} else {
|
} else {
|
||||||
alert(`❌ Fehler: ${result.error}`);
|
alert(`❌ Error: ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Payment error:', error);
|
console.error('Payment error:', e);
|
||||||
alert('Netzwerkfehler.');
|
alert('Network error.');
|
||||||
} finally {
|
} finally {
|
||||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
|
|
@ -447,23 +367,16 @@ async function submitPayment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchMode(mode) {
|
|
||||||
paymentMode = mode;
|
|
||||||
renderModalContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Expose
|
// Expose
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.paymentModal = {
|
window.paymentModal = {
|
||||||
open: openPaymentModal,
|
open: openPaymentModal,
|
||||||
openDownpayment: openDownpaymentModal,
|
|
||||||
close: closePaymentModal,
|
close: closePaymentModal,
|
||||||
submit: submitPayment,
|
submit: submitPayment,
|
||||||
addById: addInvoiceById,
|
addById: addInvoiceById,
|
||||||
removeInvoice: removeInvoice,
|
removeInvoice: removeInvoice,
|
||||||
updateAmount: updatePayAmount,
|
updateAmount: updatePayAmount,
|
||||||
updateTotal: updateTotal,
|
updateTotal: updateTotal
|
||||||
switchMode: switchMode
|
|
||||||
};
|
};
|
||||||
227
server.js
227
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
|
// QBO PAYMENT ENDPOINTS v3 — In server.js einfügen
|
||||||
// Supports: multi-invoice, partial, unapplied (downpayment)
|
// - 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) => {
|
app.get('/api/qbo/accounts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
|
|
@ -1625,7 +1627,6 @@ app.get('/api/qbo/accounts', async (req, res) => {
|
||||||
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
? 'https://quickbooks.api.intuit.com'
|
? 'https://quickbooks.api.intuit.com'
|
||||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||||
const response = await makeQboApiCall({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
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;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching QBO accounts:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
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'
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
? 'https://quickbooks.api.intuit.com'
|
? 'https://quickbooks.api.intuit.com'
|
||||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||||
const response = await makeQboApiCall({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
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;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching payment methods:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
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) => {
|
app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
mode, // 'invoice' | 'unapplied'
|
|
||||||
// Mode 'invoice':
|
|
||||||
invoice_payments, // [{ invoice_id, amount }]
|
invoice_payments, // [{ invoice_id, amount }]
|
||||||
// Mode 'unapplied':
|
|
||||||
customer_id, // Lokale Kunden-ID
|
|
||||||
customer_qbo_id, // QBO Customer ID
|
|
||||||
total_amount, // Betrag
|
|
||||||
// Gemeinsam:
|
|
||||||
payment_date,
|
payment_date,
|
||||||
reference_number,
|
reference_number,
|
||||||
payment_method_id,
|
payment_method_id,
|
||||||
|
|
@ -1682,8 +1706,11 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
deposit_to_account_name
|
deposit_to_account_name
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const dbClient = await pool.connect();
|
if (!invoice_payments || invoice_payments.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No invoices selected.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbClient = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
const companyId = oauthClient.getToken().realmId;
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
|
@ -1691,67 +1718,29 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
? 'https://quickbooks.api.intuit.com'
|
? 'https://quickbooks.api.intuit.com'
|
||||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
let qboPayment;
|
|
||||||
let localCustomerId;
|
|
||||||
let totalAmt;
|
|
||||||
let invoicesData = [];
|
|
||||||
|
|
||||||
if (mode === 'unapplied') {
|
|
||||||
// ---- DOWNPAYMENT: kein LinkedTxn ----
|
|
||||||
if (!customer_qbo_id || !total_amount) {
|
|
||||||
return res.status(400).json({ error: 'Kunde und Betrag erforderlich.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ids = invoice_payments.map(ip => ip.invoice_id);
|
||||||
const result = await dbClient.query(
|
const result = await dbClient.query(
|
||||||
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||||
FROM invoices i
|
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
|
||||||
WHERE i.id = ANY($1)`, [ids]
|
WHERE i.id = ANY($1)`, [ids]
|
||||||
);
|
);
|
||||||
invoicesData = result.rows;
|
const invoicesData = result.rows;
|
||||||
|
|
||||||
// Validierung
|
|
||||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||||
if (notInQbo.length > 0) {
|
if (notInQbo.length > 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
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))];
|
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||||
if (custIds.length > 1) {
|
if (custIds.length > 1) {
|
||||||
return res.status(400).json({ error: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
|
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
localCustomerId = invoicesData[0].customer_id;
|
|
||||||
|
|
||||||
// Beträge zuordnen
|
|
||||||
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
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);
|
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
||||||
|
|
||||||
qboPayment = {
|
const qboPayment = {
|
||||||
CustomerRef: { value: custIds[0] },
|
CustomerRef: { value: custIds[0] },
|
||||||
TotalAmt: totalAmt,
|
TotalAmt: totalAmt,
|
||||||
TxnDate: payment_date,
|
TxnDate: payment_date,
|
||||||
|
|
@ -1764,111 +1753,159 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasPartial = invoicesData.some(inv => {
|
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
|
||||||
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({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(qboPayment)
|
body: JSON.stringify(qboPayment)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
if (!data.Payment) {
|
if (!data.Payment) {
|
||||||
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
|
||||||
return res.status(500).json({
|
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;
|
const qboPaymentId = data.Payment.Id;
|
||||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||||
|
|
||||||
// --- Lokal speichern ---
|
// Local DB
|
||||||
await dbClient.query('BEGIN');
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
const payResult = await dbClient.query(
|
const payResult = await dbClient.query(
|
||||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
`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`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
[payment_date, reference_number || null, payment_method_name || 'Check',
|
[payment_date, reference_number || null, payment_method_name || 'Check',
|
||||||
deposit_to_account_name || '', totalAmt, localCustomerId, qboPaymentId]
|
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
|
||||||
);
|
);
|
||||||
const localPaymentId = payResult.rows[0].id;
|
const localPaymentId = payResult.rows[0].id;
|
||||||
|
|
||||||
// Invoices verknüpfen + als bezahlt markieren
|
|
||||||
if (mode !== 'unapplied' && invoice_payments) {
|
|
||||||
for (const ip of invoice_payments) {
|
for (const ip of invoice_payments) {
|
||||||
const payAmt = parseFloat(ip.amount);
|
const payAmt = parseFloat(ip.amount);
|
||||||
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
||||||
const invTotal = inv ? parseFloat(inv.total) : 0;
|
const invTotal = inv ? parseFloat(inv.total) : 0;
|
||||||
const isFullyPaid = payAmt >= invTotal;
|
|
||||||
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
[localPaymentId, ip.invoice_id, payAmt]
|
[localPaymentId, ip.invoice_id, payAmt]
|
||||||
);
|
);
|
||||||
|
// Mark paid only if fully covered
|
||||||
if (isFullyPaid) {
|
if (payAmt >= invTotal) {
|
||||||
// Voll bezahlt → paid_date setzen
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
[payment_date, ip.invoice_id]
|
[payment_date, ip.invoice_id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Teilzahlung → paid_date bleibt NULL (Rechnung noch offen)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await dbClient.query('COMMIT');
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
const modeLabel = mode === 'unapplied' ? 'Downpayment' : 'Payment';
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
payment_id: localPaymentId,
|
payment_id: localPaymentId,
|
||||||
qbo_payment_id: qboPaymentId,
|
qbo_payment_id: qboPaymentId,
|
||||||
total: totalAmt,
|
total: totalAmt,
|
||||||
invoices_paid: mode === 'unapplied' ? 0 : invoice_payments.length,
|
invoices_paid: invoice_payments.length,
|
||||||
message: `${modeLabel} $${totalAmt.toFixed(2)} erfasst (QBO: ${qboPaymentId}).`
|
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await dbClient.query('ROLLBACK').catch(() => {});
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
console.error('❌ Payment Error:', error);
|
console.error('❌ Payment Error:', error);
|
||||||
res.status(500).json({ error: 'Payment fehlgeschlagen: ' + error.message });
|
res.status(500).json({ error: 'Payment failed: ' + error.message });
|
||||||
} finally {
|
} finally {
|
||||||
dbClient.release();
|
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;
|
||||||
|
|
||||||
// --- Lokale Payments auflisten ---
|
if (!customer_qbo_id || !amount || amount <= 0) {
|
||||||
|
return res.status(400).json({ error: 'Customer and amount required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`💰 Downpayment: $${amount} for customer QBO ${customer_qbo_id}`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
qbo_payment_id: qboPaymentId,
|
||||||
|
message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Downpayment Error:', error);
|
||||||
|
res.status(500).json({ error: 'Downpayment failed: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- List local payments ---
|
||||||
app.get('/api/payments', async (req, res) => {
|
app.get('/api/payments', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT p.*, c.name as customer_name,
|
SELECT p.*, c.name as customer_name,
|
||||||
COALESCE(json_agg(json_build_object(
|
COALESCE(json_agg(json_build_object(
|
||||||
'invoice_id', pi.invoice_id,
|
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
|
||||||
'amount', pi.amount,
|
|
||||||
'invoice_number', i.invoice_number
|
|
||||||
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||||
FROM payments p
|
FROM payments p
|
||||||
LEFT JOIN customers c ON p.customer_id = c.id
|
LEFT JOIN customers c ON p.customer_id = c.id
|
||||||
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||||
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
||||||
GROUP BY p.id, c.name
|
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
|
||||||
ORDER BY p.payment_date DESC
|
|
||||||
`);
|
`);
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching payments:', error);
|
|
||||||
res.status(500).json({ error: 'Error fetching payments' });
|
res.status(500).json({ error: 'Error fetching payments' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue