update qbo sync

This commit is contained in:
Andreas Knuth 2026-02-20 10:53:20 -06:00
parent 7ba4eef5db
commit 8643aebcfc
2 changed files with 280 additions and 46 deletions

View File

@ -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">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
<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
View File

@ -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() {