update
This commit is contained in:
parent
a9465aa812
commit
171450400a
|
|
@ -1,54 +1,49 @@
|
|||
// payment-modal.js — ES Module für das Payment Recording Modal
|
||||
// Fixes: Correct CSS class 'modal', local DB payment storage
|
||||
// payment-modal.js — ES Module v2
|
||||
// Supports: Multi-invoice, partial payments, unapplied (downpayments), editable amounts
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
let bankAccounts = [];
|
||||
let paymentMethods = [];
|
||||
let selectedInvoices = [];
|
||||
let selectedInvoices = []; // { invoice, payAmount }
|
||||
let dataLoaded = false;
|
||||
let paymentMode = 'invoice'; // 'invoice' | 'unapplied'
|
||||
|
||||
// ============================================================
|
||||
// Load QBO Reference Data
|
||||
// Load QBO 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);
|
||||
}
|
||||
} catch (e) { console.error('Error loading QBO data:', e); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Open / Close
|
||||
// ============================================================
|
||||
|
||||
export async function openPaymentModal(invoiceIds = []) {
|
||||
export async function openPaymentModal(invoiceIds = [], mode = 'invoice') {
|
||||
await loadQboData();
|
||||
|
||||
paymentMode = mode;
|
||||
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);
|
||||
selectedInvoices.push({
|
||||
invoice: data.invoice,
|
||||
payAmount: parseFloat(data.invoice.total)
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||
}
|
||||
|
||||
ensureModalElement();
|
||||
|
|
@ -56,12 +51,92 @@ export async function openPaymentModal(invoiceIds = []) {
|
|||
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 = [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Add / Remove Invoices
|
||||
// ============================================================
|
||||
|
||||
async function addInvoiceById() {
|
||||
const input = document.getElementById('payment-add-invoice-id');
|
||||
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
|
||||
);
|
||||
|
||||
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 (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||
alert('Alle Rechnungen müssen zum selben Kunden gehören.');
|
||||
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)
|
||||
});
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
input.value = '';
|
||||
} catch (e) {
|
||||
console.error('Error adding invoice:', e);
|
||||
alert('Fehler beim Suchen.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeInvoice(invoiceId) {
|
||||
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM
|
||||
// ============================================================
|
||||
|
|
@ -71,7 +146,6 @@ function ensureModalElement() {
|
|||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'payment-modal';
|
||||
// Verwende GLEICHE Klasse wie die existierenden Modals
|
||||
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);
|
||||
}
|
||||
|
|
@ -85,7 +159,6 @@ function renderModalContent() {
|
|||
`<option value="${acc.id}">${acc.name}</option>`
|
||||
).join('');
|
||||
|
||||
// Zeige Check und ACH bevorzugt, aber alle als Fallback
|
||||
const filteredMethods = paymentMethods.filter(pm =>
|
||||
pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach')
|
||||
);
|
||||
|
|
@ -95,11 +168,13 @@ function renderModalContent() {
|
|||
).join('');
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const isUnapplied = paymentMode === 'unapplied';
|
||||
const title = isUnapplied ? '💰 Record Downpayment' : '💰 Record Payment';
|
||||
|
||||
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-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800">${title}</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" />
|
||||
|
|
@ -107,12 +182,20 @@ function renderModalContent() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected Invoices -->
|
||||
<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-48 overflow-y-auto"></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>
|
||||
|
||||
${isUnapplied ? renderUnappliedSection() : renderInvoiceSection()}
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
|
|
@ -152,56 +235,113 @@ function renderModalContent() {
|
|||
<!-- 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>
|
||||
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>
|
||||
`;
|
||||
</div>`;
|
||||
|
||||
if (!isUnapplied) {
|
||||
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 ausgewählt</div>`;
|
||||
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">Keine Rechnungen — bitte unten hinzufügen</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>
|
||||
container.innerHTML = selectedInvoices.map(si => {
|
||||
const inv = si.invoice;
|
||||
const total = parseFloat(inv.total);
|
||||
const isPartial = 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">
|
||||
<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-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||||
${isPartial ? '<span class="text-xs text-yellow-600 ml-2 font-semibold">Partial</span>' : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-gray-900">$${parseFloat(inv.total).toFixed(2)}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 text-sm">$</span>
|
||||
<input type="number" step="0.01" min="0.01" max="${total}"
|
||||
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' : ''}">
|
||||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||
class="text-red-400 hover:text-red-600 text-sm">✕</button>
|
||||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</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)}`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function removeInvoiceFromPayment(invoiceId) {
|
||||
selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId);
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
totalEl.textContent = `$${total.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -209,8 +349,6 @@ function removeInvoiceFromPayment(invoiceId) {
|
|||
// ============================================================
|
||||
|
||||
async function submitPayment() {
|
||||
if (selectedInvoices.length === 0) return;
|
||||
|
||||
const paymentDate = document.getElementById('payment-date').value;
|
||||
const reference = document.getElementById('payment-reference').value;
|
||||
const methodSelect = document.getElementById('payment-method');
|
||||
|
|
@ -225,10 +363,59 @@ async function submitPayment() {
|
|||
return;
|
||||
}
|
||||
|
||||
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||
const invoiceNums = selectedInvoices.map(i => i.invoice_number || `ID:${i.id}`).join(', ');
|
||||
let body;
|
||||
|
||||
if (!confirm(`Payment $${total.toFixed(2)} für #${invoiceNums} an QBO senden?`)) return;
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('payment-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||||
|
|
@ -239,17 +426,8 @@ async function submitPayment() {
|
|||
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,
|
||||
payment_method_name: methodName,
|
||||
deposit_to_account_id: depositToId,
|
||||
deposit_to_account_name: depositToName
|
||||
})
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -261,7 +439,7 @@ async function submitPayment() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
alert('Netzwerkfehler beim Payment.');
|
||||
alert('Netzwerkfehler.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||
submitBtn.disabled = false;
|
||||
|
|
@ -269,13 +447,23 @@ async function submitPayment() {
|
|||
}
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
paymentMode = mode;
|
||||
renderModalContent();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.paymentModal = {
|
||||
open: openPaymentModal,
|
||||
openDownpayment: openDownpaymentModal,
|
||||
close: closePaymentModal,
|
||||
submit: submitPayment,
|
||||
removeInvoice: removeInvoiceFromPayment
|
||||
addById: addInvoiceById,
|
||||
removeInvoice: removeInvoice,
|
||||
updateAmount: updatePayAmount,
|
||||
updateTotal: updateTotal,
|
||||
switchMode: switchMode
|
||||
};
|
||||
180
server.js
180
server.js
|
|
@ -1612,12 +1612,12 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
|
|||
});
|
||||
|
||||
// =====================================================
|
||||
// QBO PAYMENT ENDPOINTS — In server.js einfügen
|
||||
// Speichert Payments sowohl in lokaler DB als auch in QBO
|
||||
// QBO PAYMENT ENDPOINTS v2 — In server.js einfügen
|
||||
// Supports: multi-invoice, partial, unapplied (downpayment)
|
||||
// =====================================================
|
||||
|
||||
|
||||
// --- 1. Bank-Konten aus QBO laden (für "Deposit To" Dropdown) ---
|
||||
// --- Bank-Konten aus QBO (für Deposit To) ---
|
||||
app.get('/api/qbo/accounts', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
|
|
@ -1631,23 +1631,16 @@ app.get('/api/qbo/accounts', async (req, res) => {
|
|||
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);
|
||||
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||
} catch (error) {
|
||||
console.error('Error fetching QBO accounts:', error);
|
||||
res.status(500).json({ error: 'Error fetching bank accounts: ' + error.message });
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- 2. Payment Methods aus QBO laden ---
|
||||
// --- Payment Methods aus QBO ---
|
||||
app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
|
|
@ -1661,37 +1654,34 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
|
|||
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);
|
||||
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||
} catch (error) {
|
||||
console.error('Error fetching payment methods:', error);
|
||||
res.status(500).json({ error: 'Error fetching payment methods: ' + error.message });
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- 3. Payment erstellen: Lokal + QBO ---
|
||||
// --- Record Payment (multi-invoice, partial, unapplied) ---
|
||||
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 # oder ACH Referenz
|
||||
payment_method_id, // QBO PaymentMethod ID
|
||||
payment_method_name, // 'Check' oder 'ACH' (für lokale DB)
|
||||
deposit_to_account_id, // QBO Bank Account ID
|
||||
deposit_to_account_name // Bankname (für lokale DB)
|
||||
mode, // 'invoice' | 'unapplied'
|
||||
// Mode 'invoice':
|
||||
invoice_payments, // [{ invoice_id, amount }]
|
||||
// Mode 'unapplied':
|
||||
customer_id, // Lokale Kunden-ID
|
||||
customer_qbo_id, // QBO Customer ID
|
||||
total_amount, // Betrag
|
||||
// Gemeinsam:
|
||||
payment_date,
|
||||
reference_number,
|
||||
payment_method_id,
|
||||
payment_method_name,
|
||||
deposit_to_account_id,
|
||||
deposit_to_account_name
|
||||
} = 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 {
|
||||
|
|
@ -1701,15 +1691,46 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
|||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
// Lokale Invoices laden
|
||||
const invoicesResult = await dbClient.query(
|
||||
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 result = 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]
|
||||
WHERE i.id = ANY($1)`, [ids]
|
||||
);
|
||||
const invoicesData = invoicesResult.rows;
|
||||
invoicesData = result.rows;
|
||||
|
||||
// Validierung
|
||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||
|
|
@ -1719,36 +1740,44 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||
if (customerIds.length > 1) {
|
||||
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||
if (custIds.length > 1) {
|
||||
return res.status(400).json({ error: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
|
||||
}
|
||||
|
||||
const customerQboId = customerIds[0];
|
||||
const customerId = invoicesData[0].customer_id;
|
||||
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||
localCustomerId = invoicesData[0].customer_id;
|
||||
|
||||
// ----- QBO Payment Objekt -----
|
||||
const payment = {
|
||||
CustomerRef: { value: customerQboId },
|
||||
TotalAmt: totalAmount,
|
||||
// Beträge zuordnen
|
||||
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);
|
||||
|
||||
qboPayment = {
|
||||
CustomerRef: { value: custIds[0] },
|
||||
TotalAmt: totalAmt,
|
||||
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),
|
||||
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
|
||||
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
||||
}))
|
||||
};
|
||||
|
||||
console.log(`💰 QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en)`);
|
||||
const hasPartial = invoicesData.some(inv => {
|
||||
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({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payment)
|
||||
body: JSON.stringify(qboPayment)
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
|
@ -1756,45 +1785,58 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
|||
if (!data.Payment) {
|
||||
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
||||
return res.status(500).json({
|
||||
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.Fault?.Error?.[0]?.Message || data)
|
||||
error: 'QBO Fehler: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||
});
|
||||
}
|
||||
|
||||
const qboPaymentId = data.Payment.Id;
|
||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||
|
||||
// ----- Lokal in DB speichern -----
|
||||
// --- Lokal speichern ---
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
// Payment-Datensatz
|
||||
const paymentResult = 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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[payment_date, reference_number || null, payment_method_name || 'Check', deposit_to_account_name || '', totalAmount, customerId, qboPaymentId]
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[payment_date, reference_number || null, payment_method_name || 'Check',
|
||||
deposit_to_account_name || '', totalAmt, localCustomerId, qboPaymentId]
|
||||
);
|
||||
const localPaymentId = paymentResult.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) {
|
||||
const payAmt = parseFloat(ip.amount);
|
||||
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
||||
const invTotal = inv ? parseFloat(inv.total) : 0;
|
||||
const isFullyPaid = payAmt >= invTotal;
|
||||
|
||||
// Invoices mit Payment verknüpfen + als bezahlt markieren
|
||||
for (const inv of invoicesData) {
|
||||
await dbClient.query(
|
||||
`INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)`,
|
||||
[localPaymentId, inv.id, parseFloat(inv.total)]
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[localPaymentId, ip.invoice_id, payAmt]
|
||||
);
|
||||
|
||||
if (isFullyPaid) {
|
||||
// Voll bezahlt → paid_date setzen
|
||||
await dbClient.query(
|
||||
`UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
|
||||
[payment_date, inv.id]
|
||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[payment_date, ip.invoice_id]
|
||||
);
|
||||
}
|
||||
// Teilzahlung → paid_date bleibt NULL (Rechnung noch offen)
|
||||
}
|
||||
}
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
const modeLabel = mode === 'unapplied' ? 'Downpayment' : 'Payment';
|
||||
res.json({
|
||||
success: true,
|
||||
payment_id: localPaymentId,
|
||||
qbo_payment_id: qboPaymentId,
|
||||
total: totalAmount,
|
||||
invoices_paid: invoicesData.length,
|
||||
message: `Payment $${totalAmount.toFixed(2)} erfasst (QBO: ${qboPaymentId}, Lokal: ${localPaymentId}).`
|
||||
total: totalAmt,
|
||||
invoices_paid: mode === 'unapplied' ? 0 : invoice_payments.length,
|
||||
message: `${modeLabel} $${totalAmt.toFixed(2)} erfasst (QBO: ${qboPaymentId}).`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -1807,16 +1849,16 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
|||
});
|
||||
|
||||
|
||||
// --- 4. Lokale Payments auflisten (optional, für spätere Übersicht) ---
|
||||
// --- Lokale Payments auflisten ---
|
||||
app.get('/api/payments', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT p.*, c.name as customer_name,
|
||||
json_agg(json_build_object(
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'invoice_id', pi.invoice_id,
|
||||
'amount', pi.amount,
|
||||
'invoice_number', i.invoice_number
|
||||
)) as invoices
|
||||
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||
FROM payments p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||
|
|
|
|||
Loading…
Reference in New Issue