payments - 1. version
This commit is contained in:
parent
2bb304babe
commit
acb588425a
|
|
@ -3,8 +3,8 @@
|
||||||
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
|
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
|
||||||
|
|
||||||
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
||||||
|
import './payment-modal.js';
|
||||||
|
|
||||||
// Warte bis DOM fertig ist
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -12,14 +12,8 @@ if (document.readyState === 'loading') {
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
// Toolbar injizieren
|
|
||||||
injectToolbar();
|
injectToolbar();
|
||||||
|
|
||||||
// Globale Funktionen für app.js verfügbar machen
|
|
||||||
// (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird)
|
|
||||||
window.loadInvoices = loadInvoices;
|
window.loadInvoices = loadInvoices;
|
||||||
window.renderInvoices = renderInvoiceView;
|
window.renderInvoices = renderInvoiceView;
|
||||||
|
|
||||||
// Initiales Laden
|
|
||||||
loadInvoices();
|
loadInvoices();
|
||||||
}
|
}
|
||||||
|
|
@ -253,10 +253,15 @@ function renderInvoiceRow(invoice) {
|
||||||
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
|
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
|
||||||
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
|
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
|
||||||
|
|
||||||
// Paid/Unpaid
|
// Paid/Unpaid — wenn in QBO: Payment-Modal öffnen, sonst lokal markieren
|
||||||
const paidBtn = paid
|
let paidBtn;
|
||||||
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
|
if (paid) {
|
||||||
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
|
paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
|
||||||
|
} else if (hasQbo && window.paymentModal) {
|
||||||
|
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
|
||||||
|
} else {
|
||||||
|
paidBtn = `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid (local)">💰 Paid</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
// payment-modal.js — ES Module für das Payment Recording Modal
|
||||||
|
// Ermöglicht: Auswahl mehrerer Rechnungen, Check/ACH, Deposit To Konto
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// State
|
||||||
|
// ============================================================
|
||||||
|
let bankAccounts = [];
|
||||||
|
let paymentMethods = [];
|
||||||
|
let selectedInvoices = []; // Array of invoice objects
|
||||||
|
let isOpen = false;
|
||||||
|
|
||||||
|
// Cache QBO reference data (nur einmal laden)
|
||||||
|
let dataLoaded = false;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Load QBO Reference Data
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadQboData() {
|
||||||
|
if (dataLoaded) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [accRes, pmRes] = await Promise.all([
|
||||||
|
fetch('/api/qbo/accounts'),
|
||||||
|
fetch('/api/qbo/payment-methods')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (accRes.ok) bankAccounts = await accRes.json();
|
||||||
|
if (pmRes.ok) paymentMethods = await pmRes.json();
|
||||||
|
|
||||||
|
dataLoaded = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading QBO reference data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Open / Close Modal
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function openPaymentModal(invoiceIds = []) {
|
||||||
|
// Lade QBO-Daten falls noch nicht geschehen
|
||||||
|
await loadQboData();
|
||||||
|
|
||||||
|
// Lade die ausgewählten Rechnungen
|
||||||
|
if (invoiceIds.length > 0) {
|
||||||
|
selectedInvoices = [];
|
||||||
|
for (const id of invoiceIds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.invoice) {
|
||||||
|
selectedInvoices.push(data.invoice);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading invoice:', id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal();
|
||||||
|
document.getElementById('payment-modal').classList.add('active');
|
||||||
|
isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePaymentModal() {
|
||||||
|
const modal = document.getElementById('payment-modal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
isOpen = false;
|
||||||
|
selectedInvoices = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Add/Remove Invoices from selection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function addInvoiceToPayment(invoiceId) {
|
||||||
|
if (selectedInvoices.find(inv => inv.id === invoiceId)) return; // already selected
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.invoice) {
|
||||||
|
// Validierung: Muss QBO-verknüpft sein
|
||||||
|
if (!data.invoice.qbo_id) {
|
||||||
|
alert('Diese Rechnung ist noch nicht in QBO. Bitte erst exportieren.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validierung: Alle müssen zum selben Kunden gehören
|
||||||
|
if (selectedInvoices.length > 0 && data.invoice.customer_id !== selectedInvoices[0].customer_id) {
|
||||||
|
alert('Alle Rechnungen eines Payments müssen zum selben Kunden gehören.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedInvoices.push(data.invoice);
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding invoice:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInvoiceFromPayment(invoiceId) {
|
||||||
|
selectedInvoices = selectedInvoices.filter(inv => inv.id !== invoiceId);
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Rendering
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderModal() {
|
||||||
|
let modal = document.getElementById('payment-modal');
|
||||||
|
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'payment-modal';
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountOptions = bankAccounts.map(acc =>
|
||||||
|
`<option value="${acc.id}">${acc.name}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const methodOptions = paymentMethods
|
||||||
|
.filter(pm => ['Check', 'ACH'].includes(pm.name) || pm.name.toLowerCase().includes('check') || pm.name.toLowerCase().includes('ach'))
|
||||||
|
.map(pm => `<option value="${pm.id}">${pm.name}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Falls keine Filter-Treffer, alle anzeigen
|
||||||
|
const allMethodOptions = paymentMethods.map(pm =>
|
||||||
|
`<option value="${pm.id}">${pm.name}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||||
|
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Invoices -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices to pay</label>
|
||||||
|
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-48 overflow-y-auto">
|
||||||
|
<!-- Wird dynamisch gefüllt -->
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<input type="number" id="payment-add-invoice-id" placeholder="Invoice ID hinzufügen..."
|
||||||
|
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
<button onclick="window.paymentModal.addById()"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Details -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||||||
|
<input type="date" id="payment-date" value="${today}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
|
||||||
|
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
||||||
|
<select id="payment-method"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
${methodOptions || allMethodOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||||||
|
<select id="payment-deposit-to"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
${accountOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||||||
|
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button onclick="window.paymentModal.close()"
|
||||||
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||||||
|
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||||||
|
💰 Record Payment in QBO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
renderInvoiceList();
|
||||||
|
updateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvoiceList() {
|
||||||
|
const container = document.getElementById('payment-invoice-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (selectedInvoices.length === 0) {
|
||||||
|
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">Keine Rechnungen ausgewählt</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = selectedInvoices.map(inv => `
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||||||
|
<span class="text-gray-500 text-sm ml-2">${inv.customer_name || ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-semibold text-gray-900">$${parseFloat(inv.total).toFixed(2)}</span>
|
||||||
|
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||||
|
class="text-red-400 hover:text-red-600 text-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotal() {
|
||||||
|
const totalEl = document.getElementById('payment-total');
|
||||||
|
if (!totalEl) return;
|
||||||
|
|
||||||
|
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
|
totalEl.textContent = `$${total.toFixed(2)}`;
|
||||||
|
|
||||||
|
// Submit-Button deaktivieren wenn keine Rechnungen
|
||||||
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = selectedInvoices.length === 0;
|
||||||
|
submitBtn.classList.toggle('opacity-50', selectedInvoices.length === 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Submit Payment
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function submitPayment() {
|
||||||
|
if (selectedInvoices.length === 0) {
|
||||||
|
alert('Bitte mindestens eine Rechnung auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDate = document.getElementById('payment-date').value;
|
||||||
|
const reference = document.getElementById('payment-reference').value;
|
||||||
|
const methodId = document.getElementById('payment-method').value;
|
||||||
|
const depositToId = document.getElementById('payment-deposit-to').value;
|
||||||
|
|
||||||
|
if (!paymentDate) {
|
||||||
|
alert('Bitte ein Zahlungsdatum angeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!methodId || !depositToId) {
|
||||||
|
alert('Bitte Payment Method und Deposit To auswählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = selectedInvoices.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
|
const invoiceNums = selectedInvoices.map(i => i.invoice_number || `ID:${i.id}`).join(', ');
|
||||||
|
|
||||||
|
if (!confirm(`Payment von $${total.toFixed(2)} für Rechnung(en) ${invoiceNums} an QBO senden?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('payment-submit-btn');
|
||||||
|
const origText = submitBtn.innerHTML;
|
||||||
|
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/record-payment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
invoice_ids: selectedInvoices.map(inv => inv.id),
|
||||||
|
payment_date: paymentDate,
|
||||||
|
reference_number: reference,
|
||||||
|
payment_method_id: methodId,
|
||||||
|
deposit_to_account_id: depositToId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ ${result.message}`);
|
||||||
|
closePaymentModal();
|
||||||
|
// Invoice-Liste aktualisieren
|
||||||
|
if (window.invoiceView) {
|
||||||
|
window.invoiceView.loadInvoices();
|
||||||
|
} else if (typeof window.loadInvoices === 'function') {
|
||||||
|
window.loadInvoices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`❌ Fehler: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment error:', error);
|
||||||
|
alert('Netzwerkfehler beim Payment.');
|
||||||
|
} finally {
|
||||||
|
submitBtn.innerHTML = origText;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helper: Add by ID from input field
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function addInvoiceById() {
|
||||||
|
const input = document.getElementById('payment-add-invoice-id');
|
||||||
|
const id = parseInt(input.value);
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
await addInvoiceToPayment(id);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to window
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.paymentModal = {
|
||||||
|
open: openPaymentModal,
|
||||||
|
close: closePaymentModal,
|
||||||
|
submit: submitPayment,
|
||||||
|
addInvoice: addInvoiceToPayment,
|
||||||
|
removeInvoice: removeInvoiceFromPayment,
|
||||||
|
addById: addInvoiceById
|
||||||
|
};
|
||||||
198
server.js
198
server.js
|
|
@ -1611,6 +1611,204 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// QBO PAYMENT RECORDING - Server Endpoints
|
||||||
|
// In server.js einfügen (z.B. nach dem /api/qbo/import-unpaid Endpoint)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
|
||||||
|
// --- 1. Bank-Konten aus QBO laden (für "Deposit To" Dropdown) ---
|
||||||
|
app.get('/api/qbo/accounts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// Nur Bank-Konten abfragen
|
||||||
|
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const accounts = (data.QueryResponse?.Account || []).map(acc => ({
|
||||||
|
id: acc.Id,
|
||||||
|
name: acc.Name,
|
||||||
|
fullName: acc.FullyQualifiedName || acc.Name
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(accounts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching QBO accounts:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching bank accounts: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- 2. Payment Methods aus QBO laden (für Check/ACH Dropdown) ---
|
||||||
|
app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const methods = (data.QueryResponse?.PaymentMethod || []).map(pm => ({
|
||||||
|
id: pm.Id,
|
||||||
|
name: pm.Name
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(methods);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching payment methods:', error);
|
||||||
|
res.status(500).json({ error: 'Error fetching payment methods: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- 3. Payment in QBO erstellen (ein Check für 1..n Invoices) ---
|
||||||
|
app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
|
const {
|
||||||
|
invoice_ids, // Array von lokalen Invoice IDs
|
||||||
|
payment_date, // 'YYYY-MM-DD'
|
||||||
|
reference_number, // Check-Nummer oder ACH-Referenz
|
||||||
|
payment_method_id, // QBO PaymentMethod ID
|
||||||
|
deposit_to_account_id // QBO Bank Account ID
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!invoice_ids || invoice_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Keine Rechnungen ausgewählt.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// Lokale Invoices laden
|
||||||
|
const invoicesResult = await dbClient.query(
|
||||||
|
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = ANY($1)`,
|
||||||
|
[invoice_ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoicesData = invoicesResult.rows;
|
||||||
|
|
||||||
|
// Validierung: Alle müssen eine qbo_id haben (schon in QBO)
|
||||||
|
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||||
|
if (notInQbo.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Folgende Rechnungen sind noch nicht in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung: Alle müssen denselben Kunden haben
|
||||||
|
const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||||
|
if (customerIds.length > 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Alle Rechnungen eines Payments müssen zum selben Kunden gehören.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerQboId = customerIds[0];
|
||||||
|
|
||||||
|
// Gesamtbetrag berechnen
|
||||||
|
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
|
||||||
|
|
||||||
|
// QBO Payment Objekt bauen
|
||||||
|
const payment = {
|
||||||
|
CustomerRef: {
|
||||||
|
value: customerQboId
|
||||||
|
},
|
||||||
|
TotalAmt: totalAmount,
|
||||||
|
TxnDate: payment_date,
|
||||||
|
PaymentRefNum: reference_number || '',
|
||||||
|
PaymentMethodRef: {
|
||||||
|
value: payment_method_id
|
||||||
|
},
|
||||||
|
DepositToAccountRef: {
|
||||||
|
value: deposit_to_account_id
|
||||||
|
},
|
||||||
|
Line: invoicesData.map(inv => ({
|
||||||
|
Amount: parseFloat(inv.total),
|
||||||
|
LinkedTxn: [{
|
||||||
|
TxnId: inv.qbo_id,
|
||||||
|
TxnType: 'Invoice'
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`💰 Erstelle QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en), Kunde: ${invoicesData[0].customer_name}`);
|
||||||
|
|
||||||
|
// Payment an QBO senden
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payment)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (data.Payment) {
|
||||||
|
const qboPaymentId = data.Payment.Id;
|
||||||
|
console.log(`✅ QBO Payment erstellt: ID ${qboPaymentId}`);
|
||||||
|
|
||||||
|
// Lokale Invoices als bezahlt markieren
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
for (const inv of invoicesData) {
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE invoices
|
||||||
|
SET paid_date = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[payment_date, inv.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
qbo_payment_id: qboPaymentId,
|
||||||
|
total: totalAmount,
|
||||||
|
invoices_paid: invoicesData.length,
|
||||||
|
message: `Payment $${totalAmount.toFixed(2)} erfolgreich in QBO erfasst (ID: ${qboPaymentId}).`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.fault?.error?.[0]?.message || data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('❌ Payment Error:', error);
|
||||||
|
res.status(500).json({ error: 'Payment fehlgeschlagen: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue