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:
@@ -367,7 +367,7 @@ describe('getTaxReport', () => {
|
|||||||
const out = await taxReportService.getTaxReport({
|
const out = await taxReportService.getTaxReport({
|
||||||
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
|
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({
|
expect(out.summary).toMatchObject({
|
||||||
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
|
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
|
||||||
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
|
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ async function loadCosts({ from, to, cur }) {
|
|||||||
let totalNet = 0;
|
let totalNet = 0;
|
||||||
let totalVat = 0;
|
let totalVat = 0;
|
||||||
let totalGross = 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
|
// 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
|
// (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
|
// 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;
|
totalNet += r.netMinor;
|
||||||
totalVat += r.vatMinor;
|
totalVat += r.vatMinor;
|
||||||
totalGross += r.totalMinor;
|
totalGross += r.totalMinor;
|
||||||
|
if (r.taxTreatment !== 'foreign_vat_non_reclaimable') reclaimableVat += r.vatMinor;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1) Incoming invoices (external supplier payables).
|
// 1) Incoming invoices (external supplier payables).
|
||||||
@@ -329,7 +334,20 @@ async function loadCosts({ from, to, cur }) {
|
|||||||
// Stable chronological order across both sources.
|
// Stable chronological order across both sources.
|
||||||
rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
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
|
// SUPPLEMENTARY — if it fails (e.g. an accounting table/column missing
|
||||||
// on an older install) it must NOT take down the core revenue report.
|
// on an older install) it must NOT take down the core revenue report.
|
||||||
// Degrade to empty costs + log the real error for diagnosis.
|
// 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;
|
let costsError = null;
|
||||||
if (includeCosts) {
|
if (includeCosts) {
|
||||||
try {
|
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
|
// Summary: income vs cost vs result. Result = a simplified
|
||||||
// Einnahmen-Ausgaben surplus (net basis); vatPayable = output VAT
|
// Einnahmen-Ausgaben surplus (net basis).
|
||||||
// minus input VAT (a guideline figure — actual MWST filing depends
|
|
||||||
// on tax_treatment per cost; verify with your Treuhänder).
|
|
||||||
const summary = {
|
const summary = {
|
||||||
incomeNetMinor: grandTotalNet,
|
incomeNetMinor: grandTotalNet,
|
||||||
incomeVatMinor: grandTotalVat,
|
incomeVatMinor: grandTotalVat,
|
||||||
@@ -492,7 +518,8 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
|
|||||||
costGrossMinor: costs.totalGross,
|
costGrossMinor: costs.totalGross,
|
||||||
resultNetMinor: grandTotalNet - costs.totalNet,
|
resultNetMinor: grandTotalNet - costs.totalNet,
|
||||||
resultGrossMinor: grandTotal - costs.totalGross,
|
resultGrossMinor: grandTotal - costs.totalGross,
|
||||||
vatPayableMinor: grandTotalVat - costs.totalVat,
|
vatRegistered,
|
||||||
|
vatPayableMinor: vatRegistered ? (grandTotalVat - reclaimableInputVat) : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unified ledger (#5 — one typed, signed, sortable list). Outgoing
|
// Unified ledger (#5 — one typed, signed, sortable list). Outgoing
|
||||||
|
|||||||
Reference in New Issue
Block a user