diff --git a/migrations/add-sales-tax-periods-status.sql b/migrations/add-sales-tax-periods-status.sql new file mode 100644 index 0000000..5ec5801 --- /dev/null +++ b/migrations/add-sales-tax-periods-status.sql @@ -0,0 +1,2 @@ +ALTER TABLE sales_tax_periods ADD COLUMN status VARCHAR(20) DEFAULT 'open'; +UPDATE sales_tax_periods SET status = 'booked' WHERE qbo_journal_entry_id IS NOT NULL; diff --git a/public/js/utils/api.js b/public/js/utils/api.js index a079b40..7447ebb 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -152,6 +152,9 @@ const API = { 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()), + markTaxPaidExternal: (periodId) => fetch(`/api/accounting/sales-tax/periods/${periodId}/mark-external`, { + method: 'POST' + }).then(r => r.json()), // Phase 2 Lieferung 1 — Sync + Cache-Reads syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()), diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 5a73c86..5f43fbe 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -762,22 +762,28 @@ function renderTaxPeriodsTable() { } const rows = stPeriods.map(p => { - const status = p.qbo_journal_entry_id - ? `Paid` - : `Open`; + 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') { statusHtml = 'External'; statusColor = 'bg-blue-100 text-blue-800'; } + else { statusHtml = 'Open'; statusColor = 'bg-yellow-100 text-yellow-800'; } + 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; + const paidOn = pStatus === 'external' ? 'Paid in QBO' : (p.booked_at ? formatDate(p.booked_at) : '—'); return ` ${monthLabel} - ${status} + + ${statusHtml} + ${fmtMoney(parseFloat(p.tax_collected) || 0)} ${adjStr} ${fmtMoney(netPaid)} - ${p.booked_at ? formatDate(p.booked_at) : '—'} + ${paidOn} @@ -851,7 +857,9 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { 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 periodStatus = existingPeriod?.status || (existingPeriod?.qbo_journal_entry_id ? 'booked' : 'open'); + const isOpen = periodStatus === 'open'; + const isEditable = isOpen; const bankOpts = stAccounts .filter(a => a.accountType === 'Bank' || a.accountType === 'Credit Card') @@ -891,19 +899,19 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) {
-

Positive = discount (reduces net due)

