+ container.innerHTML = selectedInvoices.map(si => {
+ const inv = si.invoice;
+ const total = parseFloat(inv.total);
+ const isPartial = si.payAmount < total;
+
+ return `
+
+
#${inv.invoice_number || 'Draft'}
${inv.customer_name || ''}
+ (Total: $${total.toFixed(2)})
+ ${isPartial ? 'Partial' : ''}
-
-
$${parseFloat(inv.total).toFixed(2)}
-
+
+ $
+
+
-
- `).join('');
+
`;
+ }).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)}`;
-}
-function removeInvoiceFromPayment(invoiceId) {
- selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId);
- renderInvoiceList();
- updateTotal();
+ 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);
+ }
+
+ 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(', ');
-
- if (!confirm(`Payment $${total.toFixed(2)} für #${invoiceNums} an QBO senden?`)) 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 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
};
\ No newline at end of file
diff --git a/server.js b/server.js
index 3c639cf..b8a717f 100644
--- a/server.js
+++ b/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,54 +1691,93 @@ 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(
- `SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
- FROM invoices i
- LEFT JOIN customers c ON i.customer_id = c.id
- WHERE i.id = ANY($1)`,
- [invoice_ids]
- );
- const invoicesData = invoicesResult.rows;
+ let qboPayment;
+ let localCustomerId;
+ let totalAmt;
+ let invoicesData = [];
- // Validierung
- const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
- if (notInQbo.length > 0) {
- return res.status(400).json({
- error: `Nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
+ 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)`, [ids]
+ );
+ invoicesData = result.rows;
+
+ // Validierung
+ const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
+ if (notInQbo.length > 0) {
+ return res.status(400).json({
+ error: `Nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
+ });
+ }
+
+ 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.' });
+ }
+
+ localCustomerId = invoicesData[0].customer_id;
+
+ // 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: paymentMap.get(inv.id) || parseFloat(inv.total),
+ LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
+ }))
+ };
+
+ 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)' : ''}`);
}
- const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
- if (customerIds.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);
-
- // ----- QBO Payment Objekt -----
- const payment = {
- CustomerRef: { value: customerQboId },
- TotalAmt: totalAmount,
- TxnDate: payment_date,
- PaymentRefNum: reference_number || '',
- PaymentMethodRef: { value: payment_method_id },
- DepositToAccountRef: { value: deposit_to_account_id },
- Line: invoicesData.map(inv => ({
- Amount: parseFloat(inv.total),
- LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
- }))
- };
-
- console.log(`💰 QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en)`);
-
+ // --- 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 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)]
- );
- await dbClient.query(
- `UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
- [payment_date, inv.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;
+
+ await dbClient.query(
+ '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, 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