/** * salestax-view.js — manuelle Sales-Tax-Verwaltung * * Aus accounting-view.js herausgelöst (vormals Sektion "Sales Tax" dort). * Logik unverändert übernommen; angepasst wurden ausschließlich: * - der onclick-Namespace: window.accountingView.X() → window.salesTaxView.X() * - Helper kommen jetzt aus ../utils/report-helpers.js statt modul-lokal * - der Sektions-Klapper makeCollapsible('Sales Tax', …) entfällt ersatzlos * * Hinweis: openTaxPeriodDetail() ruft API.accounting.getTaxSummary() auf — * dieselbe QBO-Schnittstelle, die der Report "Sales Tax (QBO)" auf dem * Reports-Tab nutzt. Die Abhängigkeit liegt auf API-Ebene, die Trennung der * beiden Oberflächen berührt sie nicht. * * Maßgeblichkeit der Beträge: Sobald eine Periode in sales_tax_periods steht, * ist der dort gespeicherte QBO-Snapshot (tax_collected & Co.) der angezeigte * Grundbetrag. Der Live-Abruf liefert dann nur noch die Distrikt-Aufschlüsselung. * Nur für eine noch nicht gespeicherte Periode kommen die Kennzahlen live. */ import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; import { fmtMoney, escapeHtml, showError, showLoading, todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, renderQboReport } from '../utils/report-helpers.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) // ──────────────────────────────────────────────────────────────────── let stPeriods = []; let stEditingPeriodId = null; // current period in detail dialog, null = new let stCurrentTaxData = null; // parsed data for the open detail dialog // ──────────────────────────────────────────────────────────────────── // Sales Tax Periods // ──────────────────────────────────────────────────────────────────── export function injectSalesTaxSection() { const c = document.getElementById('salestax-content'); if (!c) return; c.innerHTML = `

Period Overview

`; } 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 = `
No sales tax periods recorded yet. Click "+ New Period" to get started.
`; 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 ` ${monthLabel} ${statusHtml} ${fmtMoney(parseFloat(p.tax_collected) || 0)} ${adjStr} ${fmtMoney(netPaid)} ${paidOn} `; }).join(''); el.innerHTML = `
${rows}
Period Status Tax Amount Adjustment Net Due Paid On Actions
${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}
`; } 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 ? `−${fmtMoney(adj)}` : adj < 0 ? `+$${Math.abs(adj).toFixed(2)}` : '—'; const paidOn = formatDate(existingPeriod.booked_at); detailEl.innerHTML = `

${mLabel} — Sales Tax Detail (Paid)

✅ Paid on ${paidOn}

${existingPeriod.adjustment_reason ? `` : ''}
Total Sales${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}
Nontaxable Sales${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}
Taxable Sales${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}
Tax Collected${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}
Adjustment${adjStr}
Reason: ${escapeHtml(existingPeriod.adjustment_reason)}
Net Paid${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}
`; detailEl.classList.remove('hidden'); detailEl.scrollIntoView({ behavior: 'smooth' }); return; } showLoading('sales-tax-detail', 'Loading tax summary from QBO…'); let taxData = null; try { taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual'); } catch (e) { // Für eine gespeicherte Periode ist der Live-Abruf nur Beiwerk: die // Kennzahlen stehen in der DB-Zeile, also weiter mit dem Snapshot. if (!existingPeriod) { showError('sales-tax-detail', e.message || 'Failed to load tax summary'); return; } console.error('Tax summary fetch failed, showing saved snapshot:', e.message); } // Gespeicherte Periode → DB-Snapshot ist maßgeblich; der Live-Abruf liefert // nur die Distrikt-Zeilen. Neue Periode → live geladene Werte. const liveParsed = parseTaxDataFromReport(taxData); const useSnapshot = !!existingPeriod; const parsed = useSnapshot ? snapshotFromPeriod(existingPeriod, liveParsed) : liveParsed; const reportData = useSnapshot ? withSnapshotGrandTotal(taxData, parsed) : taxData; const reportHtml = reportData?.Rows ? renderQboReport(reportData) : `

No tax data for this period.

`; const snapshotNote = useSnapshot ? `

Grand total shown is the saved QBO snapshot for this period${existingPeriod.updated_at ? ` (last saved ${formatDate(existingPeriod.updated_at)})` : ''}. The district rows above are recalculated live and may differ in detail.

