diff --git a/migrations/add-sales-tax-district-breakdown.sql b/migrations/add-sales-tax-district-breakdown.sql new file mode 100644 index 0000000..670bbae --- /dev/null +++ b/migrations/add-sales-tax-district-breakdown.sql @@ -0,0 +1,19 @@ +-- Speichert die Distrikt-Aufschlüsselung einer Sales-Tax-Periode zum Zeitpunkt +-- des Speicherns. Die Aufschlüsselung liegt in der getTaxSummary()-Antwort bereits +-- vor (accounting-service.js:438-466) und wurde bisher im Frontend verworfen. +-- +-- Rein additiv: die vier Aggregat-Spalten (total_sales, nontaxable_sales, +-- taxable_sales, tax_collected) und ihre Werte bleiben unangetastet. +-- +-- Bestandszeilen bleiben bewusst NULL. Sie werden NICHT rückwirkend per +-- Live-Abfrage befuellt — deren Datumsfenster weicht ab (Datums-Roundtrip in +-- accounting-service.js:393). Altperioden laufen dauerhaft über die als +-- indikativ gekennzeichnete Live-Anzeige. +-- +-- Idempotent: mehrfach ausführbar. + +ALTER TABLE sales_tax_periods + ADD COLUMN IF NOT EXISTS district_breakdown JSONB; + +COMMENT ON COLUMN sales_tax_periods.district_breakdown IS + 'Snapshot der Distrikt-Aufschlüsselung beim Speichern. NULL = kein Snapshot vorhanden (Altperiode), Anzeige faellt auf indikative Live-Werte zurueck.'; diff --git a/public/js/views/salestax-view.js b/public/js/views/salestax-view.js index ac7d24b..d68a54c 100644 --- a/public/js/views/salestax-view.js +++ b/public/js/views/salestax-view.js @@ -155,6 +155,17 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { 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 = `
@@ -162,6 +173,7 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) {

✅ Paid on ${paidOn}

+ ${paidBreakdownHtml} @@ -193,19 +205,15 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { 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. + // 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 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 { html: reportHtml, note: snapshotNote } = renderPeriodBreakdown({ + taxData, parsed, startDate, endDate, existingPeriod, useSnapshot, hasSavedBreakdown + }); stCurrentTaxData = { ...parsed, startDate, endDate }; const taxCollected = parsed.taxCollected; @@ -235,7 +243,7 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) {
- Tax Collected: ${fmtMoney(taxCollected)}${useSnapshot ? ' (saved)' : ''} + Tax Collected: ${fmtMoney(taxCollected)}${useSnapshot ? ` (saved${hasSavedBreakdown ? '' : ' — authoritative'})` : ''} Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} Net Due: ${fmtMoney(netPaid)}
@@ -335,6 +343,19 @@ export function updateTaxPreview() { } } +/** + * 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(); @@ -352,6 +373,7 @@ export async function saveTaxPeriodDraft() { adjustment_amount: adjAmount, adjustment_reason: adjReason || null, net_paid: stCurrentTaxData.taxCollected - adjAmount, + district_breakdown: breakdownForSave(), status: 'open' }); await loadTaxPeriods(); @@ -387,6 +409,7 @@ export async function markPeriodPaid() { adjustment_amount: adjAmount, adjustment_reason: adjReason || null, net_paid: netPaid, + district_breakdown: breakdownForSave(), status: 'open' }); } catch (e) { @@ -405,21 +428,109 @@ export async function markPeriodPaid() { } } +/** + * 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) { - 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 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 { - 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 + 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; } } - return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 }; + 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. */ @@ -433,40 +544,127 @@ function numOr(value, 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) + 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 } }; } /** - * 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. + * 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 withSnapshotGrandTotal(taxData, snapshot) { - if (!taxData?.Rows?.Row || !Array.isArray(taxData.Rows.Row)) return taxData; +function markLiveGrandTotalAsIndicative(taxData) { + if (!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; + if (!isGrandTotalSection(row)) 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 || '' } - ] - } + 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 } }; diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index 0b9d6f8..8b8f0df 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -534,8 +534,8 @@ async function upsertTaxPeriod(period) { (period_start, period_end, total_sales, nontaxable_sales, taxable_sales, tax_collected, adjustment_amount, adjustment_reason, adjustment_account_id, adjustment_account_name, net_paid, bank_account_id, bank_account_name, - sales_tax_payable_id, sales_tax_payable_name, status) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + sales_tax_payable_id, sales_tax_payable_name, status, district_breakdown) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT (period_start, period_end) DO UPDATE SET total_sales = EXCLUDED.total_sales, nontaxable_sales = EXCLUDED.nontaxable_sales, @@ -551,6 +551,9 @@ async function upsertTaxPeriod(period) { sales_tax_payable_id = COALESCE(sales_tax_periods.sales_tax_payable_id, EXCLUDED.sales_tax_payable_id), sales_tax_payable_name = COALESCE(sales_tax_periods.sales_tax_payable_name, EXCLUDED.sales_tax_payable_name), status = COALESCE(sales_tax_periods.status, EXCLUDED.status), + -- Additiv: ein bereits festgehaltener Breakdown wird nie durch NULL + -- ueberschrieben; Altperioden bleiben ohne Snapshot. + district_breakdown = COALESCE(EXCLUDED.district_breakdown, sales_tax_periods.district_breakdown), updated_at = CURRENT_TIMESTAMP RETURNING *`, [ @@ -560,7 +563,8 @@ async function upsertTaxPeriod(period) { period.adjustment_account_id || null, period.adjustment_account_name || null, period.net_paid || null, period.bank_account_id || null, period.bank_account_name || null, period.sales_tax_payable_id || null, period.sales_tax_payable_name || null, - period.status || 'open' + period.status || 'open', + period.district_breakdown ? JSON.stringify(period.district_breakdown) : null ] ); return result.rows[0];
Total Sales${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}