diff --git a/migrations/add-sales-tax-periods.sql b/migrations/add-sales-tax-periods.sql new file mode 100644 index 0000000..78fc1c4 --- /dev/null +++ b/migrations/add-sales-tax-periods.sql @@ -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) +); diff --git a/public/index.html b/public/index.html index e0f6c87..a58bfdd 100644 --- a/public/index.html +++ b/public/index.html @@ -141,6 +141,11 @@

Reports

+ +
+

Sales Tax

+
+
diff --git a/public/js/utils/api.js b/public/js/utils/api.js index 80e9abc..a079b40 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -144,6 +144,15 @@ const API = { 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 syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()), syncVendors: () => fetch('/api/accounting/sync-vendors', { method: 'POST' }).then(r => r.json()), diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 8926193..ff92af6 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -30,6 +30,10 @@ let bsAccountingMethod = 'Accrual'; let tsMonth = null; // 'YYYY-MM' — selected month for tax summary 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 expEndDate = null; let expOnlyMine = false; @@ -712,6 +716,412 @@ function renderExpensesTable(expenses) { `; } +// ──────────────────────────────────────────────────────────────────── +// 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')} +
+
+
+

Period Overview

+
+ +
+
+
+
+ +
`; +} + +export async function loadTaxPeriods() { + try { + stPeriods = await window.API.accounting.getTaxPeriods() || []; + } catch (e) { + stPeriods = []; + console.error('Failed to load tax periods:', e.message); + } + renderTaxPeriodsTable(); +} + +function renderTaxPeriodsTable() { + const el = document.getElementById('sales-tax-periods-table'); + if (!el) return; + + if (!stPeriods.length) { + el.innerHTML = `
No sales tax periods recorded yet. Click "+ New Period" to get started.
`; + return; + } + + const rows = stPeriods.map(p => { + const status = p.qbo_journal_entry_id + ? `Paid` + : `Open`; + 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 ` + + ${monthLabel} + ${status} + ${fmtMoney(parseFloat(p.tax_collected) || 0)} + ${adjStr} + ${fmtMoney(netPaid)} + ${p.booked_at ? formatDate(p.booked_at) : '—'} + + + + `; + }).join(''); + + el.innerHTML = ` +
+ + + + + + + + + + + + + ${rows} +
PeriodStatusTax AmountAdjustmentNet DuePaid OnActions
+
${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}
+
`; +} + +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) : `

No tax data for this period.

`; + + 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 => ``).join(''); + const liabilityOpts = stAccounts + .filter(a => a.accountType === 'Other Current Liability') + .map(a => ``).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 ``; + }).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 = ` +
+
+

${monthLabel} — Sales Tax Detail

+ +
+ ${reportHtml} + +
+
+ Tax Collected: ${fmtMoney(taxCollected)} + Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} + Net Due: ${fmtMoney(netPaid)} +
+ +
+
+ + +

Positive = discount (reduces net due)

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + ${!isPaid ? ` +
+
+

Journal Entry lines:

+

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

+ ${adjustments > 0 ? `

Credit Discount: ${fmtMoney(adjustments)}

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

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

` : ''} +

Credit Bank: ${fmtMoney(netPaid)}

+
+
+ + +
+
+ ` : ` +
+

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

+
+ `} +
+
`; + + 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() { await openExpenseModal({ onSaved: () => loadExpenses() @@ -728,9 +1138,9 @@ export function renderAccountingView() { injectRegisterControls(); injectReportsControls(); injectExpensesSection(); + injectSalesTaxSection(); + loadTaxPeriods(); - // Sequenz: erst Auto-Sync (wenn nötig), DANN Daten laden, - // damit das Frontend frische Cache-basierte Daten kriegt. maybeAutoSyncCaches().then(() => { loadAccountsOverview(); }); @@ -775,5 +1185,12 @@ window.accountingView = { openNewRefund, editExpense, selectRegisterAccount, - toggleSection + toggleSection, + injectSalesTaxSection, + loadTaxPeriods, + openNewTaxPeriod, + openTaxPeriod, + closeTaxPeriodDetail, + saveTaxPeriodDraft, + recordTaxPayment }; \ No newline at end of file diff --git a/schema.sql b/schema.sql index f5ef198..6d314c1 100644 --- a/schema.sql +++ b/schema.sql @@ -356,6 +356,36 @@ CREATE TABLE public.settings ( 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 -- diff --git a/src/routes/accounting.js b/src/routes/accounting.js index 5dbbed1..4ad4be9 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -95,6 +95,29 @@ router.get('/reports/tax-summary', async (req, res) => { } 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 // ════════════════════════════════════════════════════════════════════ diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index 423f27e..6dbd882 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -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 // ════════════════════════════════════════════════════════════════════ @@ -1562,6 +1697,9 @@ module.exports = { getProfitAndLoss, getBalanceSheet, getTaxSummary, + getTaxPeriods, + upsertTaxPeriod, + createTaxPaymentJE, normalizeTransactionListReport, // Phase 2 Lieferung 1 — Sync