Files
invoice-system/public/js/views/accounting-view.js
2026-06-12 10:03:37 -05:00

1285 lines
67 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 crStartDate = null;
let crEndDate = null;
let stPeriods = [];
let stEditingPeriodId = null; // current period in detail dialog, null = new
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showError(slotId, message) {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">QBO Error</p>
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
</div>`;
}
function showLoading(slotId, message = 'Loading…') {
const el = document.getElementById(slotId);
if (!el) return;
el.innerHTML = `
<div class="flex items-center gap-3 p-4 text-gray-500">
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>${escapeHtml(message)}</span>
</div>`;
}
function makeCollapsible(headerText, contentId, startCollapsed = false) {
return `
<div class="flex items-center gap-2 cursor-pointer select-none mb-2"
onclick="window.accountingView.toggleSection('${contentId}', this)">
<svg class="w-4 h-4 text-gray-500 transition-transform ${startCollapsed ? '' : 'rotate-90'}"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<h3 class="text-base font-semibold text-gray-800">${escapeHtml(headerText)}</h3>
</div>`;
}
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 = `
<div class="flex items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">Accounting</h2>
<span class="text-sm text-gray-400">read-only registers · expense entry</span>
<div class="ml-auto flex items-center gap-2">
<span id="accounting-sync-status" class="text-xs text-gray-500"></span>
<button onclick="window.accountingView.manualSync()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
🔄 Sync from QBO
</button>
<button onclick="window.accountingView.refreshAll()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
↻ Refresh
</button>
</div>
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// 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 = `<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600">No bank or credit card accounts found.</div>`;
} else {
el.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${cards.map(renderAccountCard).join('')}</div>`;
}
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 `
<div class="rounded-lg border ${accent} p-4 cursor-pointer hover:shadow-md transition"
onclick="window.accountingView.selectRegisterAccount('${a.id}')">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold uppercase tracking-wide ${labelColor}">${label}</span>
<span class="text-xs text-gray-400">#${a.id}</span>
</div>
<div class="text-base font-semibold text-gray-900 mb-2">${escapeHtml(a.name)}</div>
<div class="text-2xl font-bold text-gray-900">${balText}</div>
${a.accountSubType ? `<div class="text-xs text-gray-500 mt-1">${escapeHtml(a.accountSubType)}</div>` : ''}
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Register
// ────────────────────────────────────────────────────────────────────
function populateRegisterAccountDropdown(bankCardAccounts) {
const sel = document.getElementById('reg-account');
if (!sel) return;
const current = registerAccountId || sel.value;
sel.innerHTML = `<option value="">— Select account —</option>` +
bankCardAccounts.map(a => `<option value="${a.id}">${escapeHtml(a.name)} (${a.accountType})</option>`).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')}
<div id="register-section-body">
<div class="flex flex-wrap items-end gap-3 mb-3 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Account</label>
<select id="reg-account" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-72">
<option value="">— Loading accounts —</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Date</label>
<input type="date" id="reg-start" value="${registerStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Date</label>
<input type="date" id="reg-end" value="${registerEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<button onclick="window.accountingView.loadRegister()"
class="px-4 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
Load Register
</button>
</div>
<div id="accounting-register-table"></div>
</div>`;
}
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 = `<p class="text-sm text-gray-500 px-4 py-3">Select an account to view the register.</p>`;
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 = `<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600">No transactions in selected range.</div>`;
return;
}
const tbody = rows.map(renderRegisterRow).join('');
el.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b bg-gray-50 flex items-center justify-between">
<div class="text-xs text-gray-500">
${escapeHtml(meta.reportName || 'Transaction List')}
${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''}
${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
</div>
<div class="text-sm font-semibold text-gray-700">
${rows.length} ${rows.length === 1 ? 'transaction' : 'transactions'}
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Type</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">No.</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Payee</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Split / Category</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Memo</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Amount</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
</div>
</div>`;
}
function renderRegisterRow(r) {
const isSplit = r.splitAccount === '-Split-';
const splitContent = isSplit ? renderSplitCell(r) : escapeHtml(r.splitAccount || '');
return `
<tr class="border-t hover:bg-gray-50 align-top">
<td class="px-3 py-2 text-sm whitespace-nowrap">${escapeHtml(r.date || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.type || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.docNum || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(r.payee || '')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${splitContent}</td>
<td class="px-3 py-2 text-sm text-gray-500">${escapeHtml(r.memo || '')}</td>
<td class="px-3 py-2 text-sm text-right whitespace-nowrap ${r.amount < 0 ? 'text-red-600' : 'text-gray-900'}">
${r.amount != null ? fmtMoney(r.amount) : ''}
</td>
</tr>`;
}
function renderSplitCell(r) {
if (!r.splits || !r.splits.length) {
const type = (r.type || '').toLowerCase();
if (type.includes('tax payment')) {
return `<span class="text-gray-500 italic" title="Sales Tax remittance — see Memo for period">-Split- (Sales Tax)</span>`;
}
if (type.includes('paycheck') || type.includes('payroll')) {
return `<span class="text-gray-500 italic" title="Payroll transaction">-Split- (Payroll)</span>`;
}
return `<span class="text-gray-500 italic">-Split-</span>`;
}
const lines = r.splits.map(s => `
<div class="flex justify-between gap-3 text-xs">
<span class="text-gray-700">${escapeHtml(s.account || '?')}</span>
<span class="text-gray-600 whitespace-nowrap">${s.amount != null ? fmtMoney(s.amount) : ''}</span>
</div>`).join('');
return `<div class="space-y-0.5">${lines}</div>`;
}
// ────────────────────────────────────────────────────────────────────
// 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();
if (!crStartDate) crStartDate = firstOfYearISO();
if (!crEndDate) crEndDate = todayISO();
c.innerHTML = `
${makeCollapsible('Reports', 'reports-section-body')}
<div id="reports-section-body">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Profit &amp; Loss</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="pl-start" value="${plStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
<input type="date" id="pl-end" value="${plEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
<select id="pl-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${plAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadProfitLoss()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportProfitLossPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="pl-result"></div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Balance Sheet</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
<input type="date" id="bs-asof" value="${bsAsOfDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
<select id="bs-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${bsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadBalanceSheet()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportBalanceSheetPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="bs-result"></div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Sales Tax (QBO)</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Month</label>
<input type="month" id="ts-month" value="${tsMonth}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
<select id="ts-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="Accrual" ${tsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
<option value="Cash" ${tsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
</select></div>
<button onclick="window.accountingView.loadTaxSummary()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportTaxSummaryPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
</div>
<div id="ts-result"></div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Customer Revenue</h3></div>
<div class="p-4">
<div class="flex flex-wrap items-end gap-3 mb-3">
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="cr-start" value="${crStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
<input type="date" id="cr-end" value="${crEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
<button onclick="window.accountingView.loadCustomerRevenue()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
<button onclick="window.accountingView.exportCustomerRevenuePdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 Export PDF</button>
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" id="cr-anonymize" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
Anonymize
</label>
</div>
<div id="cr-result"></div>
</div>
</div>
</div>
</div>`;
}
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'); }
}
export async function loadCustomerRevenue() {
crStartDate = document.getElementById('cr-start').value;
crEndDate = document.getElementById('cr-end').value;
const anonymize = document.getElementById('cr-anonymize')?.checked || false;
if (!crStartDate || !crEndDate) return showError('cr-result', 'Please select both start and end dates.');
showLoading('cr-result', 'Loading customer revenue...');
const maskName = (name) => anonymize ? name.charAt(0) : name;
try {
const data = await window.API.accounting.getCustomerRevenue(crStartDate, crEndDate);
if (data.error) return showError('cr-result', data.error);
if (!data.length) {
document.getElementById('cr-result').innerHTML = '<p class="text-sm text-gray-500">No invoices found in this period.</p>';
return;
}
const grandTotal = parseFloat(data[0].grand_total) || 0;
const totalInvoices = data.reduce((s, r) => s + parseInt(r.invoice_count), 0);
let rowsHtml = '';
let rank = 0;
for (const r of data) {
rank++;
const rev = parseFloat(r.total_revenue) || 0;
const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0';
rowsHtml += `<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium">${rank}. ${escapeHtml(maskName(r.customer_name))}</td>
<td class="px-3 py-2 text-sm text-center">${r.invoice_count}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(rev)}</td>
<td class="px-3 py-2 text-sm text-right text-gray-500">${pct}%</td>
</tr>`;
}
rowsHtml += `<tr class="border-t bg-gray-50 font-semibold">
<td class="px-3 py-2 text-sm">TOTAL (${data.length} customers)</td>
<td class="px-3 py-2 text-sm text-center">${totalInvoices}</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(grandTotal)}</td>
<td class="px-3 py-2 text-sm text-right">100.0%</td>
</tr>`;
document.getElementById('cr-result').innerHTML = `
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Revenue (net)</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">% of Total</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
} catch (err) { showError('cr-result', err.message || 'Failed to load revenue report'); }
}
export function exportCustomerRevenuePdf() {
const startEl = document.getElementById('cr-start');
const endEl = document.getElementById('cr-end');
const anonymize = document.getElementById('cr-anonymize')?.checked || false;
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
let url = `/api/accounting/reports/customer-revenue/pdf?startDate=${startEl.value}&endDate=${endEl.value}`;
if (anonymize) url += '&anonymize=true';
window.open(url, '_blank');
}
export function exportProfitLossPdf() {
const startEl = document.getElementById('pl-start');
const endEl = document.getElementById('pl-end');
const method = document.getElementById('pl-method')?.value || 'Accrual';
if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.');
window.open(`/api/accounting/reports/profit-loss/pdf?startDate=${startEl.value}&endDate=${endEl.value}&accountingMethod=${method}`, '_blank');
}
export function exportBalanceSheetPdf() {
const asOf = document.getElementById('bs-asof');
const method = document.getElementById('bs-method')?.value || 'Accrual';
if (!asOf?.value) return alert('Please select an as-of date first.');
window.open(`/api/accounting/reports/balance-sheet/pdf?asOfDate=${asOf.value}&accountingMethod=${method}`, '_blank');
}
export function exportTaxSummaryPdf() {
const monthEl = document.getElementById('ts-month');
const method = document.getElementById('ts-method')?.value || 'Accrual';
if (!monthEl?.value) return alert('Please select a month first.');
const [y, m] = monthEl.value.split('-').map(Number);
const startDate = firstOfMonthISO(y, m - 1);
const endDate = lastOfMonthISO(y, m - 1);
window.open(`/api/accounting/reports/tax-summary/pdf?startDate=${startDate}&endDate=${endDate}&accountingMethod=${method}`, '_blank');
}
function renderQboReport(report) {
if (!report || !report.Header) return `<p class="text-sm text-gray-500">No report data.</p>`;
const cols = (report.Columns && report.Columns.Column) || [];
const headerRow = cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 first:text-left">${escapeHtml(c.ColTitle || '')}</th>`).join('');
const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : '';
return `
<div class="text-xs text-gray-500 mb-2">
${escapeHtml(report.Header.ReportName || '')}
${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' ' + escapeHtml(report.Header.EndPeriod) : ''}
${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
</div>
<div class="overflow-x-auto border border-gray-200 rounded">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 border-b"><tr>${headerRow}</tr></thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
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
? `<td class="px-3 py-1.5 font-semibold text-gray-800" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right text-gray-500"></td>`
).join('');
html += `<tr class="bg-gray-50">${cells}</tr>`;
}
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
? `<td class="px-3 py-1.5 font-semibold text-gray-700 border-t" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right font-semibold text-gray-900 border-t">${escapeHtml(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
if (!isSection && row.ColData) {
const cells = row.ColData.map((c, i) =>
i === 0
? `<td class="px-3 py-1.5" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
: `<td class="px-3 py-1.5 text-right">${escapeHtml(c.value || '')}</td>`
).join('');
html += `<tr>${cells}</tr>`;
}
}
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')}
<div id="expenses-section-body">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
<input type="date" id="exp-start" value="${expStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End</label>
<input type="date" id="exp-end" value="${expEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div class="flex items-center gap-2 pt-5">
<input type="checkbox" id="exp-only-mine" ${expOnlyMine ? 'checked' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="exp-only-mine" class="text-sm text-gray-700" title="Only show expenses created from this app">Only mine</label>
</div>
<button onclick="window.accountingView.loadExpenses()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
Load
</button>
<div class="ml-auto flex gap-2">
<button onclick="window.accountingView.openNewRefund()"
class="px-4 py-1.5 bg-amber-600 text-white rounded-md text-sm font-semibold hover:bg-amber-700">
↩️ Record Refund
</button>
<button onclick="window.accountingView.openNewExpense()"
class="px-4 py-1.5 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
+ New Expense
</button>
</div>
</div>
<div id="accounting-expenses-table"></div>
</div>
</div>`;
}
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 = `<div class="p-4 text-gray-500 text-sm">No expenses in selected range${expOnlyMine ? ' (created from this app)' : ''}.</div>`;
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
? `<details><summary class="cursor-pointer text-blue-600 text-xs">${e.lines.length} lines</summary>
<div class="mt-1 space-y-0.5">
${e.lines.map(l => `
<div class="flex justify-between gap-3 text-xs">
<span class="text-gray-700">${escapeHtml(l.accountName || '?')}</span>
<span class="text-gray-600 whitespace-nowrap">${l.amount != null ? fmtMoney(l.amount) : ''}</span>
</div>`).join('')}
</div>
</details>`
: escapeHtml(e.lines[0]?.accountName || '');
const editBtn = expOnlyMine
? `<button onclick='window.accountingView.editExpense(${JSON.stringify(JSON.stringify(e))})'
class="text-blue-600 hover:text-blue-800 text-xs font-medium">Edit</button>`
: `<span class="text-gray-300 text-xs" title="Only expenses created from this app can be edited">—</span>`;
return `
<tr class="border-t hover:bg-gray-50 align-top">
<td class="px-3 py-2 text-sm whitespace-nowrap">${escapeHtml(e.txnDate || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.vendorName || '')}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.accountName || '')}</td>
<td class="px-3 py-2 text-sm text-gray-600">${splitsHtml}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(e.refNo || '')}</td>
<td class="px-3 py-2 text-sm text-gray-500">${escapeHtml(e.memo || '')}</td>
<td class="px-3 py-2 text-sm text-right whitespace-nowrap text-red-600">${fmtMoney(e.totalAmt)}</td>
<td class="px-3 py-2 text-sm text-center">${editBtn}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Date</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Vendor</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Payment Account</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Category</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Ref</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Memo</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Amount</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Action</th>
</tr>
</thead>
<tbody>${tbody}</tbody>
</table>
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${sorted.length} expense${sorted.length === 1 ? '' : 's'}</div>
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Sales Tax Periods
// ────────────────────────────────────────────────────────────────────
export function injectSalesTaxSection() {
const c = document.getElementById('accounting-sales-tax');
if (!c) return;
c.innerHTML = `
${makeCollapsible('Sales Tax', 'sales-tax-section-body')}
<div id="sales-tax-section-body">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-center gap-3">
<h3 class="font-semibold text-gray-800 text-sm">Period Overview</h3>
<div class="ml-auto flex gap-2">
<button onclick="window.accountingView.openNewTaxPeriod()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
+ New Period
</button>
</div>
</div>
<div id="sales-tax-periods-table"></div>
</div>
<div id="sales-tax-detail" class="mt-4 hidden"></div>
</div>`;
}
export async function loadTaxPeriods() {
try {
stPeriods = await window.API.accounting.getTaxPeriods() || [];
} catch (e) {
stPeriods = [];
console.error('Failed to load tax periods:', e.message);
}
renderTaxPeriodsTable();
}
function renderTaxPeriodsTable() {
const el = document.getElementById('sales-tax-periods-table');
if (!el) return;
if (!stPeriods.length) {
el.innerHTML = `<div class="p-4 text-gray-500 text-sm">No sales tax periods recorded yet. Click "+ New Period" to get started.</div>`;
return;
}
const rows = stPeriods.map(p => {
const pStatus = p.status || (p.qbo_journal_entry_id ? 'booked' : 'open');
let statusHtml, statusColor;
if (pStatus === 'booked') { statusHtml = 'Booked'; statusColor = 'bg-green-100 text-green-800'; }
else if (pStatus === 'external' || pStatus === 'paid') { statusHtml = 'Paid'; statusColor = 'bg-green-100 text-green-800'; }
else { statusHtml = 'Open'; statusColor = 'bg-yellow-100 text-yellow-800'; }
const [py, pm] = String(p.period_start).split('T')[0].split('-');
const monthLabel = new Date(Number(py), Number(pm) - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' });
const adj = parseFloat(p.adjustment_amount) || 0;
const adjStr = adj !== 0 ? (adj > 0 ? `$${adj.toFixed(2)}` : `+$${Math.abs(adj).toFixed(2)}`) : '—';
const netPaid = parseFloat(p.net_paid) || parseFloat(p.tax_collected) || 0;
const paidOn = (pStatus === 'external' || pStatus === 'paid' || pStatus === 'booked')
? (p.booked_at ? formatDate(p.booked_at) : '—') : '—';
return `
<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium text-gray-800">${monthLabel}</td>
<td class="px-3 py-2 text-sm">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${statusColor}">${statusHtml}</span>
</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(parseFloat(p.tax_collected) || 0)}</td>
<td class="px-3 py-2 text-sm text-right">${adjStr}</td>
<td class="px-3 py-2 text-sm text-right font-semibold">${fmtMoney(netPaid)}</td>
<td class="px-3 py-2 text-sm text-gray-500">${paidOn}</td>
<td class="px-3 py-2 text-sm text-center">
<button onclick="window.accountingView.openTaxPeriod(${p.id})"
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View summary</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Period</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Status</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax Amount</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Adjustment</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Net Due</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Paid On</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}</div>
</div>`;
}
export async function openNewTaxPeriod() {
const [y, m] = prevMonthISO().split('-').map(Number);
const startDate = firstOfMonthISO(y, m - 1);
const endDate = lastOfMonthISO(y, m - 1);
await openTaxPeriodDetail(startDate, endDate, null);
}
export async function openTaxPeriod(periodId) {
const period = stPeriods.find(p => p.id === periodId);
if (!period) return alert('Period not found.');
await openTaxPeriodDetail(period.period_start, period.period_end, period);
}
async function openTaxPeriodDetail(startDate, endDate, existingPeriod) {
const detailEl = document.getElementById('sales-tax-detail');
if (!detailEl) return;
stEditingPeriodId = existingPeriod ? existingPeriod.id : null;
if (existingPeriod && existingPeriod.status === 'paid') {
const [py, pm] = String(existingPeriod.period_start).split('T')[0].split('-');
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const mLabel = MONTHS[Number(pm) - 1] + ' ' + py;
const adj = parseFloat(existingPeriod.adjustment_amount) || 0;
const adjStr = adj > 0 ? `\u2212${fmtMoney(adj)}` : adj < 0 ? `+$${Math.abs(adj).toFixed(2)}` : '\u2014';
const paidOn = formatDate(existingPeriod.booked_at);
detailEl.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">${mLabel} \u2014 Sales Tax Detail (Paid)</h3>
<button onclick="window.accountingView.closeTaxPeriodDetail()" class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<p class="text-sm text-green-700 font-semibold mb-4">\u2705 Paid on ${paidOn}</p>
<table class="min-w-full text-sm border border-gray-200 rounded">
<tbody>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Total Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Nontaxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Taxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Tax Collected</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Adjustment</td><td class="px-3 py-2 text-right font-medium ${adj !== 0 ? 'text-red-600' : ''}">${adjStr}</td></tr>
${existingPeriod.adjustment_reason ? `<tr class="border-t"><td class="px-3 py-2 text-gray-500 text-xs pl-8" colspan="2">Reason: ${escapeHtml(existingPeriod.adjustment_reason)}</td></tr>` : ''}
<tr class="border-t bg-gray-50 font-semibold"><td class="px-3 py-2">Net Paid</td><td class="px-3 py-2 text-right">${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}</td></tr>
</tbody>
</table>
</div>`;
detailEl.classList.remove('hidden');
detailEl.scrollIntoView({ behavior: 'smooth' });
return;
}
showLoading('sales-tax-detail', 'Loading tax summary from QBO…');
let taxData;
try {
taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual');
} catch (e) {
showError('sales-tax-detail', e.message || 'Failed to load tax summary');
return;
}
const reportHtml = taxData?.Rows ? renderQboReport(taxData) : `<p class="text-sm text-gray-500">No tax data for this period.</p>`;
const parsed = parseTaxDataFromReport(taxData);
stCurrentTaxData = { ...parsed, startDate, endDate };
const taxCollected = parsed.taxCollected;
const totalSales = parsed.totalSales;
const nontaxable = parsed.nontaxableSales;
const taxable = parsed.taxableSales;
const adjustments = existingPeriod?.adjustment_amount != null ? parseFloat(existingPeriod.adjustment_amount) || 0 : 0;
const adjReason = existingPeriod?.adjustment_reason || '';
const netPaid = taxCollected - adjustments;
const periodStatus = existingPeriod?.status || (existingPeriod?.qbo_journal_entry_id ? 'booked' : 'open');
const isOpen = periodStatus === 'open';
const isEditable = isOpen;
const today = todayISO();
const [y, m] = startDate.split('-').map(Number);
const monthLabel = new Date(y, m - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' });
detailEl.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">${monthLabel} — Sales Tax Detail</h3>
<button onclick="window.accountingView.closeTaxPeriodDetail()"
class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
${reportHtml}
<div class="mt-4 border-t pt-4">
<div class="flex items-center gap-4 text-sm mb-3" id="st-summary-bar">
<span><strong>Tax Collected:</strong> ${fmtMoney(taxCollected)}</span>
<span id="st-adj-display" class="${adjustments !== 0 ? 'text-red-600' : ''}"><strong>Adjustment:</strong> ${adjustments > 0 ? `${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'}</span>
<span id="st-net-due" class="text-lg font-bold text-gray-900"><strong>Net Due:</strong> ${fmtMoney(netPaid)}</span>
</div>
${isEditable ? `
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Adjustment Amount</label>
<input type="number" step="0.01" id="st-adjustment" value="${adjustments !== 0 ? adjustments : ''}" placeholder="e.g. 6.15 (discount)"
oninput="window.accountingView.updateTaxPreview()"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<p class="text-xs text-gray-400 mt-0.5">Positive = discount (reduces net due)</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Reason</label>
<input type="text" id="st-adjustment-reason" value="${escapeHtml(adjReason)}" placeholder="e.g. Timely filing discount"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Paid Date (QBO)</label>
<input type="date" id="st-paid-date" value="${today}"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
</div>
<div class="flex items-center justify-between border-t pt-3 mt-1">
<div id="st-je-lines">
<p class="text-sm"><strong>Summary:</strong></p>
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
${adjustments > 0 ? `<p class="text-xs text-gray-500">Discount: \u2212${fmtMoney(adjustments)}</p>` : ''}
${adjustments < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adjustments).toFixed(2)}</p>` : ''}
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>
</div>
<div class="flex gap-2">
<button onclick="window.accountingView.saveTaxPeriodDraft()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
💾 Save Draft
</button>
<button onclick="window.accountingView.markPeriodPaid()"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
✅ Mark as Paid
</button>
</div>
</div>
` : periodStatus === 'booked' ? `
<div class="border-t pt-3 mt-1">
<p class="text-sm text-green-700 font-semibold">✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}</p>
</div>
` : `
<div class="border-t pt-3 mt-1">
<p class="text-sm text-green-700 font-semibold">✅ Paid on ${formatDate(existingPeriod.booked_at)}</p>
</div>
`}
</div>
</div>`;
detailEl.classList.remove('hidden');
detailEl.scrollIntoView({ behavior: 'smooth' });
}
export function closeTaxPeriodDetail() {
const el = document.getElementById('sales-tax-detail');
if (el) el.classList.add('hidden');
stEditingPeriodId = null;
}
export function updateTaxPreview() {
if (!stCurrentTaxData) return;
const adjInput = document.getElementById('st-adjustment');
const adj = parseFloat(adjInput?.value) || 0;
const taxCollected = stCurrentTaxData.taxCollected;
const netPaid = taxCollected - adj;
const adjDisplay = document.getElementById('st-adj-display');
if (adjDisplay) {
adjDisplay.className = adj !== 0 ? 'text-red-600' : '';
adjDisplay.innerHTML = adj > 0
? `<strong>Adjustment:</strong> ${fmtMoney(adj)}`
: adj < 0
? `<strong>Adjustment:</strong> +$${Math.abs(adj).toFixed(2)}`
: `<strong>Adjustment:</strong> —`;
}
const netDue = document.getElementById('st-net-due');
if (netDue) {
netDue.innerHTML = `<strong>Net Due:</strong> ${fmtMoney(netPaid)}`;
}
const jeLines = document.getElementById('st-je-lines');
if (jeLines) {
jeLines.innerHTML = `
<p class="text-sm"><strong>Summary:</strong></p>
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
${adj > 0 ? `<p class="text-xs text-gray-500">Discount: \u2212${fmtMoney(adj)}</p>` : ''}
${adj < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adj).toFixed(2)}</p>` : ''}
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>`;
}
}
export async function saveTaxPeriodDraft() {
const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0;
const adjReason = document.getElementById('st-adjustment-reason').value.trim();
if (!stCurrentTaxData) return alert('No tax data loaded.');
try {
const period = await window.API.accounting.upsertTaxPeriod({
period_start: stCurrentTaxData.startDate,
period_end: stCurrentTaxData.endDate,
total_sales: stCurrentTaxData.totalSales,
nontaxable_sales: stCurrentTaxData.nontaxableSales,
taxable_sales: stCurrentTaxData.taxableSales,
tax_collected: stCurrentTaxData.taxCollected,
adjustment_amount: adjAmount,
adjustment_reason: adjReason || null,
net_paid: stCurrentTaxData.taxCollected - adjAmount,
status: 'open'
});
await loadTaxPeriods();
if (period?.period_start) {
await openTaxPeriodDetail(period.period_start, period.period_end, period);
}
} catch (e) {
alert('Failed to save: ' + e.message);
}
}
export async function markPeriodPaid() {
const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0;
const adjReason = document.getElementById('st-adjustment-reason').value.trim();
const paidDate = document.getElementById('st-paid-date').value;
if (!paidDate) return alert('Please select a paid date.');
if (!stCurrentTaxData) return alert('No tax data loaded.');
if (!stEditingPeriodId) return alert('Please save the period first (Save Draft).');
const netPaid = stCurrentTaxData.taxCollected - adjAmount;
if (!confirm(`Mark this period as paid?\n\nPaid Date: ${paidDate}\nTax Collected: $${stCurrentTaxData.taxCollected.toFixed(2)}\nAdjustment: $${adjAmount.toFixed(2)}\nNet Paid: $${netPaid.toFixed(2)}\n\nThis does NOT write to QBO — record the payment in QBO first.`)) return;
try {
await window.API.accounting.upsertTaxPeriod({
period_start: stCurrentTaxData.startDate,
period_end: stCurrentTaxData.endDate,
total_sales: stCurrentTaxData.totalSales,
nontaxable_sales: stCurrentTaxData.nontaxableSales,
taxable_sales: stCurrentTaxData.taxableSales,
tax_collected: stCurrentTaxData.taxCollected,
adjustment_amount: adjAmount,
adjustment_reason: adjReason || null,
net_paid: netPaid,
status: 'open'
});
} catch (e) {
return alert('Failed to save period: ' + e.message);
}
try {
await window.API.accounting.markTaxPaidExternal(stEditingPeriodId, paidDate);
await loadTaxPeriods();
const updated = stPeriods.find(p => p.id === stEditingPeriodId);
if (updated) {
await openTaxPeriodDetail(updated.period_start, updated.period_end, updated);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
function parseTaxDataFromReport(taxData) {
if (!taxData?.Rows?.Row) return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 };
const rows = Array.isArray(taxData.Rows.Row) ? taxData.Rows.Row : [];
for (const row of rows) {
if (row.type === 'Section' && row.Header?.ColData?.[0]?.value?.toLowerCase().includes('grand total')) {
const cd = row.Summary?.ColData || [];
return {
totalSales: parseFloat(cd[1]?.value || '0') || 0,
nontaxableSales: parseFloat(cd[2]?.value || '0') || 0,
taxableSales: parseFloat(cd[3]?.value || '0') || 0,
taxCollected: parseFloat(cd[4]?.value || '0') || 0
};
}
}
return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 };
}
let stCurrentTaxData = null; // parsed data for the open detail dialog
export async function openNewExpense() {
await openExpenseModal({
onSaved: () => loadExpenses()
});
}
// ────────────────────────────────────────────────────────────────────
// Init / Public Entry Points
// ────────────────────────────────────────────────────────────────────
export function renderAccountingView() {
autoSyncDoneThisOpen = false;
injectToolbar();
injectRegisterControls();
injectReportsControls();
injectExpensesSection();
injectSalesTaxSection();
loadTaxPeriods();
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,
injectSalesTaxSection,
loadTaxPeriods,
openNewTaxPeriod,
openTaxPeriod,
closeTaxPeriodDetail,
updateTaxPreview,
saveTaxPeriodDraft,
markPeriodPaid,
loadCustomerRevenue,
exportCustomerRevenuePdf,
exportProfitLossPdf,
exportBalanceSheetPdf,
exportTaxSummaryPdf
};