Files
invoice-system/public/js/views/salestax-view.js
2026-08-08 05:37:14 -05:00

693 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-center gap-3">
<h3 class="font-semibold text-gray-800 text-sm">Period Overview</h3>
<div class="ml-auto flex gap-2">
<button onclick="window.salesTaxView.openNewTaxPeriod()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
+ New Period
</button>
</div>
</div>
<div id="sales-tax-periods-table"></div>
</div>
<div id="sales-tax-detail" class="mt-4 hidden"></div>`;
}
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 = `<div class="p-4 text-gray-500 text-sm">No sales tax periods recorded yet. Click "+ New Period" to get started.</div>`;
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 `
<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium text-gray-800">${monthLabel}</td>
<td class="px-3 py-2 text-sm">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${statusColor}">${statusHtml}</span>
</td>
<td class="px-3 py-2 text-sm text-right">${fmtMoney(parseFloat(p.tax_collected) || 0)}</td>
<td class="px-3 py-2 text-sm text-right">${adjStr}</td>
<td class="px-3 py-2 text-sm text-right font-semibold">${fmtMoney(netPaid)}</td>
<td class="px-3 py-2 text-sm text-gray-500">${paidOn}</td>
<td class="px-3 py-2 text-sm text-center">
<button onclick="window.salesTaxView.openTaxPeriod(${p.id})"
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View summary</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700">Period</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Status</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax Amount</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Adjustment</th>
<th class="px-3 py-2 text-right font-medium text-gray-700">Net Due</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Paid On</th>
<th class="px-3 py-2 text-center font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}</div>
</div>`;
}
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
? `<div class="mb-4">
${renderQboReport(reportFromSnapshot(paidSnapshot.districts, paidSnapshot, existingPeriod.period_start, existingPeriod.period_end))}
<p class="mt-2 text-xs text-gray-500">District rows and grand total both come from the saved snapshot for this period.</p>
</div>`
: '';
detailEl.innerHTML = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">${mLabel} — Sales Tax Detail (Paid)</h3>
<button onclick="window.salesTaxView.closeTaxPeriodDetail()" class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<p class="text-sm text-green-700 font-semibold mb-4">✅ Paid on ${paidOn}</p>
${paidBreakdownHtml}
<table class="min-w-full text-sm border border-gray-200 rounded">
<tbody>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Total Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Nontaxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Taxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Tax Collected</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}</td></tr>
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Adjustment</td><td class="px-3 py-2 text-right font-medium ${adj !== 0 ? 'text-red-600' : ''}">${adjStr}</td></tr>
${existingPeriod.adjustment_reason ? `<tr class="border-t"><td class="px-3 py-2 text-gray-500 text-xs pl-8" colspan="2">Reason: ${escapeHtml(existingPeriod.adjustment_reason)}</td></tr>` : ''}
<tr class="border-t bg-gray-50 font-semibold"><td class="px-3 py-2">Net Paid</td><td class="px-3 py-2 text-right">${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}</td></tr>
</tbody>
</table>
</div>`;
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 = `
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">${monthLabel} — Sales Tax Detail</h3>
<button onclick="window.salesTaxView.closeTaxPeriodDetail()"
class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
${reportHtml}
${snapshotNote}
<div class="mt-4 border-t pt-4">
<div class="flex items-center gap-4 text-sm mb-3" id="st-summary-bar">
<span class="${useSnapshot ? 'px-2 py-1 rounded bg-gray-100 border border-gray-300' : ''}"><strong>Tax Collected:</strong> ${fmtMoney(taxCollected)}${useSnapshot ? ` <span class="text-xs text-gray-500">(saved${hasSavedBreakdown ? '' : ' — authoritative'})</span>` : ''}</span>
<span id="st-adj-display" class="${adjustments !== 0 ? 'text-red-600' : ''}"><strong>Adjustment:</strong> ${adjustments > 0 ? `${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'}</span>
<span id="st-net-due" class="text-lg font-bold text-gray-900"><strong>Net Due:</strong> ${fmtMoney(netPaid)}</span>
</div>
${isEditable ? `
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Adjustment Amount</label>
<input type="number" step="0.01" id="st-adjustment" value="${adjustments !== 0 ? adjustments : ''}" placeholder="e.g. 6.15 (discount)"
oninput="window.salesTaxView.updateTaxPreview()"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
<p class="text-xs text-gray-400 mt-0.5">Positive = discount (reduces net due)</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Reason</label>
<input type="text" id="st-adjustment-reason" value="${escapeHtml(adjReason)}" placeholder="e.g. Timely filing discount"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Paid Date (QBO)</label>
<input type="date" id="st-paid-date" value="${today}"
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
</div>
<div class="flex items-center justify-between border-t pt-3 mt-1">
<div id="st-je-lines">
<p class="text-sm"><strong>Summary:</strong></p>
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
${adjustments > 0 ? `<p class="text-xs text-gray-500">Discount: ${fmtMoney(adjustments)}</p>` : ''}
${adjustments < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adjustments).toFixed(2)}</p>` : ''}
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>
</div>
<div class="flex gap-2">
<button onclick="window.salesTaxView.saveTaxPeriodDraft()"
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
💾 Save Draft
</button>
<button onclick="window.salesTaxView.markPeriodPaid()"
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
✅ Mark as Paid
</button>
</div>
</div>
` : periodStatus === 'booked' ? `
<div class="border-t pt-3 mt-1">
<p class="text-sm text-green-700 font-semibold">✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}</p>
</div>
` : `
<div class="border-t pt-3 mt-1">
<p class="text-sm text-green-700 font-semibold">✅ Paid on ${formatDate(existingPeriod.booked_at)}</p>
</div>
`}
</div>
</div>`;
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
? `<strong>Adjustment:</strong> ${fmtMoney(adj)}`
: adj < 0
? `<strong>Adjustment:</strong> +$${Math.abs(adj).toFixed(2)}`
: `<strong>Adjustment:</strong> —`;
}
const netDue = document.getElementById('st-net-due');
if (netDue) {
netDue.innerHTML = `<strong>Net Due:</strong> ${fmtMoney(netPaid)}`;
}
const jeLines = document.getElementById('st-je-lines');
if (jeLines) {
jeLines.innerHTML = `
<p class="text-sm"><strong>Summary:</strong></p>
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
${adj > 0 ? `<p class="text-xs text-gray-500">Discount: ${fmtMoney(adj)}</p>` : ''}
${adj < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adj).toFixed(2)}</p>` : ''}
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>`;
}
}
/**
* 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: `<p class="mt-2 text-xs text-gray-500">District rows and grand total both come from the saved snapshot for this period${savedOn}.</p>`
};
}
if (!useSnapshot) {
return {
html: taxData?.Rows ? renderQboReport(taxData) : `<p class="text-sm text-gray-500">No tax data for this period.</p>`,
note: ''
};
}
// Altperiode: kein Snapshot der Aufschlüsselung vorhanden und keiner mehr
// rekonstruierbar. Live-Zeilen optisch untergeordnet und explizit entwertet.
const liveTable = taxData?.Rows
? `<div class="opacity-60 text-xs">${renderQboReport(markLiveGrandTotalAsIndicative(taxData))}</div>`
: `<p class="text-sm text-gray-500">No live tax data for this period.</p>`;
return {
html: `
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 mb-3">
<p class="text-sm font-semibold text-amber-900">Indicative live breakdown — not the filed figures</p>
<p class="text-xs text-amber-800 mt-0.5">
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.
<strong>The saved grand total below is the authoritative figure.</strong>
</p>
</div>
${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
};