693 lines
34 KiB
JavaScript
693 lines
34 KiB
JavaScript
/**
|
||
* 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">×</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">×</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
|
||
};
|