/**
* 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.
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
};