-
- ${adjustOpts} @@ -913,33 +921,32 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) {
- ${bankOpts}
- ${liabilityOpts}
-
- ${!isPaid ? ` + ${isEditable ? `

Journal Entry lines:

Debit Sales Tax Payable: ${fmtMoney(taxCollected)}

${adjustments > 0 ? `

Credit Discount: ${fmtMoney(adjustments)}

` : ''} ${adjustments < 0 ? `

Debit Penalty: ${fmtMoney(Math.abs(adjustments))}

` : ''} - ${adjustments === 0 ? `` : ''}

Credit Bank: ${fmtMoney(netPaid)}

@@ -947,15 +954,23 @@ async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { 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 +
+ ` : periodStatus === 'booked' ? ` +
+

✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}

+
` : `
-

✅ Paid — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}

+

📋 Marked as paid externally on ${formatDate(existingPeriod.booked_at)}

`}
@@ -1138,6 +1153,62 @@ export async function recordTaxPayment() { } } +export async function markTaxPaidExternal() { + if (!stEditingPeriodId) return alert('Please save the period first (Save Draft).'); + + 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 (!confirm(`Mark this period as already paid in QBO (external)?\n\nNo Journal Entry will be created — this only records the status in the app.\n\nTax Collected: $${stCurrentTaxData.taxCollected.toFixed(2)}\nAdjustment: $${adjAmount.toFixed(2)}\nNet Due: $${(stCurrentTaxData.taxCollected - adjAmount).toFixed(2)}`)) return; + + if (!stCurrentTaxData) return alert('No tax data loaded.'); + + 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, + 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, + status: 'open' + }); + } catch (e) { + return alert('Failed to save period: ' + e.message); + } + + try { + const result = await window.API.accounting.markTaxPaidExternal(stEditingPeriodId); + 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); + } +} + 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 : []; @@ -1228,5 +1299,6 @@ window.accountingView = { closeTaxPeriodDetail, updateTaxPreview, saveTaxPeriodDraft, - recordTaxPayment + recordTaxPayment, + markTaxPaidExternal }; \ No newline at end of file diff --git a/schema.sql b/schema.sql index 6d314c1..029a162 100644 --- a/schema.sql +++ b/schema.sql @@ -378,6 +378,7 @@ CREATE TABLE public.sales_tax_periods ( sales_tax_payable_id character varying(50), sales_tax_payable_name character varying(200), qbo_journal_entry_id character varying(50), + status character varying(20) DEFAULT 'open', booked_at timestamp without time zone, created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, diff --git a/src/routes/accounting.js b/src/routes/accounting.js index 4ad4be9..0623f1b 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -118,6 +118,12 @@ router.post('/sales-tax/periods/:id/record', async (req, res) => { } catch (err) { handleQboError(err, res, 'sales-tax-record'); } }); +router.post('/sales-tax/periods/:id/mark-external', async (req, res) => { + try { + res.json(await accountingService.markTaxPaidExternal(req.params.id)); + } catch (err) { handleQboError(err, res, 'sales-tax-mark-external'); } +}); + // ════════════════════════════════════════════════════════════════════ // Phase 2 Lieferung 1 — Sync + Cache-Reads // ════════════════════════════════════════════════════════════════════ diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index 6dbd882..dec635f 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -527,8 +527,8 @@ async function upsertTaxPeriod(period) { (period_start, period_end, total_sales, nontaxable_sales, taxable_sales, tax_collected, adjustment_amount, adjustment_reason, adjustment_account_id, adjustment_account_name, net_paid, bank_account_id, bank_account_name, - sales_tax_payable_id, sales_tax_payable_name) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + sales_tax_payable_id, sales_tax_payable_name, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (period_start, period_end) DO UPDATE SET total_sales = EXCLUDED.total_sales, nontaxable_sales = EXCLUDED.nontaxable_sales, @@ -543,6 +543,7 @@ async function upsertTaxPeriod(period) { 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), + status = COALESCE(sales_tax_periods.status, EXCLUDED.status), updated_at = CURRENT_TIMESTAMP RETURNING *`, [ @@ -551,7 +552,8 @@ async function upsertTaxPeriod(period) { 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 + period.sales_tax_payable_id || null, period.sales_tax_payable_name || null, + period.status || 'open' ] ); return result.rows[0]; @@ -637,7 +639,7 @@ async function createTaxPaymentJE({ await pool.query( `UPDATE sales_tax_periods - SET qbo_journal_entry_id = $1, booked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + SET qbo_journal_entry_id = $1, status = 'booked', booked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [je.Id, periodId] ); @@ -645,6 +647,22 @@ async function createTaxPaymentJE({ return { qbo_journal_entry_id: je.Id, qbo_sync_token: je.SyncToken }; } +async function markTaxPaidExternal(periodId) { + const result = await pool.query( + `UPDATE sales_tax_periods + SET status = 'external', booked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND status = 'open' + RETURNING *`, + [periodId] + ); + if (result.rows.length === 0) { + const existing = await pool.query('SELECT status FROM sales_tax_periods WHERE id = $1', [periodId]); + const currentStatus = existing.rows[0]?.status || 'unknown'; + throw new Error(`Cannot mark as paid: period is already ${currentStatus}`); + } + return result.rows[0]; +} + // ════════════════════════════════════════════════════════════════════ // Phase 2 Lieferung 1 — Caches und Sync // ════════════════════════════════════════════════════════════════════ @@ -1700,6 +1718,7 @@ module.exports = { getTaxPeriods, upsertTaxPeriod, createTaxPaymentJE, + markTaxPaidExternal, normalizeTransactionListReport, // Phase 2 Lieferung 1 — Sync