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];