From 5b3da47d875db45a1388f97b16d6bd73c9f45be8 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Thu, 7 May 2026 10:06:14 -0500 Subject: [PATCH] expenses --- public/index.html | 5 + public/js/modals/expense-modal.js | 649 +++++++++++++++++++++++++++++ public/js/utils/api.js | 38 +- public/js/views/accounting-view.js | 511 +++++++++++++---------- src/routes/accounting.js | 49 ++- src/services/accounting-service.js | 388 +++++++++++++++++ 6 files changed, 1414 insertions(+), 226 deletions(-) create mode 100644 public/js/modals/expense-modal.js diff --git a/public/index.html b/public/index.html index c22accb..b9e1d94 100644 --- a/public/index.html +++ b/public/index.html @@ -127,6 +127,11 @@
+
+

Expenses

+
+
+

Reports

diff --git a/public/js/modals/expense-modal.js b/public/js/modals/expense-modal.js new file mode 100644 index 0000000..1248f69 --- /dev/null +++ b/public/js/modals/expense-modal.js @@ -0,0 +1,649 @@ +/** + * expense-modal.js — New Expense Modal + * + * Dependencies: window.API.accounting.* (api.js) + * + * Two main modes: + * - Create: openExpenseModal({ onSaved }) — leeres Formular + * + * The modal includes a sub-modal for "Add new vendor" inline. + * + * Action buttons: "Save & Close" and "Save & New". + */ + +import '../utils/api.js'; + +// ──────────────────────────────────────────────────────────────────── +// State (modul-lokal) +// ──────────────────────────────────────────────────────────────────── + +let modalEl = null; +let onSavedCb = null; + +let vendors = []; // alle Vendors aus Cache +let expenseAccounts = []; // alle Expense-Categories aus Cache +let paymentAccounts = []; // alle Bank/CreditCard-Accounts +let paymentMethods = []; // alle Payment-Methods (live) + +let selectedVendorId = null; +let selectedVendorName = ''; +let lineCount = 0; + +let isSaving = false; + +// ──────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────── + +function todayISO() { return new Date().toISOString().split('T')[0]; } + +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function escapeAttr(s) { + return String(s || '') + .replace(/&/g, '&').replace(/"/g, '"') + .replace(//g, '>'); +} + +function fmtMoney(n) { + if (n == null || isNaN(n)) return '$0.00'; + return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); +} + +// ──────────────────────────────────────────────────────────────────── +// Public Entry +// ──────────────────────────────────────────────────────────────────── + +export async function openExpenseModal({ onSaved } = {}) { + onSavedCb = onSaved || null; + selectedVendorId = null; + selectedVendorName = ''; + lineCount = 0; + isSaving = false; + + // Lade Stammdaten parallel (alles aus dem Cache → schnell) + try { + [vendors, expenseAccounts, paymentAccounts, paymentMethods] = await Promise.all([ + window.API.accounting.getVendors('', 1000), + window.API.accounting.getExpenseAccounts(), + window.API.accounting.getPaymentAccounts(), + window.API.accounting.getPaymentMethods() + ]); + + // Errors aus dem Backend abfangen + for (const [name, data] of [['vendors', vendors], ['expenseAccounts', expenseAccounts], ['paymentAccounts', paymentAccounts], ['paymentMethods', paymentMethods]]) { + if (data && data.error) { + alert(`Failed to load ${name}: ${data.error}\n\nTry "Sync from QBO" first.`); + return; + } + } + } catch (err) { + alert('Failed to load expense modal data: ' + err.message); + return; + } + + renderModal(); +} + +function closeModal() { + if (modalEl) { + modalEl.remove(); + modalEl = null; + } +} + +function resetForm() { + selectedVendorId = null; + selectedVendorName = ''; + document.getElementById('exp-vendor-search').value = ''; + document.getElementById('exp-vendor-id').value = ''; + document.getElementById('exp-ref-no').value = ''; + document.getElementById('exp-memo').value = ''; + document.getElementById('exp-date').value = todayISO(); + document.getElementById('exp-lines-tbody').innerHTML = ''; + lineCount = 0; + addLine(); + updateTotal(); +} + +// ──────────────────────────────────────────────────────────────────── +// Render +// ──────────────────────────────────────────────────────────────────── + +function renderModal() { + closeModal(); // safety + + const html = ` + `; + + document.body.insertAdjacentHTML('beforeend', html); + modalEl = document.getElementById('expense-modal'); + + // Initial: eine leere Linie + addLine(); + updateTotal(); +} + +// ──────────────────────────────────────────────────────────────────── +// Vendor Search +// ──────────────────────────────────────────────────────────────────── + +function onVendorInput() { + const inputEl = document.getElementById('exp-vendor-search'); + const dropdownEl = document.getElementById('exp-vendor-dropdown'); + const q = (inputEl.value || '').trim().toLowerCase(); + + // Wenn Suche != aktuell ausgewählter Vendor → Selection invalidieren + if (selectedVendorName && inputEl.value !== selectedVendorName) { + selectedVendorId = null; + selectedVendorName = ''; + document.getElementById('exp-vendor-id').value = ''; + } + + // Filter Vendors clientseitig (wir haben sie alle im Cache) + const filtered = q + ? vendors.filter(v => v.displayName.toLowerCase().includes(q)).slice(0, 50) + : vendors.slice(0, 50); + + let html = ''; + if (q && !filtered.some(v => v.displayName.toLowerCase() === q)) { + // "Add new" Option oben + html += ` +
+ + Add new vendor: ${escapeHtml(inputEl.value)} +
`; + } + + if (filtered.length === 0 && !q) { + html += `
Type to search…
`; + } else { + html += filtered.map(v => ` +
+
${escapeHtml(v.displayName)}
+ ${v.email ? `
${escapeHtml(v.email)}
` : ''} +
`).join(''); + } + + dropdownEl.innerHTML = html; + dropdownEl.classList.remove('hidden'); + + // Auto-close on outside click + if (!dropdownEl._outsideHandlerInstalled) { + dropdownEl._outsideHandlerInstalled = true; + document.addEventListener('click', (e) => { + if (!dropdownEl.contains(e.target) && e.target !== inputEl) { + dropdownEl.classList.add('hidden'); + } + }); + } +} + +function selectVendor(id, name) { + selectedVendorId = id; + selectedVendorName = name; + document.getElementById('exp-vendor-search').value = name; + document.getElementById('exp-vendor-id').value = id; + document.getElementById('exp-vendor-dropdown').classList.add('hidden'); +} + +// ──────────────────────────────────────────────────────────────────── +// Add New Vendor (sub-Modal) +// ──────────────────────────────────────────────────────────────────── + +function openAddVendor(prefilledName = '') { + document.getElementById('exp-vendor-dropdown').classList.add('hidden'); + + const html = ` +
+
+
+