` : ''; 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 = `

${monthLabel} — Sales Tax Detail

${reportHtml} ${snapshotNote}
Tax Collected: ${fmtMoney(taxCollected)}${useSnapshot ? ' (saved)' : ''} Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} Net Due: ${fmtMoney(netPaid)}
${isEditable ? `

Positive = discount (reduces net due)

Summary:

Tax Collected: ${fmtMoney(taxCollected)}

${adjustments > 0 ? `

Discount: −${fmtMoney(adjustments)}

` : ''} ${adjustments < 0 ? `

Penalty: +$${Math.abs(adjustments).toFixed(2)}

` : ''}

Net Due: ${fmtMoney(netPaid)}

` : periodStatus === 'booked' ? `

✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}

` : `

✅ Paid on ${formatDate(existingPeriod.booked_at)}

`}
`; 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 ? `Adjustment: −${fmtMoney(adj)}` : adj < 0 ? `Adjustment: +$${Math.abs(adj).toFixed(2)}` : `Adjustment: —`; } const netDue = document.getElementById('st-net-due'); if (netDue) { netDue.innerHTML = `Net Due: ${fmtMoney(netPaid)}`; } const jeLines = document.getElementById('st-je-lines'); if (jeLines) { jeLines.innerHTML = `

Summary:

Tax Collected: ${fmtMoney(taxCollected)}

${adj > 0 ? `

Discount: −${fmtMoney(adj)}

` : ''} ${adj < 0 ? `

Penalty: +$${Math.abs(adj).toFixed(2)}

` : ''}

Net Due: ${fmtMoney(netPaid)}

`; } } 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 }; } /** numeric aus der DB (pg liefert NUMERIC als String) — sonst Fallback. */ function numOr(value, fallback) { if (value == null || value === '') return fallback; const n = parseFloat(value); return Number.isFinite(n) ? n : fallback; } /** * Kennzahlen einer gespeicherten Periode aus der DB-Zeile. * Fällt spaltenweise auf den Live-Wert zurück, falls die Spalte leer ist * (Altbestände ohne vollständigen Snapshot). */ function snapshotFromPeriod(period, fallback) { return { totalSales: numOr(period.total_sales, fallback.totalSales), nontaxableSales: numOr(period.nontaxable_sales, fallback.nontaxableSales), taxableSales: numOr(period.taxable_sales, fallback.taxableSales), taxCollected: numOr(period.tax_collected, fallback.taxCollected) }; } /** * Ersetzt im live geladenen Report die GRAND-TOTAL-Zeile durch den gespeicherten * Snapshot, damit die Tabelle nicht einen anderen Gesamtbetrag zeigt als die * Summary-Leiste darunter. Die Distrikt-Zeilen bleiben unberührt (Aufschlüsselung). * Arbeitet auf einer flachen Kopie — taxData selbst wird nicht mutiert. */ function withSnapshotGrandTotal(taxData, snapshot) { if (!taxData?.Rows?.Row || !Array.isArray(taxData.Rows.Row)) return taxData; const rows = taxData.Rows.Row.map(row => { const label = row?.Header?.ColData?.[0]?.value; if (row?.type !== 'Section' || !label || !label.toLowerCase().includes('grand total')) return row; const cd = row.Summary?.ColData || []; return { ...row, Summary: { ColData: [ { value: cd[0]?.value || 'Total' }, { value: snapshot.totalSales.toFixed(2) }, { value: snapshot.nontaxableSales.toFixed(2) }, { value: snapshot.taxableSales.toFixed(2) }, { value: snapshot.taxCollected.toFixed(2) }, { value: cd[5]?.value || '' } ] } }; }); return { ...taxData, Rows: { ...taxData.Rows, Row: rows } }; } // ──────────────────────────────────────────────────────────────────── // Init / Public Entry Point // ──────────────────────────────────────────────────────────────────── export function renderSalesTaxView() { injectSalesTaxSection(); loadTaxPeriods(); } window.salesTaxView = { renderSalesTaxView, injectSalesTaxSection, loadTaxPeriods, openNewTaxPeriod, openTaxPeriod, closeTaxPeriodDetail, updateTaxPreview, saveTaxPeriodDraft, markPeriodPaid };