From 0218fa694e159cfb30f9367f5b1d08ec4fa1720d Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Fri, 7 Aug 2026 12:46:36 -0500 Subject: [PATCH] fix sales tax --- public/js/views/salestax-view.js | 84 +++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/public/js/views/salestax-view.js b/public/js/views/salestax-view.js index ceab738..ac7d24b 100644 --- a/public/js/views/salestax-view.js +++ b/public/js/views/salestax-view.js @@ -11,6 +11,11 @@ * 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'; @@ -175,17 +180,33 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { } showLoading('sales-tax-detail', 'Loading tax summary from QBO…'); - let taxData; + let taxData = null; try { taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual'); } catch (e) { - showError('sales-tax-detail', e.message || 'Failed to load tax summary'); - return; + // 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); } - const reportHtml = taxData?.Rows ? renderQboReport(taxData) : `

No tax data for this period.

`; + // 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.

` + : ''; - const parsed = parseTaxDataFromReport(taxData); stCurrentTaxData = { ...parsed, startDate, endDate }; const taxCollected = parsed.taxCollected; const totalSales = parsed.totalSales; @@ -210,10 +231,11 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">× ${reportHtml} + ${snapshotNote}
- Tax Collected: ${fmtMoney(taxCollected)} + Tax Collected: ${fmtMoney(taxCollected)}${useSnapshot ? ' (saved)' : ''} Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} Net Due: ${fmtMoney(netPaid)}
@@ -400,6 +422,56 @@ function parseTaxDataFromReport(taxData) { 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 // ────────────────────────────────────────────────────────────────────