+ New Vendor

+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+

Address (optional)

+
+ + +
+ + + +
+ +
+
+ +
+ + +
+ + +
+ +
+ + +
+
+
`; + document.body.insertAdjacentHTML('beforeend', html); + setTimeout(() => document.getElementById('ven-name').focus(), 50); +} + +function closeAddVendor() { + const m = document.getElementById('vendor-add-modal'); + if (m) m.remove(); +} + +async function saveAddVendor() { + const name = document.getElementById('ven-name').value.trim(); + if (!name) { + showVenError('Name is required'); + document.getElementById('ven-name').focus(); + return; + } + + const data = { + name, + email: document.getElementById('ven-email').value.trim() || null, + phone: document.getElementById('ven-phone').value.trim() || null, + notes: document.getElementById('ven-notes').value.trim() || null, + address: { + line1: document.getElementById('ven-line1').value.trim() || null, + line2: document.getElementById('ven-line2').value.trim() || null, + city: document.getElementById('ven-city').value.trim() || null, + state: document.getElementById('ven-state').value.trim() || null, + zip: document.getElementById('ven-zip').value.trim() || null, + country: document.getElementById('ven-country').value.trim() || null + } + }; + + const btn = document.getElementById('ven-save-btn'); + btn.disabled = true; + btn.textContent = 'Saving…'; + + try { + const result = await window.API.accounting.createVendor(data); + if (result.error) { + showVenError(result.error); + btn.disabled = false; + btn.textContent = 'Save Vendor'; + return; + } + + // Vendor in lokale Liste aufnehmen, sofort selektieren + if (!result.existed) { + vendors.push({ + id: result.id, + displayName: result.displayName, + companyName: result.displayName, + email: result.email, + phone: result.phone, + active: true + }); + // Liste alphabetisch halten + vendors.sort((a, b) => a.displayName.localeCompare(b.displayName)); + } + + selectVendor(result.id, result.displayName); + closeAddVendor(); + } catch (err) { + showVenError(err.message || 'Failed to save vendor'); + btn.disabled = false; + btn.textContent = 'Save Vendor'; + } +} + +function showVenError(msg) { + const el = document.getElementById('ven-error'); + el.textContent = msg; + el.classList.remove('hidden'); +} + +// ──────────────────────────────────────────────────────────────────── +// Lines +// ──────────────────────────────────────────────────────────────────── + +function addLine() { + lineCount++; + const tbody = document.getElementById('exp-lines-tbody'); + const tr = document.createElement('tr'); + tr.className = 'border-t'; + tr.dataset.lineIndex = lineCount; + tr.innerHTML = ` + ${lineCount} + + + + + + + + + + + + `; + tbody.appendChild(tr); +} + +function removeLine(btn) { + const tr = btn.closest('tr'); + if (tr) { + tr.remove(); + // Re-number visible lines + const rows = document.querySelectorAll('#exp-lines-tbody tr'); + rows.forEach((r, i) => { r.firstElementChild.textContent = i + 1; }); + updateTotal(); + } +} + +function clearLines() { + document.getElementById('exp-lines-tbody').innerHTML = ''; + lineCount = 0; + addLine(); + updateTotal(); +} + +function getLines() { + const rows = document.querySelectorAll('#exp-lines-tbody tr'); + return Array.from(rows).map(r => ({ + accountId: r.querySelector('.exp-line-account').value, + description: r.querySelector('.exp-line-desc').value.trim() || null, + amount: parseFloat(r.querySelector('.exp-line-amount').value) + })); +} + +function updateTotal() { + const total = getLines() + .filter(l => isFinite(l.amount)) + .reduce((sum, l) => sum + l.amount, 0); + document.getElementById('exp-total').textContent = fmtMoney(total); +} + +// ──────────────────────────────────────────────────────────────────── +// Save +// ──────────────────────────────────────────────────────────────────── + +async function save(mode) { + if (isSaving) return; + + hideError(); + + // Validate + if (!selectedVendorId) { + return showError('Please select or add a vendor.'); + } + const paymentAccountId = document.getElementById('exp-payment-account').value; + if (!paymentAccountId) { + return showError('Please select a payment account.'); + } + const txnDate = document.getElementById('exp-date').value; + if (!txnDate) { + return showError('Please enter a date.'); + } + + const lines = getLines().filter(l => l.accountId && isFinite(l.amount) && l.amount > 0); + if (lines.length === 0) { + return showError('At least one line with category and positive amount is required.'); + } + // Auch ungültige Lines flaggen + const invalidLines = getLines().filter(l => + (l.accountId && (!isFinite(l.amount) || l.amount <= 0)) || + (!l.accountId && isFinite(l.amount) && l.amount > 0) + ); + if (invalidLines.length > 0) { + return showError('Some lines have a category but no amount, or vice versa. Please complete or remove them.'); + } + + const payload = { + vendorId: selectedVendorId, + paymentAccountId, + txnDate, + paymentMethodId: document.getElementById('exp-payment-method').value || null, + refNo: document.getElementById('exp-ref-no').value.trim() || null, + memo: document.getElementById('exp-memo').value.trim() || null, + lines + }; + + isSaving = true; + setButtonsDisabled(true); + + try { + const result = await window.API.accounting.createExpense(payload); + if (result.error) { + showError(result.error); + return; + } + + if (typeof onSavedCb === 'function') { + try { onSavedCb(result); } catch (e) { console.warn('onSaved cb threw:', e); } + } + + if (mode === 'close') { + closeModal(); + } else if (mode === 'new') { + // Form leeren, Modal offen lassen + resetForm(); + // kleiner Toast + showToast(`✅ Saved Purchase #${result.id} — ${fmtMoney(result.totalAmt)}`); + } + } catch (err) { + showError(err.message || 'Save failed'); + } finally { + isSaving = false; + setButtonsDisabled(false); + } +} + +function setButtonsDisabled(disabled) { + const a = document.getElementById('exp-save-new-btn'); + const b = document.getElementById('exp-save-close-btn'); + if (a) a.disabled = disabled; + if (b) b.disabled = disabled; + if (a) a.textContent = disabled ? 'Saving…' : 'Save & New'; + if (b) b.textContent = disabled ? 'Saving…' : 'Save & Close'; +} + +function showError(msg) { + const el = document.getElementById('exp-error'); + if (el) { + el.textContent = msg; + el.classList.remove('hidden'); + } +} + +function hideError() { + const el = document.getElementById('exp-error'); + if (el) el.classList.add('hidden'); +} + +function showToast(msg) { + const id = 'exp-toast-' + Date.now(); + const div = document.createElement('div'); + div.id = id; + div.className = 'fixed bottom-6 right-6 bg-green-600 text-white px-4 py-2 rounded-md shadow-lg z-[70] text-sm'; + div.textContent = msg; + document.body.appendChild(div); + setTimeout(() => div.remove(), 3000); +} + +// ──────────────────────────────────────────────────────────────────── +// Expose +// ──────────────────────────────────────────────────────────────────── + +window.expenseModal = { + open: openExpenseModal, + close: closeModal, + onVendorInput, + selectVendor, + openAddVendor, + closeAddVendor, + saveAddVendor, + addLine, + removeLine, + clearLines, + updateTotal, + save +}; diff --git a/public/js/utils/api.js b/public/js/utils/api.js index f9fd953..25dca90 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -116,8 +116,8 @@ const API = { auth: () => window.location.href = '/auth/qbo' }, - // Accounting API (Phase 1 — read-only) accounting: { + // Phase 1 getAccounts: (type = null, activeOnly = true) => { const params = new URLSearchParams(); if (type) params.set('type', type); @@ -125,7 +125,6 @@ const API = { const qs = params.toString(); return fetch('/api/accounting/accounts' + (qs ? '?' + qs : '')).then(r => r.json()); }, - syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()), getRegister: (accountId, startDate, endDate) => { const params = new URLSearchParams({ accountId }); if (startDate) params.set('startDate', startDate); @@ -139,6 +138,41 @@ const API = { getBalanceSheet: (asOfDate, accountingMethod = 'Accrual') => { const params = new URLSearchParams({ asOfDate, accountingMethod }); return fetch('/api/accounting/reports/balance-sheet?' + params.toString()).then(r => r.json()); + }, + + // Phase 2 Lieferung 1 — Sync + Cache-Reads + syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()), + syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()), + getSyncStatus: () => fetch('/api/accounting/sync-status').then(r => r.json()), + + getVendors: (search = '', limit = 200) => { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (limit) params.set('limit', String(limit)); + const qs = params.toString(); + return fetch('/api/accounting/vendors' + (qs ? '?' + qs : '')).then(r => r.json()); + }, + getExpenseAccounts: () => fetch('/api/accounting/expense-accounts').then(r => r.json()), + getPaymentAccounts: () => fetch('/api/accounting/payment-accounts').then(r => r.json()), + getPaymentMethods: () => fetch('/api/accounting/payment-methods').then(r => r.json()), + + // Phase 2 Lieferung 2 — Mutations + List + createVendor: (data) => fetch('/api/accounting/vendors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()), + + createExpense: (data) => fetch('/api/accounting/expenses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()), + + listExpenses: (startDate, endDate, onlyMine = false) => { + const params = new URLSearchParams({ startDate, endDate }); + if (onlyMine) params.set('onlyMine', 'true'); + return fetch('/api/accounting/expenses?' + params.toString()).then(r => r.json()); } }, diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 4c81578..a68a0da 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -1,24 +1,24 @@ /** - * accounting-view.js — Phase 1, read-only + * accounting-view.js * - * Drei Bereiche: - * 1) Accounts Overview — Cards mit Bank- und Credit-Card-Balances - * 2) Register — read-only Liste der Transaktionen für ein Konto - * 3) Reports — Profit & Loss + Balance Sheet + * Phase 1: Accounts Overview, Register, Reports + * Phase 2 Lieferung 2: Expenses-Section + Auto-Sync-on-First-Open-of-Day */ -import '../utils/api.js'; // ← NEU + +import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; +import { openExpenseModal } from '../modals/expense-modal.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) // ──────────────────────────────────────────────────────────────────── -let allAccounts = []; // alle aktiven Accounts (gesamt, für Dropdown) -let registerAccountId = null; // aktuell ausgewählter Account fürs Register +let allAccounts = []; +let registerAccountId = null; let registerStartDate = null; let registerEndDate = null; +let registerLoadSeq = 0; -// Reports let plStartDate = null; let plEndDate = null; let plAccountingMethod = 'Accrual'; @@ -26,7 +26,13 @@ let plAccountingMethod = 'Accrual'; let bsAsOfDate = null; let bsAccountingMethod = 'Accrual'; -let registerLoadSeq = 0; +let expStartDate = null; +let expEndDate = null; +let expOnlyMine = false; + +// Auto-Sync nur einmal pro View-Mount +let autoSyncDoneThisOpen = false; + // ──────────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────────── @@ -34,22 +40,16 @@ let registerLoadSeq = 0; function fmtMoney(n) { if (n == null || isNaN(n)) return ''; return n.toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2 + style: 'currency', currency: 'USD', + minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -function todayISO() { - return new Date().toISOString().split('T')[0]; -} - +function todayISO() { return new Date().toISOString().split('T')[0]; } function firstOfMonthISO() { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; } - function firstOfYearISO() { const d = new Date(); return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0]; @@ -58,11 +58,8 @@ function firstOfYearISO() { function escapeHtml(s) { if (s == null) return ''; return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); } function showError(slotId, message) { @@ -89,7 +86,7 @@ function showLoading(slotId, message = 'Loading…') { } // ──────────────────────────────────────────────────────────────────── -// Toolbar (top-of-tab heading + sync button) +// Toolbar // ──────────────────────────────────────────────────────────────────── export function injectToolbar() { @@ -98,8 +95,13 @@ export function injectToolbar() { c.innerHTML = `

