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
+
+
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 = `
+
+
+
+
+ | Period |
+ Status |
+ Tax Amount |
+ Adjustment |
+ Net Due |
+ Paid On |
+ Actions |
+
+
+ ${rows}
+
+
${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