feat(accounting): tax report VAT-payable honours registration + reclaim

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.
This commit is contained in:
Luca
2026-06-16 00:33:10 +02:00
parent 4d87684882
commit d7107aaf0a
2 changed files with 34 additions and 7 deletions
@@ -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,
+33 -6
View File
@@ -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