Accounting

- read-only + read-only registers · expense entry
+ +
`; } +// ──────────────────────────────────────────────────────────────────── +// Auto-Sync beim ersten Öffnen des Tages +// ──────────────────────────────────────────────────────────────────── + +async function maybeAutoSyncCaches() { + if (autoSyncDoneThisOpen) return; + autoSyncDoneThisOpen = true; + + try { + const status = await window.API.accounting.getSyncStatus(); + const accStale = status.accounts && status.accounts.staleToday; + const venStale = status.vendors && status.vendors.staleToday; + + if (!accStale && !venStale) { + updateSyncStatusBadge(status); + return; + } + + const syncBadge = document.getElementById('accounting-sync-status'); + if (syncBadge) syncBadge.textContent = '🔄 Syncing caches…'; + + const tasks = []; + if (accStale) tasks.push(window.API.accounting.syncAccounts()); + if (venStale) tasks.push(window.API.accounting.syncVendors()); + await Promise.all(tasks); + + const newStatus = await window.API.accounting.getSyncStatus(); + updateSyncStatusBadge(newStatus); + console.log('✅ Auto-synced QBO caches (first open of day)'); + } catch (err) { + console.warn('Auto-sync failed:', err.message); + const syncBadge = document.getElementById('accounting-sync-status'); + if (syncBadge) syncBadge.textContent = '⚠️ Sync failed'; + } +} + +export async function manualSync() { + const syncBadge = document.getElementById('accounting-sync-status'); + if (syncBadge) syncBadge.textContent = '🔄 Syncing…'; + try { + await Promise.all([ + window.API.accounting.syncAccounts(), + window.API.accounting.syncVendors() + ]); + const status = await window.API.accounting.getSyncStatus(); + updateSyncStatusBadge(status); + } catch (err) { + if (syncBadge) syncBadge.textContent = '⚠️ Sync failed'; + alert('Sync failed: ' + err.message); + } +} + +function updateSyncStatusBadge(status) { + const el = document.getElementById('accounting-sync-status'); + if (!el) return; + const a = status.accounts; + const v = status.vendors; + if (a?.last_synced_at && v?.last_synced_at) { + const aTime = new Date(a.last_synced_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + el.innerHTML = `Synced @ ${aTime} · ${a.last_sync_count} accts · ${v.last_sync_count} vendors`; + } else { + el.textContent = 'Not yet synced'; + } +} + // ──────────────────────────────────────────────────────────────────── // Accounts Overview // ──────────────────────────────────────────────────────────────────── @@ -117,37 +184,20 @@ export async function loadAccountsOverview() { showLoading(slot, 'Loading accounts from QBO…'); try { - // Wir laden alle aktiven Accounts in einem Rutsch und filtern client-seitig. const accounts = await window.API.accounting.getAccounts(null, true); - - if (accounts.error) { - showError(slot, accounts.error); - return; - } + if (accounts.error) return showError(slot, accounts.error); allAccounts = accounts; - - // Bank/Credit Card Cards rendern - const cards = accounts.filter(a => - a.accountType === 'Bank' || a.accountType === 'Credit Card' - ); + const cards = accounts.filter(a => a.accountType === 'Bank' || a.accountType === 'Credit Card'); const el = document.getElementById(slot); if (!cards.length) { - el.innerHTML = ` -
- No bank or credit card accounts found in QBO. -
`; + el.innerHTML = `
No bank or credit card accounts found.
`; } else { - el.innerHTML = ` -
- ${cards.map(a => renderAccountCard(a)).join('')} -
`; + el.innerHTML = `
${cards.map(renderAccountCard).join('')}
`; } - // Register-Dropdown füttern (nur Bank + Credit Card) populateRegisterAccountDropdown(cards); - } catch (err) { console.error('Accounts load failed:', err); showError(slot, err.message || 'Failed to load accounts'); @@ -159,9 +209,7 @@ function renderAccountCard(a) { const accent = isBank ? 'border-blue-200 bg-blue-50' : 'border-purple-200 bg-purple-50'; const label = isBank ? 'Bank' : 'Credit Card'; const labelColor = isBank ? 'text-blue-700' : 'text-purple-700'; - const bal = a.currentBalance; - const balText = bal != null ? fmtMoney(bal) : '—'; - // Bei Credit Cards ist ein positiver CurrentBalance i.d.R. eine Schuld; nur Hinweis, keine Vorzeichenakrobatik. + const balText = a.currentBalance != null ? fmtMoney(a.currentBalance) : '—'; return `
@@ -172,8 +220,7 @@ function renderAccountCard(a) {
${escapeHtml(a.name)}
${balText}
${a.accountSubType ? `
${escapeHtml(a.accountSubType)}
` : ''} -
- `; +
`; } // ──────────────────────────────────────────────────────────────────── @@ -183,22 +230,15 @@ function renderAccountCard(a) { function populateRegisterAccountDropdown(bankCardAccounts) { const sel = document.getElementById('reg-account'); if (!sel) return; - const current = registerAccountId || sel.value; sel.innerHTML = `` + - bankCardAccounts.map(a => - `` - ).join(''); - - if (current && bankCardAccounts.find(a => a.id === current)) { - sel.value = current; - } + bankCardAccounts.map(a => ``).join(''); + if (current && bankCardAccounts.find(a => a.id === current)) sel.value = current; } export function injectRegisterControls() { const c = document.getElementById('accounting-register-controls'); if (!c) return; - if (!registerStartDate) registerStartDate = firstOfMonthISO(); if (!registerEndDate) registerEndDate = todayISO(); @@ -212,13 +252,11 @@ export function injectRegisterControls() {
- +
- +
+ +
- -
-
-

