/** * accounting-view.js * * Phase 1: Accounts Overview, Register, Reports * Phase 2 Lieferung 2: Expenses-Section + Auto-Sync-on-First-Open-of-Day */ import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; import { openExpenseModal } from '../modals/expense-modal.js'; import { openRefundModal } from '../modals/refund-modal.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) // ──────────────────────────────────────────────────────────────────── let allAccounts = []; let registerAccountId = null; let registerStartDate = null; let registerEndDate = null; let registerLoadSeq = 0; let plStartDate = null; let plEndDate = null; let plAccountingMethod = 'Accrual'; let bsAsOfDate = null; let bsAccountingMethod = 'Accrual'; let tsMonth = null; // 'YYYY-MM' — selected month for tax summary let tsAccountingMethod = 'Accrual'; let expStartDate = null; let expEndDate = null; let expOnlyMine = false; // Auto-Sync nur einmal pro View-Mount let autoSyncDoneThisOpen = false; // ──────────────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────────────── function fmtMoney(n) { if (n == null || isNaN(n)) return ''; return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function todayISO() { return new Date().toISOString().split('T')[0]; } function firstOfMonthISO(year, month) { const d = year != null ? new Date(year, month, 1) : new Date(); return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; } function lastOfMonthISO(year, month) { const d = year != null ? new Date(year, month + 1, 0) : new Date(); return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split('T')[0]; } function prevMonthISO() { const d = new Date(); const m = d.getMonth() === 0 ? 11 : d.getMonth() - 1; const y = d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear(); return `${y}-${String(m + 1).padStart(2, '0')}`; } function firstOfYearISO() { const d = new Date(); return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0]; } function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function showError(slotId, message) { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `

QBO Error

${escapeHtml(message)}

`; } function showLoading(slotId, message = 'Loading…') { const el = document.getElementById(slotId); if (!el) return; el.innerHTML = `
${escapeHtml(message)}
`; } function makeCollapsible(headerText, contentId, startCollapsed = false) { return `

${escapeHtml(headerText)}

`; } export function toggleSection(contentId, headerEl) { const content = document.getElementById(contentId); if (!content) return; const isHidden = content.classList.toggle('hidden'); const arrow = headerEl.querySelector('svg'); if (arrow) arrow.classList.toggle('rotate-90', !isHidden); } // ──────────────────────────────────────────────────────────────────── // Toolbar // ──────────────────────────────────────────────────────────────────── export function injectToolbar() { const c = document.getElementById('accounting-toolbar'); if (!c) return; c.innerHTML = `

Accounting

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 // ──────────────────────────────────────────────────────────────────── export async function loadAccountsOverview() { const slot = 'accounting-accounts'; showLoading(slot, 'Loading accounts from QBO…'); try { const accounts = await window.API.accounting.getAccounts(null, true); if (accounts.error) return showError(slot, accounts.error); allAccounts = accounts; 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.
`; } else { el.innerHTML = `
${cards.map(renderAccountCard).join('')}
`; } populateRegisterAccountDropdown(cards); } catch (err) { console.error('Accounts load failed:', err); showError(slot, err.message || 'Failed to load accounts'); } } function renderAccountCard(a) { const isBank = a.accountType === 'Bank'; 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 balText = a.currentBalance != null ? fmtMoney(a.currentBalance) : '—'; return `
${label} #${a.id}
${escapeHtml(a.name)}
${balText}
${a.accountSubType ? `
${escapeHtml(a.accountSubType)}
` : ''}
`; } // ──────────────────────────────────────────────────────────────────── // Register // ──────────────────────────────────────────────────────────────────── 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; } export function injectRegisterControls() { const c = document.getElementById('accounting-register-controls'); if (!c) return; if (!registerStartDate) registerStartDate = firstOfMonthISO(); if (!registerEndDate) registerEndDate = todayISO(); c.innerHTML = ` ${makeCollapsible('Register', 'register-section-body')}
`; } export function selectRegisterAccount(accountId) { registerAccountId = accountId; const sel = document.getElementById('reg-account'); if (sel) sel.value = accountId; loadRegister(); } export async function loadRegister() { const sel = document.getElementById('reg-account'); const start = document.getElementById('reg-start'); const end = document.getElementById('reg-end'); if (!sel || !sel.value) { const slot = document.getElementById('accounting-register-table'); if (slot) slot.innerHTML = `

Select an account to view the register.

