sales tax
This commit is contained in:
23
migrations/add-sales-tax-periods.sql
Normal file
23
migrations/add-sales-tax-periods.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE sales_tax_periods (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
total_sales NUMERIC(10,2),
|
||||||
|
nontaxable_sales NUMERIC(10,2),
|
||||||
|
taxable_sales NUMERIC(10,2),
|
||||||
|
tax_collected NUMERIC(10,2),
|
||||||
|
adjustment_amount NUMERIC(10,2) DEFAULT 0,
|
||||||
|
adjustment_reason TEXT,
|
||||||
|
adjustment_account_id VARCHAR(50),
|
||||||
|
adjustment_account_name VARCHAR(200),
|
||||||
|
net_paid NUMERIC(10,2),
|
||||||
|
bank_account_id VARCHAR(50),
|
||||||
|
bank_account_name VARCHAR(200),
|
||||||
|
sales_tax_payable_id VARCHAR(50),
|
||||||
|
sales_tax_payable_name VARCHAR(200),
|
||||||
|
qbo_journal_entry_id VARCHAR(50),
|
||||||
|
booked_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(period_start, period_end)
|
||||||
|
);
|
||||||
@@ -141,6 +141,11 @@
|
|||||||
<h3 class="text-md font-semibold text-gray-700 mb-3">Reports</h3>
|
<h3 class="text-md font-semibold text-gray-700 mb-3">Reports</h3>
|
||||||
<div id="accounting-reports"></div>
|
<div id="accounting-reports"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-md font-semibold text-gray-700 mb-3">Sales Tax</h3>
|
||||||
|
<div id="accounting-sales-tax"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ const API = {
|
|||||||
return fetch('/api/accounting/reports/tax-summary?' + params.toString()).then(r => r.json());
|
return fetch('/api/accounting/reports/tax-summary?' + params.toString()).then(r => r.json());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sales Tax Periods
|
||||||
|
getTaxPeriods: () => fetch('/api/accounting/sales-tax/periods').then(r => r.json()),
|
||||||
|
upsertTaxPeriod: (data) => fetch('/api/accounting/sales-tax/periods', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
recordTaxPayment: (periodId, data) => fetch(`/api/accounting/sales-tax/periods/${periodId}/record`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
|
||||||
|
}).then(r => r.json()),
|
||||||
|
|
||||||
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
||||||
syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
|
syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
|
||||||
syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()),
|
syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()),
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ let bsAccountingMethod = 'Accrual';
|
|||||||
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
|
let tsMonth = null; // 'YYYY-MM' — selected month for tax summary
|
||||||
let tsAccountingMethod = 'Accrual';
|
let tsAccountingMethod = 'Accrual';
|
||||||
|
|
||||||
|
let stPeriods = [];
|
||||||
|
let stEditingPeriodId = null; // current period in detail dialog, null = new
|
||||||
|
let stAccounts = []; // cached account list for pickers
|
||||||
|
|
||||||
let expStartDate = null;
|
let expStartDate = null;
|
||||||
let expEndDate = null;
|
let expEndDate = null;
|
||||||
let expOnlyMine = false;
|
let expOnlyMine = false;
|
||||||
@@ -712,6 +716,412 @@ function renderExpensesTable(expenses) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
// Sales Tax Periods
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function injectSalesTaxSection() {
|
||||||
|
const c = document.getElementById('accounting-sales-tax');
|
||||||
|
if (!c) return;
|
||||||
|
c.innerHTML = `
|
||||||
|
${makeCollapsible('Sales Tax', 'sales-tax-section-body')}
|
||||||
|
<div id="sales-tax-section-body">
|
||||||
|
<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.accountingView.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>
|
||||||
|
</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 status = p.qbo_journal_entry_id
|
||||||
|
? `<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">Paid</span>`
|
||||||
|
: `<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">Open</span>`;
|
||||||
|
const monthLabel = new Date(p.period_start).toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||||
|
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;
|
||||||
|
|
||||||
|
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">${status}</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">${p.booked_at ? formatDate(p.booked_at) : '—'}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-center">
|
||||||
|
<button onclick="window.accountingView.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;
|
||||||
|
|
||||||
|
showLoading('sales-tax-detail', 'Loading tax summary from QBO…');
|
||||||
|
let taxData;
|
||||||
|
try {
|
||||||
|
taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual');
|
||||||
|
} catch (e) {
|
||||||
|
showError('sales-tax-detail', e.message || 'Failed to load tax summary');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stAccounts.length) {
|
||||||
|
try {
|
||||||
|
const accts = await window.API.accounting.getAccounts() || [];
|
||||||
|
stAccounts = Array.isArray(accts) ? accts : [];
|
||||||
|
} catch (e) { stAccounts = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportHtml = taxData?.Rows ? renderQboReport(taxData) : `<p class="text-sm text-gray-500">No tax data for this period.</p>`;
|
||||||
|
|
||||||
|
const parsed = parseTaxDataFromReport(taxData);
|
||||||
|
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 isPaid = existingPeriod && existingPeriod.qbo_journal_entry_id;
|
||||||
|
|
||||||
|
const bankOpts = stAccounts
|
||||||
|
.filter(a => a.accountType === 'Bank' || a.accountType === 'Credit Card')
|
||||||
|
.map(a => `<option value="${a.id}" ${a.id === existingPeriod?.bank_account_id ? 'selected' : ''}>${escapeHtml(a.name)} (${a.accountType})</option>`).join('');
|
||||||
|
const liabilityOpts = stAccounts
|
||||||
|
.filter(a => a.accountType === 'Other Current Liability')
|
||||||
|
.map(a => `<option value="${a.id}" ${a.id === existingPeriod?.sales_tax_payable_id ? 'selected' : ''}>${escapeHtml(a.name)}</option>`).join('');
|
||||||
|
const adjustOpts = stAccounts
|
||||||
|
.filter(a => a.accountType === 'Income' || a.accountType === 'Expense' || a.classification === 'Revenue' || a.classification === 'Expense')
|
||||||
|
.map(a => {
|
||||||
|
const sel = a.id === existingPeriod?.adjustment_account_id ? 'selected' : '';
|
||||||
|
const name = (a.fullyQualifiedName || a.name || '');
|
||||||
|
const isDiscount = name.toLowerCase().includes('discount');
|
||||||
|
return `<option value="${a.id}" ${sel} data-hint="${isDiscount ? 'discount' : ''}">${escapeHtml(name)}${isDiscount ? ' ★' : ''}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const today = todayISO();
|
||||||
|
const [y, m] = startDate.split('-').map(Number);
|
||||||
|
const monthLabel = new Date(y, m - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||||
|
|
||||||
|
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.accountingView.closeTaxPeriodDetail()"
|
||||||
|
class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
${reportHtml}
|
||||||
|
|
||||||
|
<div class="mt-4 border-t pt-4">
|
||||||
|
<div class="flex items-center gap-4 text-sm mb-3">
|
||||||
|
<span><strong>Tax Collected:</strong> ${fmtMoney(taxCollected)}</span>
|
||||||
|
<span class="${adjustments !== 0 ? 'text-red-600' : ''}"><strong>Adjustment:</strong> ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'}</span>
|
||||||
|
<span class="text-lg font-bold text-gray-900"><strong>Net Due:</strong> ${fmtMoney(netPaid)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)" ${isPaid ? 'disabled' : ''}
|
||||||
|
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" ${isPaid ? 'disabled' : ''}
|
||||||
|
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">Adjustment Account</label>
|
||||||
|
<select id="st-adjustment-account" class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm" ${isPaid ? 'disabled' : ''}>
|
||||||
|
<option value="">— Select income account —</option>
|
||||||
|
${adjustOpts}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Bank Account (payment source)</label>
|
||||||
|
<select id="st-bank-account" class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm" ${isPaid ? 'disabled' : ''}>
|
||||||
|
<option value="">— Select bank account —</option>
|
||||||
|
${bankOpts}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Sales Tax Payable Account</label>
|
||||||
|
<select id="st-payable-account" class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm" ${isPaid ? 'disabled' : ''}>
|
||||||
|
<option value="">— Select liability account —</option>
|
||||||
|
${liabilityOpts}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Payment Date</label>
|
||||||
|
<input type="date" id="st-payment-date" value="${today}" ${isPaid ? 'disabled' : ''}
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!isPaid ? `
|
||||||
|
<div class="flex items-center justify-between border-t pt-3 mt-1">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm"><strong>Journal Entry lines:</strong></p>
|
||||||
|
<p class="text-xs text-gray-500 font-mono">Debit Sales Tax Payable: ${fmtMoney(taxCollected)}</p>
|
||||||
|
${adjustments > 0 ? `<p class="text-xs text-gray-500 font-mono">Credit Discount: ${fmtMoney(adjustments)}</p>` : ''}
|
||||||
|
${adjustments < 0 ? `<p class="text-xs text-gray-500 font-mono">Debit Penalty: ${fmtMoney(Math.abs(adjustments))}</p>` : ''}
|
||||||
|
<p class="text-xs text-gray-500 font-mono">Credit Bank: ${fmtMoney(netPaid)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="window.accountingView.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.accountingView.recordTaxPayment()"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
|
||||||
|
✅ Record Payment in QBO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="border-t pt-3 mt-1">
|
||||||
|
<p class="text-sm text-green-700 font-semibold">✅ Paid — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} 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 async function saveTaxPeriodDraft() {
|
||||||
|
const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0;
|
||||||
|
const adjReason = document.getElementById('st-adjustment-reason').value.trim();
|
||||||
|
const adjAccountEl = document.getElementById('st-adjustment-account');
|
||||||
|
const adjAccountId = adjAccountEl.value;
|
||||||
|
const adjAccountName = adjAccountId ? adjAccountEl.options[adjAccountEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
const bankEl = document.getElementById('st-bank-account');
|
||||||
|
const bankId = bankEl.value;
|
||||||
|
const bankName = bankId ? bankEl.options[bankEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
const payableEl = document.getElementById('st-payable-account');
|
||||||
|
const payableId = payableEl.value;
|
||||||
|
const payableName = payableId ? payableEl.options[payableEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
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,
|
||||||
|
adjustment_account_id: adjAccountId || null,
|
||||||
|
adjustment_account_name: adjAccountName || null,
|
||||||
|
net_paid: stCurrentTaxData.taxCollected - adjAmount,
|
||||||
|
bank_account_id: bankId || null,
|
||||||
|
bank_account_name: bankName || null,
|
||||||
|
sales_tax_payable_id: payableId || null,
|
||||||
|
sales_tax_payable_name: payableName || null
|
||||||
|
});
|
||||||
|
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 recordTaxPayment() {
|
||||||
|
const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0;
|
||||||
|
const adjReason = document.getElementById('st-adjustment-reason').value.trim();
|
||||||
|
const adjAccountEl = document.getElementById('st-adjustment-account');
|
||||||
|
const adjAccountId = adjAccountEl.value;
|
||||||
|
const adjAccountName = adjAccountId ? adjAccountEl.options[adjAccountEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
const bankEl = document.getElementById('st-bank-account');
|
||||||
|
const bankId = bankEl.value;
|
||||||
|
const bankName = bankId ? bankEl.options[bankEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
const payableEl = document.getElementById('st-payable-account');
|
||||||
|
const payableId = payableEl.value;
|
||||||
|
const payableName = payableId ? payableEl.options[payableEl.selectedIndex]?.text : '';
|
||||||
|
|
||||||
|
const txnDate = document.getElementById('st-payment-date').value;
|
||||||
|
if (!txnDate) return alert('Please select a payment date.');
|
||||||
|
|
||||||
|
if (!stCurrentTaxData) return alert('No tax data loaded.');
|
||||||
|
|
||||||
|
const taxCollected = stCurrentTaxData.taxCollected;
|
||||||
|
const netPaid = taxCollected - adjAmount;
|
||||||
|
|
||||||
|
if (!payableId) return alert('Please select the Sales Tax Payable account.');
|
||||||
|
if (!bankId) return alert('Please select a bank account.');
|
||||||
|
|
||||||
|
const preview = `Journal Entry on ${txnDate}:\n\n` +
|
||||||
|
`Debit Sales Tax Payable: $${taxCollected.toFixed(2)}\n` +
|
||||||
|
(adjAmount > 0 ? `Credit Discount: $${adjAmount.toFixed(2)} (${adjReason || 'Adjustment'})\n` : '') +
|
||||||
|
(adjAmount < 0 ? `Debit Penalty: $${Math.abs(adjAmount).toFixed(2)} (${adjReason || 'Adjustment'})\n` : '') +
|
||||||
|
`Credit Bank: $${netPaid.toFixed(2)}\n\n` +
|
||||||
|
(adjAmount > 0 ? `Debits (${taxCollected.toFixed(2)}) = Credits (${adjAmount.toFixed(2)} + ${netPaid.toFixed(2)} = ${(adjAmount + netPaid).toFixed(2)})` :
|
||||||
|
adjAmount < 0 ? `Debits (${taxCollected.toFixed(2)} + ${Math.abs(adjAmount).toFixed(2)} = ${(taxCollected + Math.abs(adjAmount)).toFixed(2)}) = Credits (${netPaid.toFixed(2)})` :
|
||||||
|
`Debits = Credits = ${taxCollected.toFixed(2)}`) +
|
||||||
|
'\n\nRecord this in QBO?';
|
||||||
|
|
||||||
|
if (!confirm(preview)) return;
|
||||||
|
|
||||||
|
let periodId = stEditingPeriodId;
|
||||||
|
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,
|
||||||
|
adjustment_account_id: adjAccountId || null,
|
||||||
|
adjustment_account_name: adjAccountName || null,
|
||||||
|
net_paid: netPaid,
|
||||||
|
bank_account_id: bankId || null,
|
||||||
|
bank_account_name: bankName || null,
|
||||||
|
sales_tax_payable_id: payableId || null,
|
||||||
|
sales_tax_payable_name: payableName || null
|
||||||
|
});
|
||||||
|
periodId = period.id;
|
||||||
|
} catch (e) {
|
||||||
|
return alert('Failed to save period before payment: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Recording tax payment in QBO…');
|
||||||
|
try {
|
||||||
|
const result = await window.API.accounting.recordTaxPayment(periodId, {
|
||||||
|
txnDate,
|
||||||
|
taxCollected,
|
||||||
|
adjustmentAmount: adjAmount,
|
||||||
|
netPaid,
|
||||||
|
salesTaxPayableId: payableId,
|
||||||
|
salesTaxPayableName: payableName,
|
||||||
|
adjustmentAccountId: adjAccountId || null,
|
||||||
|
adjustmentAccountName: adjAccountName || null,
|
||||||
|
adjustmentReason: adjReason || null,
|
||||||
|
bankAccountId: bankId,
|
||||||
|
bankAccountName: bankName
|
||||||
|
});
|
||||||
|
if (result.error) return alert('Failed: ' + result.error);
|
||||||
|
await loadTaxPeriods();
|
||||||
|
const updated = stPeriods.find(p => p.id === periodId);
|
||||||
|
if (updated) {
|
||||||
|
await openTaxPeriodDetail(updated.period_start, updated.period_end, updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('QBO payment failed: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let stCurrentTaxData = null; // parsed data for the open detail dialog
|
||||||
|
|
||||||
export async function openNewExpense() {
|
export async function openNewExpense() {
|
||||||
await openExpenseModal({
|
await openExpenseModal({
|
||||||
onSaved: () => loadExpenses()
|
onSaved: () => loadExpenses()
|
||||||
@@ -728,9 +1138,9 @@ export function renderAccountingView() {
|
|||||||
injectRegisterControls();
|
injectRegisterControls();
|
||||||
injectReportsControls();
|
injectReportsControls();
|
||||||
injectExpensesSection();
|
injectExpensesSection();
|
||||||
|
injectSalesTaxSection();
|
||||||
|
loadTaxPeriods();
|
||||||
|
|
||||||
// Sequenz: erst Auto-Sync (wenn nötig), DANN Daten laden,
|
|
||||||
// damit das Frontend frische Cache-basierte Daten kriegt.
|
|
||||||
maybeAutoSyncCaches().then(() => {
|
maybeAutoSyncCaches().then(() => {
|
||||||
loadAccountsOverview();
|
loadAccountsOverview();
|
||||||
});
|
});
|
||||||
@@ -775,5 +1185,12 @@ window.accountingView = {
|
|||||||
openNewRefund,
|
openNewRefund,
|
||||||
editExpense,
|
editExpense,
|
||||||
selectRegisterAccount,
|
selectRegisterAccount,
|
||||||
toggleSection
|
toggleSection,
|
||||||
|
injectSalesTaxSection,
|
||||||
|
loadTaxPeriods,
|
||||||
|
openNewTaxPeriod,
|
||||||
|
openTaxPeriod,
|
||||||
|
closeTaxPeriodDetail,
|
||||||
|
saveTaxPeriodDraft,
|
||||||
|
recordTaxPayment
|
||||||
};
|
};
|
||||||
30
schema.sql
30
schema.sql
@@ -356,6 +356,36 @@ CREATE TABLE public.settings (
|
|||||||
|
|
||||||
ALTER TABLE public.settings OWNER TO quoteuser;
|
ALTER TABLE public.settings OWNER TO quoteuser;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sales_tax_periods; Type: TABLE; Schema: public; Owner: quoteuser
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.sales_tax_periods (
|
||||||
|
id serial NOT NULL,
|
||||||
|
period_start date NOT NULL,
|
||||||
|
period_end date NOT NULL,
|
||||||
|
total_sales numeric(10,2),
|
||||||
|
nontaxable_sales numeric(10,2),
|
||||||
|
taxable_sales numeric(10,2),
|
||||||
|
tax_collected numeric(10,2),
|
||||||
|
adjustment_amount numeric(10,2) DEFAULT 0,
|
||||||
|
adjustment_reason text,
|
||||||
|
adjustment_account_id character varying(50),
|
||||||
|
adjustment_account_name character varying(200),
|
||||||
|
net_paid numeric(10,2),
|
||||||
|
bank_account_id character varying(50),
|
||||||
|
bank_account_name character varying(200),
|
||||||
|
sales_tax_payable_id character varying(50),
|
||||||
|
sales_tax_payable_name character varying(200),
|
||||||
|
qbo_journal_entry_id character varying(50),
|
||||||
|
booked_at timestamp without time zone,
|
||||||
|
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT sales_tax_periods_period_start_period_end_key UNIQUE (period_start, period_end)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.sales_tax_periods OWNER TO quoteuser;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -95,6 +95,29 @@ router.get('/reports/tax-summary', async (req, res) => {
|
|||||||
} catch (err) { handleQboError(err, res, 'tax-summary'); }
|
} catch (err) { handleQboError(err, res, 'tax-summary'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Sales Tax Periods ────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/sales-tax/periods', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await accountingService.getTaxPeriods());
|
||||||
|
} catch (err) { handleQboError(err, res, 'sales-tax-periods'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sales-tax/periods', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await accountingService.upsertTaxPeriod(req.body));
|
||||||
|
} catch (err) { handleQboError(err, res, 'sales-tax-periods'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sales-tax/periods/:id/record', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await accountingService.createTaxPaymentJE({
|
||||||
|
periodId: req.params.id,
|
||||||
|
...req.body
|
||||||
|
}));
|
||||||
|
} catch (err) { handleQboError(err, res, 'sales-tax-record'); }
|
||||||
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
// Phase 2 Lieferung 1 — Sync + Cache-Reads
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -510,6 +510,141 @@ async function getTaxSummary({ startDate, endDate, accountingMethod = 'Accrual'
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
// Sales Tax Periods — local source of truth for tax filings
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function getTaxPeriods() {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM sales_tax_periods ORDER BY period_start DESC'
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTaxPeriod(period) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO sales_tax_periods
|
||||||
|
(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)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||||
|
ON CONFLICT (period_start, period_end) DO UPDATE SET
|
||||||
|
total_sales = EXCLUDED.total_sales,
|
||||||
|
nontaxable_sales = EXCLUDED.nontaxable_sales,
|
||||||
|
taxable_sales = EXCLUDED.taxable_sales,
|
||||||
|
tax_collected = EXCLUDED.tax_collected,
|
||||||
|
adjustment_amount = COALESCE(sales_tax_periods.adjustment_amount, EXCLUDED.adjustment_amount),
|
||||||
|
adjustment_reason = COALESCE(sales_tax_periods.adjustment_reason, EXCLUDED.adjustment_reason),
|
||||||
|
adjustment_account_id = COALESCE(sales_tax_periods.adjustment_account_id, EXCLUDED.adjustment_account_id),
|
||||||
|
adjustment_account_name = COALESCE(sales_tax_periods.adjustment_account_name, EXCLUDED.adjustment_account_name),
|
||||||
|
net_paid = COALESCE(sales_tax_periods.net_paid, EXCLUDED.net_paid),
|
||||||
|
bank_account_id = COALESCE(sales_tax_periods.bank_account_id, EXCLUDED.bank_account_id),
|
||||||
|
bank_account_name = COALESCE(sales_tax_periods.bank_account_name, EXCLUDED.bank_account_name),
|
||||||
|
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),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
period.period_start, period.period_end,
|
||||||
|
period.total_sales, period.nontaxable_sales, period.taxable_sales, period.tax_collected,
|
||||||
|
period.adjustment_amount || 0, period.adjustment_reason || null,
|
||||||
|
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
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaxPaymentJE({
|
||||||
|
periodId, txnDate, taxCollected, adjustmentAmount, netPaid,
|
||||||
|
salesTaxPayableId, salesTaxPayableName,
|
||||||
|
adjustmentAccountId, adjustmentAccountName, adjustmentReason,
|
||||||
|
bankAccountId, bankAccountName
|
||||||
|
}) {
|
||||||
|
const { companyId, baseUrl } = getClientInfo();
|
||||||
|
const url = withMinorVersion(`${baseUrl}/v3/company/${companyId}/journalentry`);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
{
|
||||||
|
DetailType: 'JournalEntryLineDetail',
|
||||||
|
Amount: taxCollected,
|
||||||
|
JournalEntryLineDetail: {
|
||||||
|
PostingType: 'Debit',
|
||||||
|
AccountRef: { value: String(salesTaxPayableId), name: salesTaxPayableName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (adjustmentAmount > 0) {
|
||||||
|
lines.push({
|
||||||
|
DetailType: 'JournalEntryLineDetail',
|
||||||
|
Amount: adjustmentAmount,
|
||||||
|
Description: adjustmentReason || undefined,
|
||||||
|
JournalEntryLineDetail: {
|
||||||
|
PostingType: 'Credit',
|
||||||
|
AccountRef: { value: String(adjustmentAccountId), name: adjustmentAccountName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (adjustmentAmount < 0) {
|
||||||
|
lines.push({
|
||||||
|
DetailType: 'JournalEntryLineDetail',
|
||||||
|
Amount: Math.abs(adjustmentAmount),
|
||||||
|
Description: adjustmentReason || undefined,
|
||||||
|
JournalEntryLineDetail: {
|
||||||
|
PostingType: 'Debit',
|
||||||
|
AccountRef: { value: String(adjustmentAccountId), name: adjustmentAccountName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
DetailType: 'JournalEntryLineDetail',
|
||||||
|
Amount: netPaid,
|
||||||
|
JournalEntryLineDetail: {
|
||||||
|
PostingType: 'Credit',
|
||||||
|
AccountRef: { value: String(bankAccountId), name: bankAccountName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
TxnDate: txnDate,
|
||||||
|
DocNumber: `STP-${periodId}`,
|
||||||
|
Line: lines,
|
||||||
|
PrivateNote: adjustmentReason ? `Adjustment: ${adjustmentReason}` : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
let qboResponse;
|
||||||
|
try {
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
qboResponse = response;
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg = e.originalMessage || e.message || String(e);
|
||||||
|
throw new Error(`QBO JournalEntry failed: ${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getJson(qboResponse);
|
||||||
|
throwIfFault(data, 'JournalEntry create');
|
||||||
|
|
||||||
|
const je = data.JournalEntry;
|
||||||
|
if (!je || !je.Id) throw new Error('QBO did not return a JournalEntry ID');
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE sales_tax_periods
|
||||||
|
SET qbo_journal_entry_id = $1, booked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[je.Id, periodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { qbo_journal_entry_id: je.Id, qbo_sync_token: je.SyncToken };
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Phase 2 Lieferung 1 — Caches und Sync
|
// Phase 2 Lieferung 1 — Caches und Sync
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
@@ -1562,6 +1697,9 @@ module.exports = {
|
|||||||
getProfitAndLoss,
|
getProfitAndLoss,
|
||||||
getBalanceSheet,
|
getBalanceSheet,
|
||||||
getTaxSummary,
|
getTaxSummary,
|
||||||
|
getTaxPeriods,
|
||||||
|
upsertTaxPeriod,
|
||||||
|
createTaxPaymentJE,
|
||||||
normalizeTransactionListReport,
|
normalizeTransactionListReport,
|
||||||
|
|
||||||
// Phase 2 Lieferung 1 — Sync
|
// Phase 2 Lieferung 1 — Sync
|
||||||
|
|||||||
Reference in New Issue
Block a user