Balance Sheet

-
+

Balance Sheet

-
- - -
-
- +
+
+
-
- +
+
@@ -446,58 +429,30 @@ export async function loadProfitLoss() { plStartDate = document.getElementById('pl-start').value; plEndDate = document.getElementById('pl-end').value; plAccountingMethod = document.getElementById('pl-method').value; - showLoading('pl-result', 'Loading P&L from QBO…'); try { - const data = await window.API.accounting.getProfitAndLoss( - plStartDate, plEndDate, plAccountingMethod - ); + const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod); if (data.error) return showError('pl-result', data.error); document.getElementById('pl-result').innerHTML = renderQboReport(data); - } catch (err) { - showError('pl-result', err.message || 'Failed to load P&L'); - } + } catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); } } export async function loadBalanceSheet() { bsAsOfDate = document.getElementById('bs-asof').value; bsAccountingMethod = document.getElementById('bs-method').value; - showLoading('bs-result', 'Loading Balance Sheet from QBO…'); try { - const data = await window.API.accounting.getBalanceSheet( - bsAsOfDate, bsAccountingMethod - ); + const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod); if (data.error) return showError('bs-result', data.error); document.getElementById('bs-result').innerHTML = renderQboReport(data); - } catch (err) { - showError('bs-result', err.message || 'Failed to load Balance Sheet'); - } + } catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); } } -// ──────────────────────────────────────────────────────────────────── -// Generic QBO Report Renderer (P&L + Balance Sheet) -// QBO Reports haben rekursive Section/Row-Bäume mit Summary-Zeilen. -// Wir rendern sie als verschachtelte HTML-Tabelle. -// ──────────────────────────────────────────────────────────────────── - function renderQboReport(report) { - if (!report || !report.Header) { - return `

No report data.

`; - } - + if (!report || !report.Header) return `

No report data.

`; const cols = (report.Columns && report.Columns.Column) || []; - const colCount = cols.length; - - let body = ''; - if (report.Rows && report.Rows.Row) { - body = renderReportRows(report.Rows.Row, 0, colCount); - } - - const headerRow = cols.map(c => - `${escapeHtml(c.ColTitle || '')}` - ).join(''); - + const headerRow = cols.map(c => `${escapeHtml(c.ColTitle || '')}`).join(''); + const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : ''; return `
${escapeHtml(report.Header.ReportName || '')} @@ -506,72 +461,186 @@ function renderQboReport(report) {
- - ${headerRow} - + ${headerRow}${body}
`; } -function renderReportRows(rows, depth, colCount) { +function renderReportRows(rows, depth) { if (!rows) return ''; const arr = Array.isArray(rows) ? rows : [rows]; let html = ''; - for (const row of arr) { const isSection = row.type === 'Section' || row.Rows || row.Summary; - const indentPx = depth * 16; - + const indent = depth * 16; if (row.Header && row.Header.ColData) { - // Section header row - const headerCells = row.Header.ColData.map((c, i) => { - if (i === 0) { - return `${escapeHtml(c.value || '')}`; - } - return ``; - }).join(''); - html += `${headerCells}`; + const cells = row.Header.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `` + ).join(''); + html += `${cells}`; } - - if (isSection && row.Rows && row.Rows.Row) { - html += renderReportRows(row.Rows.Row, depth + 1, colCount); - } - + if (isSection && row.Rows && row.Rows.Row) html += renderReportRows(row.Rows.Row, depth + 1); if (row.Summary && row.Summary.ColData) { - const sumCells = row.Summary.ColData.map((c, i) => { - if (i === 0) { - return `${escapeHtml(c.value || '')}`; - } - return `${escapeHtml(c.value || '')}`; - }).join(''); - html += `${sumCells}`; + const cells = row.Summary.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `${escapeHtml(c.value || '')}` + ).join(''); + html += `${cells}`; } - if (!isSection && row.ColData) { - // Plain data row - const cells = row.ColData.map((c, i) => { - if (i === 0) { - return `${escapeHtml(c.value || '')}`; - } - return `${escapeHtml(c.value || '')}`; - }).join(''); + const cells = row.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `${escapeHtml(c.value || '')}` + ).join(''); html += `${cells}`; } } - return html; } +// ════════════════════════════════════════════════════════════════════ +// Phase 2 Lieferung 2 — Expenses Section +// ════════════════════════════════════════════════════════════════════ + +export function injectExpensesSection() { + const c = document.getElementById('accounting-expenses'); + if (!c) return; + if (!expStartDate) expStartDate = firstOfMonthISO(); + if (!expEndDate) expEndDate = todayISO(); + + c.innerHTML = ` +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
`; +} + +export async function loadExpenses() { + const startEl = document.getElementById('exp-start'); + const endEl = document.getElementById('exp-end'); + const onlyEl = document.getElementById('exp-only-mine'); + expStartDate = startEl.value; + expEndDate = endEl.value; + expOnlyMine = onlyEl.checked; + + const slot = 'accounting-expenses-table'; + showLoading(slot, 'Loading expenses from QBO…'); + + try { + const list = await window.API.accounting.listExpenses(expStartDate, expEndDate, expOnlyMine); + if (list.error) return showError(slot, list.error); + renderExpensesTable(list); + } catch (err) { + showError(slot, err.message || 'Failed to load expenses'); + } +} + +function renderExpensesTable(expenses) { + const el = document.getElementById('accounting-expenses-table'); + if (!el) return; + + if (!expenses.length) { + el.innerHTML = `
No expenses in selected range${expOnlyMine ? ' (created from this app)' : ''}.
`; + return; + } + + const sorted = expenses.slice().sort((a, b) => (b.txnDate || '').localeCompare(a.txnDate || '')); + const tbody = sorted.map(e => { + const splitsHtml = e.lines && e.lines.length > 1 + ? `
${e.lines.length} lines +
+ ${e.lines.map(l => ` +
+ ${escapeHtml(l.accountName || '?')} + ${l.amount != null ? fmtMoney(l.amount) : ''} +
`).join('')} +
+
` + : escapeHtml(e.lines[0]?.accountName || ''); + + return ` + + ${escapeHtml(e.txnDate || '')} + ${escapeHtml(e.vendorName || '')} + ${escapeHtml(e.accountName || '')} + ${splitsHtml} + ${escapeHtml(e.refNo || '')} + ${escapeHtml(e.memo || '')} + ${fmtMoney(e.totalAmt)} + `; + }).join(''); + + el.innerHTML = ` +
+ + + + + + + + + + + + + ${tbody} +
DateVendorPayment AccountCategoryRefMemoAmount
+
${sorted.length} expense${sorted.length === 1 ? '' : 's'}
+
`; +} + +export async function openNewExpense() { + await openExpenseModal({ + onSaved: () => loadExpenses() + }); +} + // ──────────────────────────────────────────────────────────────────── // Init / Public Entry Points // ──────────────────────────────────────────────────────────────────── export function renderAccountingView() { + autoSyncDoneThisOpen = false; injectToolbar(); injectRegisterControls(); injectReportsControls(); - loadAccountsOverview(); + injectExpensesSection(); + + // Sequenz: erst Auto-Sync (wenn nötig), DANN Daten laden, + // damit das Frontend frische Cache-basierte Daten kriegt. + maybeAutoSyncCaches().then(() => { + loadAccountsOverview(); + }); } export function refreshAll() { @@ -579,13 +648,15 @@ export function refreshAll() { if (registerAccountId) loadRegister(); } -// Expose for onclick handlers window.accountingView = { renderAccountingView, refreshAll, + manualSync, loadAccountsOverview, loadRegister, selectRegisterAccount, loadProfitLoss, - loadBalanceSheet -}; + loadBalanceSheet, + loadExpenses, + openNewExpense +}; \ No newline at end of file diff --git a/src/routes/accounting.js b/src/routes/accounting.js index c7e2d58..94b7bc4 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -9,10 +9,15 @@ const accountingService = require('../services/accounting-service'); // ──────────────────────────────────────────────────────────────────── function handleQboError(err, res, context) { - console.error(`❌ Accounting/${context} error:`, err.message); - if (err.qboFault) console.error(' QBO Fault detail:', JSON.stringify(err.qboFault)); - if (err.stack) console.error(err.stack); - res.status(500).json({ error: err.message || 'QBO request failed', context }); + const statusCode = err.statusCode || 500; + if (statusCode >= 500) { + console.error(`❌ Accounting/${context} error:`, err.message); + if (err.qboFault) console.error(' QBO Fault:', JSON.stringify(err.qboFault)); + if (err.stack) console.error(err.stack); + } else { + console.warn(`⚠️ Accounting/${context} ${statusCode}:`, err.message); + } + res.status(statusCode).json({ error: err.message || 'Request failed', context }); } // ════════════════════════════════════════════════════════════════════ @@ -166,4 +171,40 @@ router.get('/payment-methods', async (req, res) => { } catch (err) { handleQboError(err, res, 'payment-methods'); } }); +// ─── POST /api/accounting/vendors ─────────────────────────────────── +// Erstellt einen neuen Vendor in QBO + Cache. +// Body: { name, email?, phone?, address?: {...}, notes? } +// Status: 200 { id, displayName, ..., existed: false } +// 200 { ..., existed: true } ← Idempotent: Vendor war schon da +// 400 wenn name fehlt +router.post('/vendors', express.json(), async (req, res) => { + try { + const result = await accountingService.createVendor(req.body || {}); + res.json(result); + } catch (err) { handleQboError(err, res, 'vendor-create'); } +}); + +// ─── POST /api/accounting/expenses ────────────────────────────────── +// Erstellt eine QBO Purchase (Expense) mit ein oder mehreren Lines. +// Body: { vendorId, paymentAccountId, txnDate, paymentMethodId?, refNo?, memo?, lines: [...] } +router.post('/expenses', express.json(), async (req, res) => { + try { + const result = await accountingService.createExpense(req.body || {}); + res.json(result); + } catch (err) { handleQboError(err, res, 'expense-create'); } +}); + +// ─── GET /api/accounting/expenses ─────────────────────────────────── +// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&onlyMine=true|false +router.get('/expenses', async (req, res) => { + try { + const expenses = await accountingService.listExpenses({ + startDate: req.query.startDate, + endDate: req.query.endDate, + onlyMine: req.query.onlyMine === 'true' + }); + res.json(expenses); + } catch (err) { handleQboError(err, res, 'expense-list'); } +}); + module.exports = router; \ No newline at end of file diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index a623bae..2b9be41 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -600,6 +600,389 @@ async function getPaymentMethods({ activeOnly = true } = {}) { })); } + + +// ──────────────────────────────────────────────────────────────────── +// Phase 2 Lieferung 2 — Vendor Create +// ──────────────────────────────────────────────────────────────────── + +/** + * Erstellt einen neuen Vendor in QBO und schreibt ihn in den Cache. + * + * Idempotenz: Wenn ein aktiver Vendor mit gleichem display_name (case-insensitive) + * bereits im Cache existiert, wird KEIN neuer angelegt — stattdessen wird der + * existierende zurückgegeben mit { existed: true }. + * + * @param {Object} data + * @param {string} data.name - Pflicht: DisplayName + * @param {string} [data.email] + * @param {string} [data.phone] + * @param {Object} [data.address] - { line1, line2, city, state, zip, country } + * @param {string} [data.notes] + * @returns {{ id, displayName, email, phone, existed: boolean }} + */ +async function createVendor(data) { + const name = (data.name || '').trim(); + if (!name) { + const err = new Error('Vendor name is required'); + err.statusCode = 400; + throw err; + } + + // ── Idempotenz-Check ── + const existingResult = await pool.query( + `SELECT qbo_id, display_name, primary_email, primary_phone + FROM qbo_vendor_cache + WHERE active = true AND LOWER(display_name) = LOWER($1) + LIMIT 1`, + [name] + ); + if (existingResult.rows.length > 0) { + const v = existingResult.rows[0]; + return { + id: v.qbo_id, + displayName: v.display_name, + email: v.primary_email, + phone: v.primary_phone, + existed: true + }; + } + + // ── QBO Create ── + const { companyId, baseUrl } = getClientInfo(); + const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/vendor`); + + const payload = { + DisplayName: name, + CompanyName: name, + Active: true + }; + if (data.email) payload.PrimaryEmailAddr = { Address: data.email }; + if (data.phone) payload.PrimaryPhone = { FreeFormNumber: data.phone }; + if (data.notes) payload.Notes = data.notes; + + if (data.address && (data.address.line1 || data.address.city)) { + const a = data.address; + const billAddr = {}; + if (a.line1) billAddr.Line1 = a.line1; + if (a.line2) billAddr.Line2 = a.line2; + if (a.city) billAddr.City = a.city; + if (a.state) billAddr.CountrySubDivisionCode = a.state; + if (a.zip) billAddr.PostalCode = a.zip; + if (a.country) billAddr.Country = a.country; + payload.BillAddr = billAddr; + } + + let qboResponse; + try { + const response = await makeQboApiCall({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + qboResponse = getJson(response); + } catch (err) { + await writeAuditLog({ + action: 'vendor.create', + entityType: 'Vendor', + status: 'error', + requestExcerpt: JSON.stringify(payload).slice(0, 1000), + responseExcerpt: err.message + }); + throw err; + } + + if (qboResponse.Fault) { + const msg = qboResponse.Fault.Error.map(e => `${e.code}: ${e.Message}`).join('; '); + await writeAuditLog({ + action: 'vendor.create', + entityType: 'Vendor', + status: 'error', + requestExcerpt: JSON.stringify(payload).slice(0, 1000), + responseExcerpt: msg + }); + const err = new Error('QBO Vendor create failed: ' + msg); + err.qboFault = qboResponse.Fault; + throw err; + } + + const v = qboResponse.Vendor; + if (!v || !v.Id) { + throw new Error('QBO returned no vendor id'); + } + + // ── In Cache schreiben ── + const email = v.PrimaryEmailAddr ? v.PrimaryEmailAddr.Address : null; + const phone = v.PrimaryPhone ? v.PrimaryPhone.FreeFormNumber : null; + + await pool.query( + `INSERT INTO qbo_vendor_cache + (qbo_id, display_name, company_name, primary_email, primary_phone, + active, sync_token, cached_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP) + ON CONFLICT (qbo_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + company_name = EXCLUDED.company_name, + primary_email = EXCLUDED.primary_email, + primary_phone = EXCLUDED.primary_phone, + active = EXCLUDED.active, + sync_token = EXCLUDED.sync_token, + cached_at = CURRENT_TIMESTAMP`, + [v.Id, v.DisplayName, v.CompanyName || null, email, phone, v.Active === true, v.SyncToken || null] + ); + + await writeAuditLog({ + action: 'vendor.create', + entityType: 'Vendor', + entityQboId: v.Id, + status: 'success', + requestExcerpt: JSON.stringify(payload).slice(0, 1000), + responseExcerpt: `Vendor ${v.Id} (${v.DisplayName}) created` + }); + + console.log(`✅ QBO Vendor created: ${v.Id} (${v.DisplayName})`); + + return { + id: v.Id, + displayName: v.DisplayName, + email, + phone, + existed: false + }; +} + +// ──────────────────────────────────────────────────────────────────── +// Phase 2 Lieferung 2 — Expense Create +// ──────────────────────────────────────────────────────────────────── + +/** + * Erstellt eine QBO Purchase (= "Expense" in QBO-Sprech). + * + * @param {Object} data + * @param {string} data.vendorId - Pflicht + * @param {string} data.paymentAccountId - Pflicht (Bank- oder Credit-Card-Account) + * @param {string} data.txnDate - Pflicht, YYYY-MM-DD + * @param {string} [data.paymentMethodId] + * @param {string} [data.refNo] + * @param {string} [data.memo] + * @param {Array} data.lines - Pflicht, mind. 1 Line + * Line: { accountId, amount, description? } + * + * @returns {{ id, txnDate, totalAmt, lineCount, vendorName, accountName }} + */ +async function createExpense(data) { + // ── Validierung ── + if (!data.vendorId) throw badRequest('vendorId is required'); + if (!data.paymentAccountId) throw badRequest('paymentAccountId is required'); + if (!data.txnDate) throw badRequest('txnDate is required'); + if (!Array.isArray(data.lines) || data.lines.length === 0) { + throw badRequest('At least one line is required'); + } + for (const [i, line] of data.lines.entries()) { + if (!line.accountId) throw badRequest(`Line ${i + 1}: accountId is required`); + const amt = Number(line.amount); + if (!isFinite(amt) || amt <= 0) { + throw badRequest(`Line ${i + 1}: amount must be a positive number`); + } + } + + // ── Account-Type des Payment-Accounts bestimmen ── + // Bank → PaymentType "Check" (default in QBO) + // Credit Card → PaymentType "CreditCard" + const acctRow = await pool.query( + `SELECT qbo_id, name, account_type FROM qbo_account_cache WHERE qbo_id = $1`, + [data.paymentAccountId] + ); + if (acctRow.rows.length === 0) { + throw badRequest(`Payment account ${data.paymentAccountId} not in cache. Run sync first.`); + } + const paymentAcct = acctRow.rows[0]; + const paymentType = paymentAcct.account_type === 'Credit Card' ? 'CreditCard' : 'Check'; + + // ── Vendor-Name aus Cache (für Logging/Response) ── + const vendorRow = await pool.query( + `SELECT display_name FROM qbo_vendor_cache WHERE qbo_id = $1`, + [data.vendorId] + ); + const vendorName = vendorRow.rows[0]?.display_name || data.vendorId; + + // ── QBO Purchase Payload ── + const totalAmt = data.lines.reduce((sum, l) => sum + Number(l.amount), 0); + + const payload = { + AccountRef: { value: paymentAcct.qbo_id, name: paymentAcct.name }, + EntityRef: { value: data.vendorId, type: 'Vendor', name: vendorName }, + TxnDate: data.txnDate, + PaymentType: paymentType, + Line: data.lines.map(line => ({ + DetailType: 'AccountBasedExpenseLineDetail', + Amount: Number(line.amount), + Description: line.description || undefined, + AccountBasedExpenseLineDetail: { + AccountRef: { value: String(line.accountId) } + } + })) + }; + + if (data.refNo) payload.DocNumber = String(data.refNo).slice(0, 21); + if (data.memo) payload.PrivateNote = String(data.memo); + + if (data.paymentMethodId) { + payload.PaymentMethodRef = { value: String(data.paymentMethodId) }; + } + + // ── QBO POST ── + const { companyId, baseUrl } = getClientInfo(); + const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/purchase`); + + const requestSummary = `${vendorName} | ${paymentAcct.name} | ${data.txnDate} | $${totalAmt.toFixed(2)} | ${data.lines.length} line(s)`; + + let qboResponse; + try { + const response = await makeQboApiCall({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + qboResponse = getJson(response); + } catch (err) { + await writeAuditLog({ + action: 'expense.create', + entityType: 'Purchase', + status: 'error', + requestExcerpt: requestSummary, + responseExcerpt: err.message + }); + throw err; + } + + if (qboResponse.Fault) { + const msg = qboResponse.Fault.Error.map(e => + `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}` + ).join('; '); + await writeAuditLog({ + action: 'expense.create', + entityType: 'Purchase', + status: 'error', + requestExcerpt: requestSummary, + responseExcerpt: msg + }); + const err = new Error('QBO Purchase create failed: ' + msg); + err.qboFault = qboResponse.Fault; + throw err; + } + + const purchase = qboResponse.Purchase; + if (!purchase || !purchase.Id) throw new Error('QBO returned no Purchase id'); + + await writeAuditLog({ + action: 'expense.create', + entityType: 'Purchase', + entityQboId: purchase.Id, + status: 'success', + requestExcerpt: requestSummary, + responseExcerpt: `Purchase ${purchase.Id} created, total $${Number(purchase.TotalAmt).toFixed(2)}` + }); + + console.log(`✅ QBO Expense created: Purchase ${purchase.Id} — ${requestSummary}`); + + return { + id: purchase.Id, + txnDate: purchase.TxnDate, + totalAmt: Number(purchase.TotalAmt), + lineCount: data.lines.length, + vendorName, + accountName: paymentAcct.name + }; +} + +function badRequest(msg) { + const err = new Error(msg); + err.statusCode = 400; + return err; +} + +// ──────────────────────────────────────────────────────────────────── +// Phase 2 Lieferung 2 — Expense List (read) +// ──────────────────────────────────────────────────────────────────── + +/** + * Liefert eine Liste von QBO Purchases (=Expenses) für ein Datums-Intervall. + * + * @param {Object} opts + * @param {string} opts.startDate - Pflicht + * @param {string} opts.endDate - Pflicht + * @param {boolean} [opts.onlyMine] - Wenn true, nur Purchases die in unserem + * accounting_sync_log mit action='expense.create' + * erfolgreich exportiert wurden + * @returns {Array<{ id, txnDate, totalAmt, vendorName, accountName, refNo, memo, lines }>} + */ +async function listExpenses({ startDate, endDate, onlyMine = false } = {}) { + if (!startDate || !endDate) throw badRequest('startDate and endDate are required'); + + const { companyId, baseUrl } = getClientInfo(); + + // Wir queryen Purchases in dem Date-Range. PaymentType filtern wir nicht — + // QBO speichert auch Expense-Buchungen mit AccountBasedExpenseLineDetail. + const safeStart = startDate.replace(/'/g, ''); + const safeEnd = endDate.replace(/'/g, ''); + const sql = `SELECT * FROM Purchase WHERE TxnDate >= '${safeStart}' AND TxnDate <= '${safeEnd}' ORDERBY TxnDate DESC MAXRESULTS 1000`; + + const url = withMinorVersion( + `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(sql)}` + ); + const response = await makeQboApiCall({ url, method: 'GET' }); + const data = getJson(response); + throwIfFault(data, 'Purchase query'); + + let purchases = (data.QueryResponse && data.QueryResponse.Purchase) || []; + + // Filter: nur App-eigene Expenses + if (onlyMine) { + const r = await pool.query( + `SELECT DISTINCT entity_qbo_id FROM accounting_sync_log + WHERE action = 'expense.create' AND status = 'success' AND entity_qbo_id IS NOT NULL` + ); + const myIds = new Set(r.rows.map(row => row.entity_qbo_id)); + purchases = purchases.filter(p => myIds.has(p.Id)); + } + + return purchases.map(p => normalizePurchase(p)); +} + +function normalizePurchase(p) { + const lines = (p.Line || []) + .filter(l => l.DetailType !== 'SubTotalLineDetail') + .map(l => { + const detail = l.AccountBasedExpenseLineDetail || {}; + const acctRef = detail.AccountRef || {}; + return { + accountId: acctRef.value || null, + accountName: acctRef.name || null, + amount: l.Amount != null ? Number(l.Amount) : null, + description: l.Description || null + }; + }); + + return { + id: p.Id, + txnDate: p.TxnDate, + totalAmt: p.TotalAmt != null ? Number(p.TotalAmt) : 0, + vendorName: p.EntityRef ? p.EntityRef.name : null, + vendorId: p.EntityRef ? p.EntityRef.value : null, + accountName: p.AccountRef ? p.AccountRef.name : null, + accountId: p.AccountRef ? p.AccountRef.value : null, + paymentType: p.PaymentType, + refNo: p.DocNumber || null, + memo: p.PrivateNote || null, + lines + }; +} + + + // ════════════════════════════════════════════════════════════════════ // Exports // ════════════════════════════════════════════════════════════════════ @@ -624,6 +1007,11 @@ module.exports = { getPaymentAccountsFromCache, getPaymentMethods, + // Phase 2 Lieferung 2 — Mutations + List + createVendor, + createExpense, + listExpenses, + // Audit writeAuditLog }; \ No newline at end of file