/** * 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); // Aufschlüsselung nur, wenn sie beim Speichern festgehalten wurde. Für // Altperioden bleibt es wie bisher bei den Aggregaten — hier wird bewusst // NICHT live nachgeladen. const paidSnapshot = snapshotFromPeriod(existingPeriod, EMPTY_TAX_DATA); const paidBreakdownHtml = paidSnapshot.districts.length ? `
${renderQboReport(reportFromSnapshot(paidSnapshot.districts, paidSnapshot, existingPeriod.period_start, existingPeriod.period_end))}

District rows and grand total both come from the saved snapshot for this period.

` : ''; detailEl.innerHTML = `

${mLabel} — Sales Tax Detail (Paid)

✅ Paid on ${paidOn}

${paidBreakdownHtml} ${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. Neue Periode → live. const liveParsed = parseTaxDataFromReport(taxData); const useSnapshot = !!existingPeriod; const parsed = useSnapshot ? snapshotFromPeriod(existingPeriod, liveParsed) : liveParsed; const hasSavedBreakdown = useSnapshot && parsed.districts.length > 0; const { html: reportHtml, note: snapshotNote } = renderPeriodBreakdown({ taxData, parsed, startDate, endDate, existingPeriod, useSnapshot, hasSavedBreakdown }); 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${hasSavedBreakdown ? '' : ' — authoritative'})` : ''} 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)}

`; } } /** * Breakdown, der beim Speichern mitgeht — oder null. * * null bedeutet für den Server "nicht anfassen" (COALESCE im UPSERT). Bei einer * Altperiode ohne gespeicherten Breakdown ist stCurrentTaxData.districts leer, * weil snapshotFromPeriod() bewusst nicht auf die Live-Zeilen zurückfällt — ein * erneutes Speichern befüllt die Spalte also NICHT nachträglich mit Live-Daten. */ function breakdownForSave() { const districts = stCurrentTaxData?.districts; return Array.isArray(districts) && districts.length ? districts : null; } 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, district_breakdown: breakdownForSave(), 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, district_breakdown: breakdownForSave(), 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); } } /** * Entscheidet, welche Aufschlüsselung eine Periode zeigt: * 1. gespeicherter Breakdown → Zeilen und Grand Total aus derselben DB-Zeile * 2. gespeichert, kein Breakdown (Altperiode) → Live-Zeilen, als indikativ markiert * 3. neue Periode → Live-Report, live ist hier maßgeblich */ function renderPeriodBreakdown({ taxData, parsed, startDate, endDate, existingPeriod, useSnapshot, hasSavedBreakdown }) { const savedOn = existingPeriod?.updated_at ? ` (saved ${formatDate(existingPeriod.updated_at)})` : ''; if (hasSavedBreakdown) { return { html: renderQboReport(reportFromSnapshot(parsed.districts, parsed, startDate, endDate)), note: `

District rows and grand total both come from the saved snapshot for this period${savedOn}.

` }; } if (!useSnapshot) { return { html: taxData?.Rows ? renderQboReport(taxData) : `

No tax data for this period.

`, note: '' }; } // Altperiode: kein Snapshot der Aufschlüsselung vorhanden und keiner mehr // rekonstruierbar. Live-Zeilen optisch untergeordnet und explizit entwertet. const liveTable = taxData?.Rows ? `
${renderQboReport(markLiveGrandTotalAsIndicative(taxData))}
` : `

No live tax data for this period.

`; return { html: `

Indicative live breakdown — not the filed figures

No district breakdown was stored for this period${savedOn ? savedOn.replace(' (saved', ' (recorded') : ''}. The rows below are recalculated live from QuickBooks and will not add up to the grand total. The saved grand total below is the authoritative figure.

${liveTable}`, note: '' }; } const EMPTY_TAX_DATA = { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0, districts: [] }; function isGrandTotalSection(row) { return row?.type === 'Section' && String(row.Header?.ColData?.[0]?.value || '').toLowerCase().includes('grand total'); } /** * Liest die GRAND-TOTAL-Aggregate UND die Distrikt-Sections aus der * getTaxSummary()-Antwort. Beide stammen aus demselben Belegsatz * (accounting-service.js:438-494), sind also in sich konsistent. */ function parseTaxDataFromReport(taxData) { const rows = Array.isArray(taxData?.Rows?.Row) ? taxData.Rows.Row : null; if (!rows) return { ...EMPTY_TAX_DATA }; const districts = rows .filter(row => row?.type === 'Section' && !isGrandTotalSection(row)) .map(row => { const cd = row.Summary?.ColData || []; return { name: row.Header?.ColData?.[0]?.value || '', ratePct: row.Header?.ColData?.[5]?.value || cd[5]?.value || '', totalSales: numOr(cd[1]?.value, 0), nontaxableSales: numOr(cd[2]?.value, 0), taxableSales: numOr(cd[3]?.value, 0), taxCollected: numOr(cd[4]?.value, 0) }; }); const grand = rows.find(isGrandTotalSection); if (!grand) return { ...EMPTY_TAX_DATA, districts }; const cd = grand.Summary?.ColData || []; return { totalSales: numOr(cd[1]?.value, 0), nontaxableSales: numOr(cd[2]?.value, 0), taxableSales: numOr(cd[3]?.value, 0), taxCollected: numOr(cd[4]?.value, 0), districts }; } /** JSONB-Spalte: pg liefert bereits ein Objekt, ältere Pfade evtl. einen String. */ function parseBreakdownColumn(value) { if (!value) return null; let parsed = value; if (typeof value === 'string') { try { parsed = JSON.parse(value); } catch { return null; } } if (!Array.isArray(parsed) || !parsed.length) return null; return parsed.map(d => ({ name: d?.name || '', ratePct: d?.ratePct || '', totalSales: numOr(d?.totalSales, 0), nontaxableSales: numOr(d?.nontaxableSales, 0), taxableSales: numOr(d?.taxableSales, 0), taxCollected: numOr(d?.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). * * districts kommt AUSSCHLIESSLICH aus district_breakdown — nie aus dem Live-Abruf. * Sonst würde ein erneutes Speichern einer Altperiode eine live berechnete * Aufschlüsselung neben den abweichenden gespeicherten Grand Total schreiben. */ 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), districts: parseBreakdownColumn(period.district_breakdown) || [] }; } function round2(n) { return Math.round(n * 100) / 100; } /** Verkaufsspalten wie im Live-Report als ganze Dollar, damit beide Tabellen gleich aussehen. */ function salesCell(n) { return String(Math.round(n)); } const REPORT_COLUMNS = { Column: [ { ColTitle: '', ColType: 'Text' }, { ColTitle: 'Total Sales', ColType: 'Money' }, { ColTitle: 'Nontaxable', ColType: 'Money' }, { ColTitle: 'Taxable', ColType: 'Money' }, { ColTitle: 'Tax Collected', ColType: 'Money' }, { ColTitle: 'Tax Rate', ColType: 'Percent' } ] }; /** * Baut aus dem gespeicherten Snapshot eine Report-Struktur für renderQboReport(). * Distrikt-Zeilen und Grand Total stammen dann aus derselben DB-Zeile. * * Bleibt zwischen der Summe der Distriktbeträge und dem gespeicherten Grand Total * ein Cent-Rest stehen (die Distriktwerte wurden je Zeile einzeln gerundet, der * Grand Total einmal am Ende — accounting-service.js:461 vs. :491), wird er als * eigene Zeile ausgewiesen statt stillschweigend geschluckt. */ function reportFromSnapshot(districts, snapshot, startDate, endDate) { const rows = districts.map(d => ({ type: 'Section', Header: { ColData: [ { value: d.name }, { value: '' }, { value: '' }, { value: '' }, { value: '' }, { value: d.ratePct || '' } ] }, Summary: { ColData: [ { value: 'Total' }, { value: salesCell(d.totalSales) }, { value: salesCell(d.nontaxableSales) }, { value: salesCell(d.taxableSales) }, { value: d.taxCollected.toFixed(2) }, { value: d.ratePct || '' } ] } })); const districtSum = round2(districts.reduce((s, d) => s + d.taxCollected, 0)); const roundingRest = round2(snapshot.taxCollected - districtSum); if (roundingRest !== 0) { rows.push({ ColData: [ { value: 'Rounding difference' }, { value: '' }, { value: '' }, { value: '' }, { value: roundingRest.toFixed(2) }, { value: '' } ] }); } rows.push({ type: 'Section', Header: { ColData: [ { value: 'GRAND TOTAL' }, { value: '' }, { value: '' }, { value: '' }, { value: '' }, { value: '' } ] }, Summary: { ColData: [ { value: 'Total' }, { value: salesCell(snapshot.totalSales) }, { value: salesCell(snapshot.nontaxableSales) }, { value: salesCell(snapshot.taxableSales) }, { value: snapshot.taxCollected.toFixed(2) }, { value: '' } ] } }); return { Header: { ReportName: 'Sales Tax Liability (saved snapshot)', StartPeriod: String(startDate).split('T')[0], EndPeriod: String(endDate).split('T')[0], ReportBasis: 'Accrual' }, Columns: REPORT_COLUMNS, Rows: { Row: rows } }; } /** * Kennzeichnet die GRAND-TOTAL-Zeile des Live-Reports als indikativ und lässt ihre * Live-Werte stehen. Die Tabelle bleibt damit in sich konsistent (Live-Zeilen * summieren sich auf ihren eigenen Live-Total); der maßgebliche gespeicherte Betrag * steht separat darunter. Bewusst KEINE Ersetzung durch den Snapshot-Wert — das * würde suggerieren, die Live-Zeilen summierten sich darauf auf. * Arbeitet auf einer flachen Kopie — taxData wird nicht mutiert. */ function markLiveGrandTotalAsIndicative(taxData) { if (!Array.isArray(taxData?.Rows?.Row)) return taxData; const rows = taxData.Rows.Row.map(row => { if (!isGrandTotalSection(row)) return row; const cd = row.Summary?.ColData || []; return { ...row, Header: { ColData: [{ value: 'LIVE TOTAL (INDICATIVE)' }, ...(row.Header.ColData.slice(1))] }, Summary: { ColData: [{ value: 'Live total' }, ...cd.slice(1)] } }; }); 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 };