`; return; } registerAccountId = sel.value; registerStartDate = start.value; registerEndDate = end.value; const slot = 'accounting-register-table'; showLoading(slot, 'Loading register from QBO…'); const mySeq = ++registerLoadSeq; try { const result = await window.API.accounting.getRegister(registerAccountId, registerStartDate, registerEndDate); if (mySeq !== registerLoadSeq) return; if (result.error) return showError(slot, result.error); renderRegisterTable(result); } catch (err) { if (mySeq !== registerLoadSeq) return; console.error('Register load failed:', err); showError(slot, err.message || 'Failed to load register'); } } function renderRegisterTable(result) { const el = document.getElementById('accounting-register-table'); if (!el) return; let rows = (result.rows || []).slice().sort((a, b) => (b.date || '').localeCompare(a.date || '')); const meta = result.meta || {}; if (!rows.length) { el.innerHTML = `
No transactions in selected range.
`; return; } const tbody = rows.map(renderRegisterRow).join(''); el.innerHTML = `
${escapeHtml(meta.reportName || 'Transaction List')} ${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''} ${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
${rows.length} ${rows.length === 1 ? 'transaction' : 'transactions'}
${tbody}
Date Type No. Payee Split / Category Memo Amount
`; } function renderRegisterRow(r) { const isSplit = r.splitAccount === '-Split-'; const splitContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || ''); return ` ${escapeHtml(r.date || '')} ${escapeHtml(r.type || '')} ${escapeHtml(r.docNum || '')} ${escapeHtml(r.payee || '')} ${splitContent} ${escapeHtml(r.memo || '')} ${r.amount != null ? fmtMoney(r.amount) : ''} `; } function renderSplitCell(r) { if (!r.splits || !r.splits.length) { const type = (r.type || '').toLowerCase(); if (type.includes('tax payment')) { return `-Split- (Sales Tax)`; } if (type.includes('paycheck') || type.includes('payroll')) { return `-Split- (Payroll)`; } return `-Split-`; } const lines = r.splits.map(s => `
${escapeHtml(s.account || '?')} ${s.amount != null ? fmtMoney(s.amount) : ''}
`).join(''); return `
${lines}
`; } // ──────────────────────────────────────────────────────────────────── // Reports // ──────────────────────────────────────────────────────────────────── export function injectReportsControls() { const c = document.getElementById('accounting-reports'); if (!c) return; if (!plStartDate) plStartDate = firstOfYearISO(); if (!plEndDate) plEndDate = todayISO(); if (!bsAsOfDate) bsAsOfDate = todayISO(); if (!tsMonth) tsMonth = prevMonthISO(); c.innerHTML = ` ${makeCollapsible('Reports', 'reports-section-body')}

Profit & Loss

Balance Sheet

Sales Tax (QBO)

`; } 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); 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'); } } 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); 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'); } } export async function loadTaxSummary() { tsMonth = document.getElementById('ts-month').value; tsAccountingMethod = document.getElementById('ts-method').value; if (!tsMonth) return showError('ts-result', 'Please select a month.'); const [y, m] = tsMonth.split('-').map(Number); const startDate = firstOfMonthISO(y, m - 1); const endDate = lastOfMonthISO(y, m - 1); showLoading('ts-result', 'Loading Sales Tax Liability from QBO…'); try { const data = await window.API.accounting.getTaxSummary(startDate, endDate, tsAccountingMethod); if (data.error) return showError('ts-result', data.error); document.getElementById('ts-result').innerHTML = renderQboReport(data); } catch (err) { showError('ts-result', err.message || 'Failed to load Tax Summary'); } } function renderQboReport(report) { if (!report || !report.Header) return `

No report data.

`; const cols = (report.Columns && report.Columns.Column) || []; 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 || '')} ${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''} ${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
${headerRow}${body}
`; } 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 indent = depth * 16; if (row.Header && row.Header.ColData) { 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); if (row.Summary && row.Summary.ColData) { const cells = row.Summary.ColData.map((c, i) => i === 0 ? `${escapeHtml(c.value || '')}` : `${escapeHtml(c.value || '')}` ).join(''); html += `${cells}`; } if (!isSection && row.ColData) { 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 = ` ${makeCollapsible('Expenses', 'expenses-section-body')}
`; } 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 || ''); const editBtn = expOnlyMine ? `` : ``; return ` ${escapeHtml(e.txnDate || '')} ${escapeHtml(e.vendorName || '')} ${escapeHtml(e.accountName || '')} ${splitsHtml} ${escapeHtml(e.refNo || '')} ${escapeHtml(e.memo || '')} ${fmtMoney(e.totalAmt)} ${editBtn} `; }).join(''); el.innerHTML = `
${tbody}
Date Vendor Payment Account Category Ref Memo Amount Action
${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(); 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() { loadAccountsOverview(); if (registerAccountId) loadRegister(); } export async function editExpense(expenseJson) { let expense; try { expense = typeof expenseJson === 'string' ? JSON.parse(expenseJson) : expenseJson; } catch (e) { alert('Could not open expense for editing.'); return; } await openExpenseModal({ expense, onSaved: () => loadExpenses() }); } export async function openNewRefund() { await openRefundModal({ onSaved: (result) => { alert(`✅ Refund recorded: ${fmtMoney(result.totalAmt)} from ${result.vendorName}\nDeposit #${result.id} — booked to ${result.categoryName}`); loadExpenses(); } }); } window.accountingView = { renderAccountingView, refreshAll, manualSync, loadAccountsOverview, loadRegister, loadProfitLoss, loadBalanceSheet, loadTaxSummary, loadExpenses, openNewExpense, openNewRefund, editExpense, selectRegisterAccount, toggleSection };