update qbo sync
This commit is contained in:
parent
7ba4eef5db
commit
8643aebcfc
|
|
@ -1,5 +1,5 @@
|
|||
// invoice-view.js — ES Module v4
|
||||
// Fixes: No Paid for drafts, payment modal, UTC dates, persistent settings
|
||||
// invoice-view.js — ES Module v5
|
||||
// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button
|
||||
|
||||
let invoices = [];
|
||||
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
|
||||
|
|
@ -9,7 +9,7 @@ let groupBy = localStorage.getItem('inv_groupBy') || 'none';
|
|||
const OVERDUE_DAYS = 30;
|
||||
|
||||
// ============================================================
|
||||
// Date Helpers — KEIN new Date('YYYY-MM-DD') wegen UTC-Bug!
|
||||
// Date Helpers
|
||||
// ============================================================
|
||||
|
||||
function parseLocalDate(dateStr) {
|
||||
|
|
@ -27,6 +27,13 @@ function formatDate(date) {
|
|||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function formatDateTime(isoStr) {
|
||||
if (!isoStr) return 'Never';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +
|
||||
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function daysSince(date) {
|
||||
const d = parseLocalDate(date);
|
||||
if (!d) return 0;
|
||||
|
|
@ -64,7 +71,12 @@ function getMonthName(i) {
|
|||
|
||||
function isPaid(inv) { return !!inv.paid_date; }
|
||||
function isDraft(inv) { return !inv.qbo_id; }
|
||||
function isOverdue(inv) { return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
|
||||
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
|
||||
function isPartiallyPaid(inv) {
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
|
||||
return !inv.paid_date && amountPaid > 0 && balance > 0;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
localStorage.setItem('inv_filterStatus', filterStatus);
|
||||
|
|
@ -81,9 +93,19 @@ export async function loadInvoices() {
|
|||
const response = await fetch('/api/invoices');
|
||||
invoices = await response.json();
|
||||
renderInvoiceView();
|
||||
loadLastSync();
|
||||
} catch (error) { console.error('Error loading invoices:', error); }
|
||||
}
|
||||
|
||||
async function loadLastSync() {
|
||||
try {
|
||||
const res = await fetch('/api/qbo/last-sync');
|
||||
const data = await res.json();
|
||||
const el = document.getElementById('last-sync-time');
|
||||
if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced';
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getInvoicesData() { return invoices; }
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -96,6 +118,7 @@ function getFilteredInvoices() {
|
|||
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
|
||||
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
|
||||
else if (filterStatus === 'draft') f = f.filter(i => isDraft(i) && !isPaid(i));
|
||||
else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i));
|
||||
|
||||
if (filterCustomer.trim()) {
|
||||
const s = filterCustomer.toLowerCase();
|
||||
|
|
@ -141,19 +164,25 @@ function renderInvoiceRow(invoice) {
|
|||
const paid = isPaid(invoice);
|
||||
const overdue = isOverdue(invoice);
|
||||
const draft = isDraft(invoice);
|
||||
const amountPaid = parseFloat(invoice.amount_paid) || 0;
|
||||
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
||||
const partial = isPartiallyPaid(invoice);
|
||||
|
||||
const invNumDisplay = invoice.invoice_number
|
||||
? invoice.invoice_number
|
||||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||
|
||||
// Status Badge
|
||||
let statusBadge = '';
|
||||
const amountPaid = parseFloat(invoice.amount_paid) || 0;
|
||||
const balance = parseFloat(invoice.balance) ?? (parseFloat(invoice.total) - amountPaid);
|
||||
const isPartiallyPaid = !paid && amountPaid > 0 && balance > 0;
|
||||
|
||||
if (paid) statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||
else if (isPartiallyPaid) statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial $${amountPaid.toFixed(2)}</span>`;
|
||||
else if (overdue) statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
||||
if (paid && invoice.payment_status === 'Deposited') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||||
} else if (paid) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||
} else if (partial) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial $${amountPaid.toFixed(2)}</span>`;
|
||||
} else if (overdue) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
||||
}
|
||||
|
||||
// Send Date
|
||||
let sendDateDisplay = '—';
|
||||
|
|
@ -169,14 +198,20 @@ function renderInvoiceRow(invoice) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
|
||||
// Amount column — show balance when partially paid
|
||||
let amountDisplay;
|
||||
if (partial) {
|
||||
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
|
||||
} else {
|
||||
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
|
||||
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||
|
||||
// QBO Button — Export oder Sync
|
||||
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||
let qboBtn;
|
||||
if (hasQbo) {
|
||||
// Already in QBO — show sync button + reset option
|
||||
qboBtn = `<button onclick="window.invoiceView.syncToQBO(${invoice.id})" class="text-purple-600 hover:text-purple-900" title="Sync changes to QBO (ID: ${invoice.qbo_id})">⟳ QBO Sync</button>`;
|
||||
} else if (!customerHasQbo) {
|
||||
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||||
|
|
@ -185,21 +220,19 @@ function renderInvoiceRow(invoice) {
|
|||
}
|
||||
|
||||
const pdfBtn = draft
|
||||
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>`
|
||||
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
|
||||
: `<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>`;
|
||||
|
||||
// PAYMENT BUTTON — NUR wenn in QBO. Drafts bekommen KEINEN Button.
|
||||
// Payment button — only for QBO invoices that are not fully paid
|
||||
let paidBtn = '';
|
||||
if (paid) {
|
||||
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) {
|
||||
if (!paid && hasQbo) {
|
||||
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
|
||||
}
|
||||
// Kein Button für Drafts (!hasQbo && !paid)
|
||||
|
||||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
|
||||
|
||||
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
|
|
@ -208,12 +241,7 @@ function renderInvoiceRow(invoice) {
|
|||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">
|
||||
${isPartiallyPaid
|
||||
? `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`
|
||||
: `$${parseFloat(invoice.total).toFixed(2)}`
|
||||
}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
|
||||
</td>
|
||||
|
|
@ -285,6 +313,10 @@ function updateStatusButtons() {
|
|||
const unpaidCount = invoices.filter(i => !isPaid(i)).length;
|
||||
const ub = document.getElementById('unpaid-badge');
|
||||
if (ub) ub.textContent = unpaidCount;
|
||||
|
||||
const partialCount = invoices.filter(i => isPartiallyPaid(i)).length;
|
||||
const pb = document.getElementById('partial-badge');
|
||||
if (pb) { pb.textContent = partialCount; pb.classList.toggle('hidden', partialCount === 0); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -302,6 +334,9 @@ export function injectToolbar() {
|
|||
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
||||
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span></button>
|
||||
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
|
||||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
|
||||
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||||
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid</button>
|
||||
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
|
||||
|
|
@ -326,8 +361,17 @@ export function injectToolbar() {
|
|||
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto text-sm text-gray-500">
|
||||
<div class="w-px h-8 bg-gray-300"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
|
||||
⟳ Sync from QBO
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
|
|
@ -349,14 +393,14 @@ export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank');
|
|||
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
|
||||
|
||||
export async function exportToQBO(id) {
|
||||
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
|
||||
if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...');
|
||||
if (!confirm('Export invoice to QuickBooks Online?')) return;
|
||||
if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...');
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||
else alert(`❌ ${d.error}`);
|
||||
} catch (e) { alert('Netzwerkfehler.'); }
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
|
|
@ -372,8 +416,23 @@ export async function syncToQBO(id) {
|
|||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function syncFromQBO() {
|
||||
if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...');
|
||||
try {
|
||||
const r = await fetch('/api/qbo/sync-payments', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
alert(`✅ ${d.message}`);
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert(`❌ ${d.error}`);
|
||||
}
|
||||
} catch (e) { alert('Network error.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function resetQbo(id) {
|
||||
if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return;
|
||||
if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
|
||||
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
|
||||
|
|
@ -390,11 +449,6 @@ export async function markPaid(id) {
|
|||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
export async function markUnpaid(id) {
|
||||
try { const r = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (r.ok) loadInvoices(); }
|
||||
catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
|
||||
|
||||
export async function remove(id) {
|
||||
|
|
@ -408,6 +462,6 @@ export async function remove(id) {
|
|||
// ============================================================
|
||||
|
||||
window.invoiceView = {
|
||||
viewPDF, viewHTML, exportToQBO, syncToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
|
||||
viewPDF, viewHTML, exportToQBO, syncToQBO, syncFromQBO, resetQbo, markPaid, edit, remove,
|
||||
loadInvoices, renderInvoiceView, setStatus
|
||||
};
|
||||
194
server.js
194
server.js
|
|
@ -409,7 +409,6 @@ app.get('/api/invoices', async (req, res) => {
|
|||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
ORDER BY i.created_at DESC
|
||||
`);
|
||||
// balance berechnen
|
||||
const rows = result.rows.map(r => ({
|
||||
...r,
|
||||
amount_paid: parseFloat(r.amount_paid) || 0,
|
||||
|
|
@ -458,10 +457,7 @@ app.get('/api/invoices/:id', async (req, res) => {
|
|||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
invoice: invoice,
|
||||
items: itemsResult.rows
|
||||
});
|
||||
res.json({ invoice, items: itemsResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching invoice:', error);
|
||||
res.status(500).json({ error: 'Error fetching invoice' });
|
||||
|
|
@ -469,8 +465,6 @@ app.get('/api/invoices/:id', async (req, res) => {
|
|||
});
|
||||
|
||||
|
||||
|
||||
|
||||
app.post('/api/invoices', async (req, res) => {
|
||||
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body;
|
||||
|
||||
|
|
@ -2062,6 +2056,192 @@ app.get('/api/qbo/labor-rate', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// --- 3. Sync Payments from QBO ---
|
||||
// Prüft alle offenen lokalen Invoices gegen QBO.
|
||||
// Aktualisiert paid_date und payment_status (Paid/Deposited).
|
||||
app.post('/api/qbo/sync-payments', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
// Alle lokalen Invoices die in QBO sind aber noch nicht voll bezahlt
|
||||
const openResult = await dbClient.query(`
|
||||
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
|
||||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
|
||||
FROM invoices i
|
||||
WHERE i.qbo_id IS NOT NULL
|
||||
AND (i.paid_date IS NULL OR i.payment_status IS NULL OR i.payment_status != 'Deposited')
|
||||
`);
|
||||
|
||||
const openInvoices = openResult.rows;
|
||||
if (openInvoices.length === 0) {
|
||||
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
|
||||
return res.json({ synced: 0, message: 'All invoices up to date.' });
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// QBO Invoices in Batches laden (max 50 IDs pro Query)
|
||||
const batchSize = 50;
|
||||
const qboInvoices = new Map();
|
||||
|
||||
for (let i = 0; i < openInvoices.length; i += batchSize) {
|
||||
const batch = openInvoices.slice(i, i + batchSize);
|
||||
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||
}
|
||||
|
||||
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
||||
|
||||
let updated = 0;
|
||||
let newPayments = 0;
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
for (const localInv of openInvoices) {
|
||||
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||
if (!qboInv) continue;
|
||||
|
||||
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
||||
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
||||
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||
|
||||
// Prüfe ob in QBO bezahlt/teilweise bezahlt
|
||||
if (qboBalance === 0 && qboTotal > 0) {
|
||||
// Voll bezahlt in QBO
|
||||
// Prüfe ob "Deposited" — dafür müssen wir LinkedTxn prüfen
|
||||
// Wenn Deposit vorhanden → Deposited, sonst → Paid
|
||||
let status = 'Paid';
|
||||
|
||||
// LinkedTxn aus der Invoice prüfen
|
||||
if (qboInv.LinkedTxn) {
|
||||
// Lade die Payments um zu prüfen ob sie deposited sind
|
||||
for (const txn of qboInv.LinkedTxn) {
|
||||
if (txn.TxnType === 'Payment') {
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||
const payment = pmData.Payment;
|
||||
if (payment && payment.DepositToAccountRef) {
|
||||
// Hat DepositToAccount → wurde deposited
|
||||
status = 'Deposited';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paid_date setzen falls noch nicht
|
||||
if (!localInv.paid_date) {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET paid_date = CURRENT_DATE, payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[status, localInv.id]
|
||||
);
|
||||
} else {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[status, localInv.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Fehlenden lokalen Payment-Eintrag erstellen wenn nötig
|
||||
const qboPaid = qboTotal;
|
||||
if (qboPaid > localPaid) {
|
||||
const diff = qboPaid - localPaid;
|
||||
// Einen generischen Payment-Eintrag für den Differenzbetrag
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Auto-synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
}
|
||||
|
||||
console.log(` ✅ #${localInv.invoice_number}: ${status} (QBO Balance: $${qboBalance})`);
|
||||
updated++;
|
||||
|
||||
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||
// Teilweise bezahlt in QBO
|
||||
const qboPaid = qboTotal - qboBalance;
|
||||
if (qboPaid > localPaid) {
|
||||
const diff = qboPaid - localPaid;
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Auto-synced partial from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['Partial', localInv.id]
|
||||
);
|
||||
|
||||
console.log(` 📎 #${localInv.invoice_number}: Partial ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
||||
updated++;
|
||||
newPayments++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last sync timestamp speichern
|
||||
await dbClient.query(`
|
||||
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1
|
||||
`, [new Date().toISOString()]);
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
||||
res.json({
|
||||
synced: updated,
|
||||
new_payments: newPayments,
|
||||
total_checked: openInvoices.length,
|
||||
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Sync Error:', error);
|
||||
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- 4. Last sync timestamp ---
|
||||
app.get('/api/qbo/last-sync', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
|
||||
res.json({ last_sync: result.rows[0]?.value || null });
|
||||
} catch (error) {
|
||||
res.json({ last_sync: null });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Start server and browser
|
||||
async function startServer() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue