From 545ef334f42d3cb09e63ffc77a80dd684ab5a807 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:09:13 +0200 Subject: [PATCH] feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case: - taxReportService.getTaxReport now returns a cost side (loadCosts: incoming invoices + internal expenses, company- or event-booked, schema-guarded) plus a summary (income / costs / result, VAT payable) - declined/duplicate costs excluded; re-billed costs kept (matching re-bill revenue is counted, so the net is correct) - CSV + PDF exports gain a Costs section and an income/costs/result summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine — flag for native review) - frontend tax page renders the summary card, a costs table (company vs event), and a 'verify with Treuhänder' disclaimer - tax-report tests cover the cost aggregation + zeroed summary when the accounting tables are absent; adminCrmAuth test enables the accounting master flag the route now requires fr/nl/pt/ru strings are machine-generated and need native review. --- backend/__tests__/routes/adminCrmAuth.test.js | 4 +- .../__tests__/services/taxReportPdf.test.js | 17 +- .../services/taxReportService.test.js | 116 +++++++- backend/src/services/pdf-i18n.js | 72 +++++ backend/src/services/taxReportService.js | 274 +++++++++++++++++- frontend/src/i18n/locales/de.json | 22 +- frontend/src/i18n/locales/en.json | 22 +- .../src/pages/admin/clients/TaxReportPage.tsx | 144 ++++++++- frontend/src/services/taxReport.service.ts | 46 +++ 9 files changed, 701 insertions(+), 16 deletions(-) diff --git a/backend/__tests__/routes/adminCrmAuth.test.js b/backend/__tests__/routes/adminCrmAuth.test.js index 567f0a6f..3365994c 100644 --- a/backend/__tests__/routes/adminCrmAuth.test.js +++ b/backend/__tests__/routes/adminCrmAuth.test.js @@ -72,7 +72,9 @@ describe('admin CRM routes — auth + permission gate', () => { // of permissions, so for happy-path tests we flip every CRM flag // on. Negative tests (no-token, bad-signature) hit adminAuth // first and never reach the flag check, so they're unaffected. - const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients']; + // `accounting` is the master flag the tax-report route now requires + // (tax export moved out of CRM into Accounting, independent of bills). + const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting']; for (const key of crmFlags) { // eslint-disable-next-line no-await-in-loop await db('feature_flags').where({ key }).update({ value: 1 }); diff --git a/backend/__tests__/services/taxReportPdf.test.js b/backend/__tests__/services/taxReportPdf.test.js index 9838a7da..61754eea 100644 --- a/backend/__tests__/services/taxReportPdf.test.js +++ b/backend/__tests__/services/taxReportPdf.test.js @@ -22,19 +22,26 @@ function makeChain(initialRows) { }, leftJoin: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }), + whereNot: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }), + whereNotIn: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }), + whereRaw: jest.fn(function () { return this; }), orderBy: jest.fn(function () { return this; }), + orderByRaw: jest.fn(function () { return this; }), select: jest.fn(function () { return Promise.resolve(this._rows); }), }; } const mockDbFn = jest.fn((tableName) => { - callCount += 1; // Route by table name when supplied — the Skonto aggregate (added - // by migration 126) queries `invoice_payment_log`; everything else - // (main listing, replacements lookup) hits `invoices`. + // by migration 126) queries `invoice_payment_log`; the #4 cost side + // queries `inbound_documents` + `expenses`; everything else (main + // listing, replacements lookup) hits `invoices`. if (tableName === 'invoice_payment_log') return makeChain([]); + if (tableName === 'inbound_documents') return makeChain([]); + if (tableName === 'expenses') return makeChain([]); + callCount += 1; if (callCount === 1) return makeChain(invoiceRowsForRun); return makeChain(replacementsRowsForRun); }); @@ -42,6 +49,10 @@ const mockDbFn = jest.fn((tableName) => { // COALESCE (migration 123). The chain's select() ignores its // arguments so the raw() return value just needs to exist. mockDbFn.raw = jest.fn((sql) => sql); +// #4 loadCosts schema-guards each cost table; default the PDF/CSV +// fixtures to "no accounting tables" so these renderers exercise the +// revenue path unchanged. +mockDbFn.schema = { hasTable: jest.fn(async () => false) }; jest.mock('../../src/database/db', () => ({ db: mockDbFn, diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index a6fcb5b7..0e7a93e7 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -22,6 +22,9 @@ let invoiceRowsForRun = []; let replacementsRowsForRun = []; +let inboundRowsForRun = []; +let expenseRowsForRun = []; +let costTablesPresent = false; let callCount = 0; function makeChain(initialRows) { @@ -32,24 +35,35 @@ function makeChain(initialRows) { }, leftJoin: jest.fn(function () { return this; }), where: jest.fn(function () { return this; }), + whereNot: jest.fn(function () { return this; }), whereIn: jest.fn(function () { return this; }), + whereNotIn: jest.fn(function () { return this; }), whereBetween: jest.fn(function () { return this; }), + whereRaw: jest.fn(function () { return this; }), orderBy: jest.fn(function () { return this; }), + orderByRaw: jest.fn(function () { return this; }), select: jest.fn(function () { return Promise.resolve(this._rows); }), }; return c; } const mockDbFn = jest.fn((tableName) => { - callCount += 1; // Migration 126 added a Skonto aggregate that hits // `invoice_payment_log` — route those explicitly to an empty list so // the test surface stays focused on the invoices/replacements flow. if (tableName === 'invoice_payment_log') return makeChain([]); - // First call: main listing. Second call: replacements lookup. + // Cost side (#4): incoming invoices + internal expenses. + if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun); + if (tableName === 'expenses') return makeChain(expenseRowsForRun); + // `invoices` is queried for the main listing (call 1) and, when there + // are cancelled rows, the replacements lookup (call 2). + callCount += 1; if (callCount === 1) return makeChain(invoiceRowsForRun); return makeChain(replacementsRowsForRun); }); +// loadCosts (#4) schema-guards each cost table. Default off so the +// revenue-only tests are unaffected; cost-side tests flip it on. +mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) }; // `.raw()` is used in the .select() column list for the event_name // COALESCE (migration 123). The chain's select() ignores its // arguments and returns the mocked rows, so the raw() return value @@ -67,6 +81,9 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport beforeEach(() => { invoiceRowsForRun = []; replacementsRowsForRun = []; + inboundRowsForRun = []; + expenseRowsForRun = []; + costTablesPresent = false; callCount = 0; mockDbFn.mockClear(); }); @@ -335,4 +352,99 @@ describe('getTaxReport', () => { expect(out.totalsByVatRate).toEqual([]); expect(out.cancelledCount).toBe(0); }); + + it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => { + invoiceRowsForRun = [ + { + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 7.7, + net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'X', + }, + ]; + costTablesPresent = false; // no accounting migrations on this DB + 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.summary).toMatchObject({ + incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770, + costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0, + resultNetMinor: 10000, resultGrossMinor: 10770, vatPayableMinor: 770, + }); + }); + + it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => { + invoiceRowsForRun = [ + { + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 7.7, + net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'Wedding A', + }, + ]; + costTablesPresent = true; + // Incoming supplier invoice: net 20000 + vat 1540 = 21540. + inboundRowsForRun = [ + { + id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00', + supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'categorized', event_id: 7, + net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540, + event_name: 'Wedding A', + }, + ]; + // Internal expense (mileage, no VAT split): only a CHF base amount. + expenseRowsForRun = [ + { + id: 9, created_at: '2026-02-01 12:00:00', + supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'open', event_id: null, + original_currency: null, original_amount_minor: null, chf_amount_minor: 5000, + net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, + event_name: null, + }, + ]; + const out = await taxReportService.getTaxReport({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + + expect(out.costs.rows).toHaveLength(2); + // Incoming invoice mapped + booked to the event. + const incoming = out.costs.rows.find((r) => r.source === 'incoming'); + expect(incoming).toMatchObject({ + supplierLabel: 'Lab AG', eventName: 'Wedding A', + netMinor: 20000, vatMinor: 1540, totalMinor: 21540, + }); + // Expense: no net/vat/gross → falls back to the CHF base as total, + // and (company-booked) event name blank. + const expense = out.costs.rows.find((r) => r.source === 'expense'); + expect(expense).toMatchObject({ + eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000, + }); + + expect(out.costs.totalNet).toBe(25000); + expect(out.costs.totalVat).toBe(1540); + expect(out.costs.totalGross).toBe(26540); + + // Summary nets income against costs. + expect(out.summary).toMatchObject({ + incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700, + costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540, + resultNetMinor: 75000, resultGrossMinor: 81160, vatPayableMinor: 6160, + }); + }); + + it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => { + costTablesPresent = true; + inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed + expenseRowsForRun = []; + const out = await taxReportService.getTaxReport({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + expect(out.costs.totalGross).toBe(0); + expect(out.summary.costGrossMinor).toBe(0); + }); }); diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js index 3f241ab2..244b61e5 100644 --- a/backend/src/services/pdf-i18n.js +++ b/backend/src/services/pdf-i18n.js @@ -89,6 +89,18 @@ const LABELS = { tax_grand_total_gross: 'Total gross', tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).', tax_no_invoices: 'No invoices in this period.', + tax_costs_section: 'Costs (incoming invoices + expenses)', + tax_summary_section: 'Summary (income / costs)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Supplier / description', + tax_cost_col_tax_treatment: 'Tax treatment', + tax_cost_source_incoming: 'Incoming invoice', + tax_cost_source_expense: 'Expense', + tax_cost_total: 'Total costs', + tax_summary_income: 'Income', + tax_summary_costs: 'Costs', + tax_summary_result: 'Result', + tax_summary_vat_payable: 'VAT payable (output − input)', // Contracts (migration 130). Section labels stay in sync with the // SECTIONS_ORDER enum in contractService. contract_title: 'Contract', @@ -197,6 +209,18 @@ const LABELS = { tax_grand_total_gross: 'Gesamt Brutto', tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).', tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.', + tax_costs_section: 'Kosten (Eingangsrechnungen + Spesen)', + tax_summary_section: 'Zusammenfassung (Einnahmen / Ausgaben)', + tax_cost_col_source: 'Art', + tax_cost_col_supplier: 'Lieferant / Beschreibung', + tax_cost_col_tax_treatment: 'Steuerliche Behandlung', + tax_cost_source_incoming: 'Eingangsrechnung', + tax_cost_source_expense: 'Spese', + tax_cost_total: 'Summe Kosten', + tax_summary_income: 'Einnahmen', + tax_summary_costs: 'Ausgaben', + tax_summary_result: 'Ergebnis', + tax_summary_vat_payable: 'MWST-Zahllast (Umsatz- − Vorsteuer)', contract_title: 'Vertrag', contract_number_label: 'Vertragsnummer', section_basics: 'Vertragsgrundlagen', @@ -298,6 +322,18 @@ const LABELS = { tax_grand_total_gross: 'Total brut', tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).', tax_no_invoices: 'Aucune facture sur cette période.', + tax_costs_section: 'Charges (factures entrantes + frais)', + tax_summary_section: 'Résumé (revenus / charges)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Fournisseur / description', + tax_cost_col_tax_treatment: 'Traitement fiscal', + tax_cost_source_incoming: 'Facture entrante', + tax_cost_source_expense: 'Frais', + tax_cost_total: 'Total des charges', + tax_summary_income: 'Revenus', + tax_summary_costs: 'Charges', + tax_summary_result: 'Résultat', + tax_summary_vat_payable: 'TVA à payer (collectée − déductible)', }, nl: { // Machine-translated, flagged for native review. @@ -368,6 +404,18 @@ const LABELS = { tax_grand_total_gross: 'Totaal bruto', tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).', tax_no_invoices: 'Geen facturen in deze periode.', + tax_costs_section: 'Kosten (inkomende facturen + onkosten)', + tax_summary_section: 'Samenvatting (inkomsten / kosten)', + tax_cost_col_source: 'Type', + tax_cost_col_supplier: 'Leverancier / omschrijving', + tax_cost_col_tax_treatment: 'Fiscale behandeling', + tax_cost_source_incoming: 'Inkomende factuur', + tax_cost_source_expense: 'Onkosten', + tax_cost_total: 'Totale kosten', + tax_summary_income: 'Inkomsten', + tax_summary_costs: 'Kosten', + tax_summary_result: 'Resultaat', + tax_summary_vat_payable: 'Te betalen btw (af − voor)', }, pt: { // Machine-translated, flagged for native review. @@ -438,6 +486,18 @@ const LABELS = { tax_grand_total_gross: 'Total bruto', tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).', tax_no_invoices: 'Sem faturas neste período.', + tax_costs_section: 'Custos (faturas recebidas + despesas)', + tax_summary_section: 'Resumo (receitas / custos)', + tax_cost_col_source: 'Tipo', + tax_cost_col_supplier: 'Fornecedor / descrição', + tax_cost_col_tax_treatment: 'Tratamento fiscal', + tax_cost_source_incoming: 'Fatura recebida', + tax_cost_source_expense: 'Despesa', + tax_cost_total: 'Total de custos', + tax_summary_income: 'Receitas', + tax_summary_costs: 'Custos', + tax_summary_result: 'Resultado', + tax_summary_vat_payable: 'IVA a pagar (cobrado − dedutível)', }, ru: { // Machine-translated, flagged for native review. @@ -508,6 +568,18 @@ const LABELS = { tax_grand_total_gross: 'Итого брутто', tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).', tax_no_invoices: 'Нет счетов за этот период.', + tax_costs_section: 'Расходы (входящие счета + издержки)', + tax_summary_section: 'Итоги (доходы / расходы)', + tax_cost_col_source: 'Тип', + tax_cost_col_supplier: 'Поставщик / описание', + tax_cost_col_tax_treatment: 'Налоговый режим', + tax_cost_source_incoming: 'Входящий счёт', + tax_cost_source_expense: 'Расход', + tax_cost_total: 'Итого расходы', + tax_summary_income: 'Доходы', + tax_summary_costs: 'Расходы', + tax_summary_result: 'Результат', + tax_summary_vat_payable: 'НДС к уплате (исходящий − входящий)', }, }; diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 7fd33efe..7423923e 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -157,6 +157,161 @@ async function loadSkontoMap(invoiceIds) { return map; } +/** + * Cost side of the Milchbüchlein view (Einnahmen-Ausgaben-Rechnung). + * + * Aggregates the two cost entities the Accounting feature tracks, both + * keyed on an accrual date inside [from, to] and scoped to `cur`: + * + * 1. incoming invoices (`inbound_documents`) — external supplier + * payables. Accrual date = invoice_date, falling back to created_at. + * Excludes declined + duplicate rows (not real costs). + * 2. expenses (`expenses`) — internal own-costs (mileage / per-diem / + * amount). Accrual date = created_at (no separate invoice date on + * internal expenses). Excludes declined status + duplikat/abgelehnt + * disposition. + * + * Both book to an event OR the company (event_id NULL = company); the + * report surfaces every cost regardless so the total is the full + * outflow for the period. Re-billed costs intentionally stay IN — the + * matching re-bill revenue is already counted on the income side, so + * keeping both sides nets correctly (a pure pass-through cancels out). + * + * Currency: the report is single-currency. Incoming invoices match on + * their own `currency`. Internal expenses are stored in CHF base + * (chf_amount_minor) plus an optional original-currency amount — for a + * CHF report we use the CHF base; for a foreign-currency report we match + * the expense's original_currency and use original_amount_minor. + * + * Tables are schema-guarded: a DB without the accounting migrations + * yields an empty cost side rather than throwing. + * + * Returns { rows, totalNet, totalVat, totalGross } in minor units. + */ +function normMinor(v) { return ensureInt(v); } + +async function loadCosts({ from, to, cur }) { + const rows = []; + let totalNet = 0; + let totalVat = 0; + let totalGross = 0; + + const push = (r) => { + rows.push(r); + totalNet += r.netMinor; + totalVat += r.vatMinor; + totalGross += r.totalMinor; + }; + + // 1) Incoming invoices (external supplier payables). + if (await db.schema.hasTable('inbound_documents')) { + const inbound = await db('inbound_documents') + .leftJoin('events', 'inbound_documents.event_id', 'events.id') + .whereRaw('date(COALESCE(inbound_documents.invoice_date, inbound_documents.created_at)) BETWEEN ? AND ?', [from, to]) + .where('inbound_documents.currency', cur) + .whereNotIn('inbound_documents.status', ['declined', 'duplicate']) + .orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc') + .select( + 'inbound_documents.id', + 'inbound_documents.invoice_date', + 'inbound_documents.created_at', + 'inbound_documents.supplier_name', + 'inbound_documents.description', + 'inbound_documents.disposition', + 'inbound_documents.tax_treatment', + 'inbound_documents.status', + 'inbound_documents.event_id', + 'inbound_documents.net_amount_minor', + 'inbound_documents.vat_amount_minor', + 'inbound_documents.total_amount_minor', + 'events.event_name as event_name', + ); + for (const r of inbound) { + const vat = normMinor(r.vat_amount_minor); + let total = normMinor(r.total_amount_minor); + let net = normMinor(r.net_amount_minor); + if (!total && (net || vat)) total = net + vat; + if (!net && total) net = total - vat; + push({ + id: r.id, + source: 'incoming', + date: r.invoice_date || r.created_at, + supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '', + description: r.description || '', + eventName: r.event_id ? (r.event_name || '') : '', + disposition: r.disposition || '', + taxTreatment: r.tax_treatment || 'domestic', + status: r.status || '', + netMinor: net, + vatMinor: vat, + totalMinor: total, + }); + } + } + + // 2) Internal expenses (own-costs). + if (await db.schema.hasTable('expenses')) { + const isChf = cur === 'CHF'; + const q = db('expenses') + .leftJoin('events', 'expenses.event_id', 'events.id') + .whereRaw('date(expenses.created_at) BETWEEN ? AND ?', [from, to]) + .whereNot('expenses.status', 'declined') + .whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt']); + // CHF report includes every expense (all carry a CHF base). A + // foreign-currency report matches the expense's original currency. + if (!isChf) q.where('expenses.original_currency', cur); + const expenses = await q + .orderBy('expenses.created_at', 'asc') + .select( + 'expenses.id', + 'expenses.created_at', + 'expenses.supplier_name', + 'expenses.description', + 'expenses.disposition', + 'expenses.tax_treatment', + 'expenses.status', + 'expenses.event_id', + 'expenses.original_currency', + 'expenses.original_amount_minor', + 'expenses.chf_amount_minor', + 'expenses.net_amount_minor', + 'expenses.vat_amount_minor', + 'expenses.gross_amount_minor', + 'events.event_name as event_name', + ); + for (const r of expenses) { + const vat = normMinor(r.vat_amount_minor); + let net = normMinor(r.net_amount_minor); + let total = normMinor(r.gross_amount_minor); + // Fallback to the single stored amount when net/vat/gross are not + // broken out (internal mileage/per-diem expenses carry only a base + // amount, no VAT split). + const base = isChf ? normMinor(r.chf_amount_minor) : normMinor(r.original_amount_minor); + if (!total) total = (net || vat) ? net + vat : base; + if (!net) net = total - vat; + push({ + id: r.id, + source: 'expense', + date: r.created_at, + supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '', + description: r.description || '', + eventName: r.event_id ? (r.event_name || '') : '', + disposition: r.disposition || '', + taxTreatment: r.tax_treatment || 'domestic', + status: r.status || '', + netMinor: net, + vatMinor: vat, + totalMinor: total, + }); + } + } + + // Stable chronological order across both sources. + rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))); + + return { rows, totalNet, totalVat, totalGross }; +} + /** * The main entry point. * @@ -166,8 +321,13 @@ async function loadSkontoMap(invoiceIds) { * required and must match `invoices.currency` exactly — mixing * currencies in one report is unsound for tax filing, so the API * forces a single-currency view. + * + * `includeCosts` (default true) adds the Einnahmen-Ausgaben cost side + * (incoming invoices + expenses) plus a `summary` block (income vs cost + * vs result, and VAT payable = output VAT − input VAT). Pass false to + * get the legacy revenue-only shape. */ -async function getTaxReport({ from, to, currency } = {}) { +async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { if (!from || !to) { throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)'); } @@ -283,6 +443,28 @@ async function getTaxReport({ from, to, currency } = {}) { const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate); + // Cost side (Einnahmen-Ausgaben). Optional so legacy callers that + // only want the revenue listing can opt out. + const costs = includeCosts + ? await loadCosts({ from, to, cur }) + : { rows: [], totalNet: 0, totalVat: 0, totalGross: 0 }; + + // 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). + const summary = { + incomeNetMinor: grandTotalNet, + incomeVatMinor: grandTotalVat, + incomeGrossMinor: grandTotal, + costNetMinor: costs.totalNet, + costVatMinor: costs.totalVat, + costGrossMinor: costs.totalGross, + resultNetMinor: grandTotalNet - costs.totalNet, + resultGrossMinor: grandTotal - costs.totalGross, + vatPayableMinor: grandTotalVat - costs.totalVat, + }; + return { rows, totalsByVatRate, @@ -290,6 +472,8 @@ async function getTaxReport({ from, to, currency } = {}) { grandTotalVat, grandTotal, cancelledCount, + costs, + summary, currency: cur, period: { from, to }, }; @@ -585,7 +769,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { // otherwise PDFKit auto-paginates mid-totals, creating phantom // pages whose footer ends up at unexpected Y positions on the // subsequent bufferedPageRange loop. - const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12; + const hasCostSummary = report.summary && (report.costs?.rows?.length || report.costs?.totalGross); + const summaryHeight = hasCostSummary ? (10 + 6 + (3 * 13) + 12 + 12) : 0; + const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12 + summaryHeight; const footerReserve = 24; // 12 above + 12 of page-number text room if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) { doc.addPage({ @@ -636,6 +822,32 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { doc.text(formatMinor(report.grandTotal, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' }); + // Einnahmen-Ausgaben summary (income vs costs vs result). Only + // rendered when the report carries a cost side. Compact 4-line + // block beneath the revenue grand totals. + if (report.summary && (report.costs?.rows?.length || report.costs?.totalGross)) { + const s = report.summary; + ty += 10; + doc.moveTo(totalsX, ty).lineTo(totalsX + totalsBoxWidth, ty) + .lineWidth(0.6).strokeColor('#000').stroke(); + ty += 6; + const summaryLine = (labelKey, netMinor, grossMinor, bold) => { + doc.font(bold ? fonts.bold : fonts.body).fontSize(9); + doc.text(t(useLocale, labelKey), totalsX, ty, { width: 170, align: 'left' }); + doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' }); + doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' }); + ty += 13; + }; + summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeGrossMinor, false); + summaryLine('tax_summary_costs', s.costNetMinor, s.costGrossMinor, false); + summaryLine('tax_summary_result', s.resultNetMinor, s.resultGrossMinor, true); + doc.font(fonts.body).fontSize(8).fillColor('#555') + .text(`${t(useLocale, 'tax_summary_vat_payable')}: ${formatMinor(s.vatPayableMinor, report.currency, intlLocale)}`, + totalsX, ty, { width: totalsBoxWidth, align: 'left' }); + doc.fillColor('#000'); + ty += 12; + } + // Cancelled footnote (bottom-left). Only when there are any. if (report.cancelledCount > 0) { doc.font(fonts.body).fontSize(8).fillColor('#555') @@ -746,6 +958,62 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) { '', '', ].map(escape).join(',')); + // Cost side (Einnahmen-Ausgaben). Appended below the revenue block as + // its own labelled section so the accountant gets income + costs + + // result in one file. + const costs = report.costs || { rows: [], totalNet: 0, totalVat: 0, totalGross: 0 }; + if (costs.rows.length || costs.totalGross) { + lines.push(''); + lines.push(escape(t(useLocale, 'tax_costs_section'))); + const costHeaders = [ + t(useLocale, 'tax_col_no'), + t(useLocale, 'tax_col_date'), + t(useLocale, 'tax_cost_col_source'), + t(useLocale, 'tax_cost_col_supplier'), + t(useLocale, 'tax_col_event'), + t(useLocale, 'tax_cost_col_tax_treatment'), + `${t(useLocale, 'tax_col_net')} (${report.currency})`, + `${t(useLocale, 'tax_col_vat')} (${report.currency})`, + `${t(useLocale, 'tax_col_total')} (${report.currency})`, + ]; + lines.push(costHeaders.map(escape).join(',')); + costs.rows.forEach((row, i) => { + lines.push([ + i + 1, + row.date, + t(useLocale, row.source === 'incoming' ? 'tax_cost_source_incoming' : 'tax_cost_source_expense'), + row.supplierLabel || row.description || '', + row.eventName || '', + row.taxTreatment || '', + minorToDotDecimal(row.netMinor), + minorToDotDecimal(row.vatMinor), + minorToDotDecimal(row.totalMinor), + ].map(escape).join(',')); + }); + lines.push([ + '', '', '', + t(useLocale, 'tax_cost_total'), + '', '', + minorToDotDecimal(costs.totalNet), + minorToDotDecimal(costs.totalVat), + minorToDotDecimal(costs.totalGross), + ].map(escape).join(',')); + } + + // Summary block: income vs costs vs result + VAT payable. + const summary = report.summary; + if (summary) { + lines.push(''); + lines.push(escape(t(useLocale, 'tax_summary_section'))); + const sline = (labelKey, net, vat, gross) => lines.push([ + '', '', '', t(useLocale, labelKey), '', '', + minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross), + ].map(escape).join(',')); + sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor); + sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor); + sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor); + } + const content = lines.join('\r\n') + '\r\n'; const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`; return { content, filename, contentType: 'text/csv; charset=utf-8' }; @@ -756,5 +1024,5 @@ module.exports = { renderTaxReportPdf, renderTaxReportCsv, // Exposed for unit tests. - _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate }, + _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts }, }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 36f83cc3..3c7a7570 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3769,6 +3769,25 @@ "grandTotalVat": "Gesamt MwSt.", "grandTotalGross": "Gesamt Brutto", "cancelledFootnote": "{{count}} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).", + "skontoTooltip": "Mit Skonto bezahlt", + "costsTitle": "Kosten (Eingangsrechnungen + Spesen)", + "costsDisclaimer": "Diese Einnahmen-Ausgaben-Übersicht ist eine Orientierungshilfe für Ihre Aufzeichnungen (Milchbüchleinrechnung). Vorsteuerabzug und Ergebnis hängen von der steuerlichen Behandlung jeder Kostenposition ab — vor der Einreichung mit Ihrem Treuhänder / der Steuerverwaltung prüfen.", + "summary": { + "title": "Einnahmen / Ausgaben", + "income": "Einnahmen", + "costs": "Ausgaben", + "result": "Ergebnis", + "vatPayable": "MWST-Zahllast (Umsatz- − Vorsteuer)" + }, + "cost": { + "source": "Art", + "sourceIncoming": "Eingang", + "sourceExpense": "Spese", + "supplier": "Lieferant / Beschreibung", + "taxTreatment": "Steuerliche Behandlung", + "company": "Unternehmen", + "total": "Summe Kosten" + }, "col": { "date": "Datum", "invoice": "Rechnung", @@ -3777,7 +3796,8 @@ "vatRate": "MwSt %", "net": "Netto", "vat": "MwSt.", - "total": "Brutto" + "total": "Brutto", + "skonto": "Skonto" } }, "quotes": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 02e52f04..2493ffff 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3769,6 +3769,25 @@ "grandTotalVat": "Total VAT", "grandTotalGross": "Total gross", "cancelledFootnote": "{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).", + "skontoTooltip": "Paid with Skonto", + "costsTitle": "Costs (incoming invoices + expenses)", + "costsDisclaimer": "This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.", + "summary": { + "title": "Income / costs", + "income": "Income", + "costs": "Costs", + "result": "Result", + "vatPayable": "VAT payable (output − input)" + }, + "cost": { + "source": "Type", + "sourceIncoming": "Incoming", + "sourceExpense": "Expense", + "supplier": "Supplier / description", + "taxTreatment": "Tax treatment", + "company": "Company", + "total": "Total costs" + }, "col": { "date": "Date", "invoice": "Invoice", @@ -3777,7 +3796,8 @@ "vatRate": "VAT %", "net": "Net", "vat": "VAT", - "total": "Gross" + "total": "Gross", + "skonto": "Skonto" } }, "quotes": { diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index d60c3ade..890980ba 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -143,7 +143,9 @@ export const TaxReportPage: React.FC = () => { // rates in the period. With a single rate the breakdown is just a // restatement of the grand totals — pure noise. const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1; - const exportsDisabled = isLoading || isExporting !== null || !report || report.rows.length === 0; + const hasCosts = (report?.costs?.rows.length || 0) > 0; + const hasAnyData = !!report && (report.rows.length > 0 || hasCosts); + const exportsDisabled = isLoading || isExporting !== null || !hasAnyData; return (
@@ -260,7 +262,7 @@ export const TaxReportPage: React.FC = () => { there are 2+ rates in the period (otherwise it duplicates the grand totals). Cancelled footnote at the bottom when applicable. */} - {report && report.rows.length > 0 && ( + {hasAnyData && report && (
@@ -283,6 +285,45 @@ export const TaxReportPage: React.FC = () => {
+ {/* Einnahmen-Ausgaben summary (#4): income vs costs vs + result. Only when there is a cost side. The result line + is the simplified surplus a Milchbüchlein needs; VAT + payable is a guideline (depends on each cost's tax + treatment — see disclaimer below the cost table). */} + {hasCosts && report.summary && ( +
+

+ {t('taxReport.summary.title', 'Income / costs')} +

+
+
+ {t('taxReport.summary.income', 'Income')} + + {formatMinor(report.summary.incomeGrossMinor, report.currency, intlLocale)} + +
+
+ {t('taxReport.summary.costs', 'Costs')} + + −{formatMinor(report.summary.costGrossMinor, report.currency, intlLocale)} + +
+
+ {t('taxReport.summary.result', 'Result')} + + {formatMinor(report.summary.resultGrossMinor, report.currency, intlLocale)} + +
+
+ {t('taxReport.summary.vatPayable', 'VAT payable (output − input)')} + + {formatMinor(report.summary.vatPayableMinor, report.currency, intlLocale)} + +
+
+
+ )} + {showPerRateBreakdown && (

@@ -335,7 +376,7 @@ export const TaxReportPage: React.FC = () => {

- ) : !report || report.rows.length === 0 ? ( + ) : !hasAnyData ? (

{t('taxReport.empty', 'No invoices in this period.')} @@ -343,9 +384,10 @@ export const TaxReportPage: React.FC = () => { ) : ( <> - {/* Table — full width below the filter + totals row above. + {report && report.rows.length > 0 && ( + /* Table — full width below the filter + totals row above. The totals card now lives in the top-right of the page - header so this section is purely the invoice list. */} + header so this section is purely the invoice list. */ {/* Two nested wrappers: the OUTER clips the header row's solid fill so the top corners stay rounded (matches @@ -436,6 +478,98 @@ export const TaxReportPage: React.FC = () => { + )} + + {/* Cost side (#4) — incoming invoices + expenses, company or + event-booked. Shown as its own table beneath the revenue + list so the Einnahmen-Ausgaben picture is complete on one + page. */} + {hasCosts && report && ( + +

+

+ {t('taxReport.costsTitle', 'Costs (incoming invoices + expenses)')} +

+
+
+
+ + + + + + + + + + + + + + + + {report.costs.rows.map((row, i) => ( + + + + + + + + + + + + ))} + + + + + + + + + +
#{t('taxReport.col.date', 'Date')}{t('taxReport.cost.source', 'Type')}{t('taxReport.cost.supplier', 'Supplier / description')}{t('taxReport.col.event', 'Event')}{t('taxReport.cost.taxTreatment', 'Tax treatment')}{t('taxReport.col.net', 'Net')}{t('taxReport.col.vat', 'VAT')}{t('taxReport.col.total', 'Gross')}
{i + 1}{fmtDate(String(row.date).slice(0, 10))} + + {row.source === 'incoming' + ? t('taxReport.cost.sourceIncoming', 'Incoming') + : t('taxReport.cost.sourceExpense', 'Expense')} + + + {row.supplierLabel || row.description || '—'} + + {row.eventName || {t('taxReport.cost.company', 'Company')}} + {row.taxTreatment} + {formatMinor(row.netMinor, report.currency, intlLocale)} + + {formatMinor(row.vatMinor, report.currency, intlLocale)} + + {formatMinor(row.totalMinor, report.currency, intlLocale)} +
{t('taxReport.cost.total', 'Total costs')}{formatMinor(report.costs.totalNet, report.currency, intlLocale)}{formatMinor(report.costs.totalVat, report.currency, intlLocale)}{formatMinor(report.costs.totalGross, report.currency, intlLocale)}
+
+
+
+ )} + + {/* Legal disclaimer — tax figures are a guideline. Per project + rule: any surface touching tax/financial output must point + the user at a professional. */} + {hasCosts && ( +

+ + + {t( + 'taxReport.costsDisclaimer', + 'This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.', + )} + +

+ )} )} diff --git a/frontend/src/services/taxReport.service.ts b/frontend/src/services/taxReport.service.ts index 64ba7e2b..00c4c41a 100644 --- a/frontend/src/services/taxReport.service.ts +++ b/frontend/src/services/taxReport.service.ts @@ -46,6 +46,47 @@ export interface TaxReportBucket { totalMinor: number; } +/** A single cost-side line (#4 — Einnahmen-Ausgaben). Either an external + * incoming invoice (`source: 'incoming'`) or an internal expense + * (`source: 'expense'`). Booked to an event (`eventName`) or the + * company (empty `eventName`). */ +export interface TaxReportCostRow { + id: number; + source: 'incoming' | 'expense'; + date: string; + supplierLabel: string; + description: string; + eventName: string; + disposition: string; + taxTreatment: string; + status: string; + netMinor: number; + vatMinor: number; + totalMinor: number; +} + +export interface TaxReportCosts { + rows: TaxReportCostRow[]; + totalNet: number; + totalVat: number; + totalGross: number; +} + +/** Income vs cost vs result summary (minor units). `vatPayableMinor` = + * output VAT − input VAT (a guideline figure; actual MWST filing + * depends on each cost's tax treatment — verify with a Treuhänder). */ +export interface TaxReportSummary { + incomeNetMinor: number; + incomeVatMinor: number; + incomeGrossMinor: number; + costNetMinor: number; + costVatMinor: number; + costGrossMinor: number; + resultNetMinor: number; + resultGrossMinor: number; + vatPayableMinor: number; +} + export interface TaxReport { rows: TaxReportRow[]; totalsByVatRate: TaxReportBucket[]; @@ -53,6 +94,11 @@ export interface TaxReport { grandTotalVat: number; grandTotal: number; cancelledCount: number; + /** Cost side (#4). Present when the accounting tables exist; empty + * otherwise. */ + costs: TaxReportCosts; + /** Income/cost/result summary (#4). */ + summary: TaxReportSummary; currency: string; period: { from: string; to: string }; }