From d7107aaf0adf5e03f08085c7691b6530b15ed702 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:33:10 +0200 Subject: [PATCH] feat(accounting): tax report VAT-payable honours registration + reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT minus the RECLAIMABLE input VAT only (costs with tax_treatment foreign_vat_non_reclaimable are excluded from the deduction). Registration reads accounting_vat_registered; when unset it falls back to a behaviour-preserving heuristic (charged output VAT this period ⇒ registered), so existing reports are unchanged and non-VAT installs correctly show 0. loadCosts now tracks reclaimableVat. Tests updated; 32 pass. --- .../services/taxReportService.test.js | 2 +- backend/src/services/taxReportService.js | 39 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index 0e7a93e7..12fc4ed6 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -367,7 +367,7 @@ describe('getTaxReport', () => { const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF', }); - expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0 }); + expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 }); expect(out.summary).toMatchObject({ incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770, costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0, diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index ebeebe00..1a1a013c 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -196,6 +196,10 @@ async function loadCosts({ from, to, cur }) { let totalNet = 0; let totalVat = 0; let totalGross = 0; + // Input VAT is only reclaimable for domestic-style treatments; foreign + // non-reclaimable VAT is a cost, not a deduction. Feeds the report's + // VAT-payable (output − reclaimable input). + let reclaimableVat = 0; // Inclusive upper bound covering the whole `to` day. Plain range comparison // (no SQL date() function) so it's valid on both Postgres and SQLite — the // mocked unit tests can't catch a PG-only function error. invoice_date is a @@ -207,6 +211,7 @@ async function loadCosts({ from, to, cur }) { totalNet += r.netMinor; totalVat += r.vatMinor; totalGross += r.totalMinor; + if (r.taxTreatment !== 'foreign_vat_non_reclaimable') reclaimableVat += r.vatMinor; }; // 1) Incoming invoices (external supplier payables). @@ -329,7 +334,20 @@ async function loadCosts({ from, to, cur }) { // Stable chronological order across both sources. rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); - return { rows, totalNet, totalVat, totalGross }; + return { rows, totalNet, totalVat, totalGross, reclaimableVat }; +} + +// Is the business VAT-registered? (Settings → Accounting). Returns null when the +// setting was never set, so the caller can fall back to a behaviour-preserving +// heuristic (charged output VAT this period ⇒ treat as registered). +async function getVatRegisteredSetting() { + try { + const v = await getAppSetting('accounting_vat_registered'); + if (v === undefined || v === null) return null; + return v === true || v === 1 || v === '1' || v === 'true'; + } catch (_) { + return null; + } } /** @@ -468,7 +486,7 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { // SUPPLEMENTARY — if it fails (e.g. an accounting table/column missing // on an older install) it must NOT take down the core revenue report. // Degrade to empty costs + log the real error for diagnosis. - let costs = { rows: [], totalNet: 0, totalVat: 0, totalGross: 0 }; + let costs = { rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 }; let costsError = null; if (includeCosts) { try { @@ -479,10 +497,18 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { } } + // VAT-payable honours the accounting settings: when NOT VAT-registered the + // business doesn't file VAT (payable = 0); when registered it's output VAT + // minus the RECLAIMABLE input VAT only (foreign non-reclaimable cost VAT is + // not deducted). Guideline figure — verify with your Treuhänder. + let vatRegistered = await getVatRegisteredSetting(); + // Unset → preserve prior behaviour: if the business charged output VAT this + // period it's effectively registered; otherwise treat as small-business. + if (vatRegistered === null) vatRegistered = grandTotalVat > 0; + const reclaimableInputVat = costs.reclaimableVat != null ? costs.reclaimableVat : costs.totalVat; + // Summary: income vs cost vs result. Result = a simplified - // Einnahmen-Ausgaben surplus (net basis); vatPayable = output VAT - // minus input VAT (a guideline figure — actual MWST filing depends - // on tax_treatment per cost; verify with your Treuhänder). + // Einnahmen-Ausgaben surplus (net basis). const summary = { incomeNetMinor: grandTotalNet, incomeVatMinor: grandTotalVat, @@ -492,7 +518,8 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { costGrossMinor: costs.totalGross, resultNetMinor: grandTotalNet - costs.totalNet, resultGrossMinor: grandTotal - costs.totalGross, - vatPayableMinor: grandTotalVat - costs.totalVat, + vatRegistered, + vatPayableMinor: vatRegistered ? (grandTotalVat - reclaimableInputVat) : 0, }; // Unified ledger (#5 — one typed, signed, sortable list). Outgoing