feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue
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.
This commit is contained in:
@@ -72,7 +72,9 @@ describe('admin CRM routes — auth + permission gate', () => {
|
|||||||
// of permissions, so for happy-path tests we flip every CRM flag
|
// of permissions, so for happy-path tests we flip every CRM flag
|
||||||
// on. Negative tests (no-token, bad-signature) hit adminAuth
|
// on. Negative tests (no-token, bad-signature) hit adminAuth
|
||||||
// first and never reach the flag check, so they're unaffected.
|
// 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) {
|
for (const key of crmFlags) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await db('feature_flags').where({ key }).update({ value: 1 });
|
await db('feature_flags').where({ key }).update({ value: 1 });
|
||||||
|
|||||||
@@ -22,19 +22,26 @@ function makeChain(initialRows) {
|
|||||||
},
|
},
|
||||||
leftJoin: jest.fn(function () { return this; }),
|
leftJoin: jest.fn(function () { return this; }),
|
||||||
where: jest.fn(function () { return this; }),
|
where: jest.fn(function () { return this; }),
|
||||||
|
whereNot: jest.fn(function () { return this; }),
|
||||||
whereIn: jest.fn(function () { return this; }),
|
whereIn: jest.fn(function () { return this; }),
|
||||||
|
whereNotIn: jest.fn(function () { return this; }),
|
||||||
whereBetween: jest.fn(function () { return this; }),
|
whereBetween: jest.fn(function () { return this; }),
|
||||||
|
whereRaw: jest.fn(function () { return this; }),
|
||||||
orderBy: 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); }),
|
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDbFn = jest.fn((tableName) => {
|
const mockDbFn = jest.fn((tableName) => {
|
||||||
callCount += 1;
|
|
||||||
// Route by table name when supplied — the Skonto aggregate (added
|
// Route by table name when supplied — the Skonto aggregate (added
|
||||||
// by migration 126) queries `invoice_payment_log`; everything else
|
// by migration 126) queries `invoice_payment_log`; the #4 cost side
|
||||||
// (main listing, replacements lookup) hits `invoices`.
|
// queries `inbound_documents` + `expenses`; everything else (main
|
||||||
|
// listing, replacements lookup) hits `invoices`.
|
||||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
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);
|
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||||
return makeChain(replacementsRowsForRun);
|
return makeChain(replacementsRowsForRun);
|
||||||
});
|
});
|
||||||
@@ -42,6 +49,10 @@ const mockDbFn = jest.fn((tableName) => {
|
|||||||
// COALESCE (migration 123). The chain's select() ignores its
|
// COALESCE (migration 123). The chain's select() ignores its
|
||||||
// arguments so the raw() return value just needs to exist.
|
// arguments so the raw() return value just needs to exist.
|
||||||
mockDbFn.raw = jest.fn((sql) => sql);
|
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', () => ({
|
jest.mock('../../src/database/db', () => ({
|
||||||
db: mockDbFn,
|
db: mockDbFn,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
|
|
||||||
let invoiceRowsForRun = [];
|
let invoiceRowsForRun = [];
|
||||||
let replacementsRowsForRun = [];
|
let replacementsRowsForRun = [];
|
||||||
|
let inboundRowsForRun = [];
|
||||||
|
let expenseRowsForRun = [];
|
||||||
|
let costTablesPresent = false;
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
|
||||||
function makeChain(initialRows) {
|
function makeChain(initialRows) {
|
||||||
@@ -32,24 +35,35 @@ function makeChain(initialRows) {
|
|||||||
},
|
},
|
||||||
leftJoin: jest.fn(function () { return this; }),
|
leftJoin: jest.fn(function () { return this; }),
|
||||||
where: jest.fn(function () { return this; }),
|
where: jest.fn(function () { return this; }),
|
||||||
|
whereNot: jest.fn(function () { return this; }),
|
||||||
whereIn: jest.fn(function () { return this; }),
|
whereIn: jest.fn(function () { return this; }),
|
||||||
|
whereNotIn: jest.fn(function () { return this; }),
|
||||||
whereBetween: jest.fn(function () { return this; }),
|
whereBetween: jest.fn(function () { return this; }),
|
||||||
|
whereRaw: jest.fn(function () { return this; }),
|
||||||
orderBy: 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); }),
|
select: jest.fn(function () { return Promise.resolve(this._rows); }),
|
||||||
};
|
};
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockDbFn = jest.fn((tableName) => {
|
const mockDbFn = jest.fn((tableName) => {
|
||||||
callCount += 1;
|
|
||||||
// Migration 126 added a Skonto aggregate that hits
|
// Migration 126 added a Skonto aggregate that hits
|
||||||
// `invoice_payment_log` — route those explicitly to an empty list so
|
// `invoice_payment_log` — route those explicitly to an empty list so
|
||||||
// the test surface stays focused on the invoices/replacements flow.
|
// the test surface stays focused on the invoices/replacements flow.
|
||||||
if (tableName === 'invoice_payment_log') return makeChain([]);
|
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);
|
if (callCount === 1) return makeChain(invoiceRowsForRun);
|
||||||
return makeChain(replacementsRowsForRun);
|
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
|
// `.raw()` is used in the .select() column list for the event_name
|
||||||
// COALESCE (migration 123). The chain's select() ignores its
|
// COALESCE (migration 123). The chain's select() ignores its
|
||||||
// arguments and returns the mocked rows, so the raw() return value
|
// arguments and returns the mocked rows, so the raw() return value
|
||||||
@@ -67,6 +81,9 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
invoiceRowsForRun = [];
|
invoiceRowsForRun = [];
|
||||||
replacementsRowsForRun = [];
|
replacementsRowsForRun = [];
|
||||||
|
inboundRowsForRun = [];
|
||||||
|
expenseRowsForRun = [];
|
||||||
|
costTablesPresent = false;
|
||||||
callCount = 0;
|
callCount = 0;
|
||||||
mockDbFn.mockClear();
|
mockDbFn.mockClear();
|
||||||
});
|
});
|
||||||
@@ -335,4 +352,99 @@ describe('getTaxReport', () => {
|
|||||||
expect(out.totalsByVatRate).toEqual([]);
|
expect(out.totalsByVatRate).toEqual([]);
|
||||||
expect(out.cancelledCount).toBe(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Total gross',
|
tax_grand_total_gross: 'Total gross',
|
||||||
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).',
|
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_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
|
// Contracts (migration 130). Section labels stay in sync with the
|
||||||
// SECTIONS_ORDER enum in contractService.
|
// SECTIONS_ORDER enum in contractService.
|
||||||
contract_title: 'Contract',
|
contract_title: 'Contract',
|
||||||
@@ -197,6 +209,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Gesamt Brutto',
|
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_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_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_title: 'Vertrag',
|
||||||
contract_number_label: 'Vertragsnummer',
|
contract_number_label: 'Vertragsnummer',
|
||||||
section_basics: 'Vertragsgrundlagen',
|
section_basics: 'Vertragsgrundlagen',
|
||||||
@@ -298,6 +322,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Total brut',
|
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_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_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: {
|
nl: {
|
||||||
// Machine-translated, flagged for native review.
|
// Machine-translated, flagged for native review.
|
||||||
@@ -368,6 +404,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Totaal bruto',
|
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_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_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: {
|
pt: {
|
||||||
// Machine-translated, flagged for native review.
|
// Machine-translated, flagged for native review.
|
||||||
@@ -438,6 +486,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Total bruto',
|
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_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_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: {
|
ru: {
|
||||||
// Machine-translated, flagged for native review.
|
// Machine-translated, flagged for native review.
|
||||||
@@ -508,6 +568,18 @@ const LABELS = {
|
|||||||
tax_grand_total_gross: 'Итого брутто',
|
tax_grand_total_gross: 'Итого брутто',
|
||||||
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
|
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
|
||||||
tax_no_invoices: 'Нет счетов за этот период.',
|
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: 'НДС к уплате (исходящий − входящий)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,161 @@ async function loadSkontoMap(invoiceIds) {
|
|||||||
return map;
|
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.
|
* The main entry point.
|
||||||
*
|
*
|
||||||
@@ -166,8 +321,13 @@ async function loadSkontoMap(invoiceIds) {
|
|||||||
* required and must match `invoices.currency` exactly — mixing
|
* required and must match `invoices.currency` exactly — mixing
|
||||||
* currencies in one report is unsound for tax filing, so the API
|
* currencies in one report is unsound for tax filing, so the API
|
||||||
* forces a single-currency view.
|
* 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) {
|
if (!from || !to) {
|
||||||
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)');
|
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);
|
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 {
|
return {
|
||||||
rows,
|
rows,
|
||||||
totalsByVatRate,
|
totalsByVatRate,
|
||||||
@@ -290,6 +472,8 @@ async function getTaxReport({ from, to, currency } = {}) {
|
|||||||
grandTotalVat,
|
grandTotalVat,
|
||||||
grandTotal,
|
grandTotal,
|
||||||
cancelledCount,
|
cancelledCount,
|
||||||
|
costs,
|
||||||
|
summary,
|
||||||
currency: cur,
|
currency: cur,
|
||||||
period: { from, to },
|
period: { from, to },
|
||||||
};
|
};
|
||||||
@@ -585,7 +769,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
|||||||
// otherwise PDFKit auto-paginates mid-totals, creating phantom
|
// otherwise PDFKit auto-paginates mid-totals, creating phantom
|
||||||
// pages whose footer ends up at unexpected Y positions on the
|
// pages whose footer ends up at unexpected Y positions on the
|
||||||
// subsequent bufferedPageRange loop.
|
// 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
|
const footerReserve = 24; // 12 above + 12 of page-number text room
|
||||||
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
|
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
|
||||||
doc.addPage({
|
doc.addPage({
|
||||||
@@ -636,6 +822,32 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
|||||||
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
|
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
|
||||||
totalsX + 270, ty, { width: 90, align: 'right' });
|
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.
|
// Cancelled footnote (bottom-left). Only when there are any.
|
||||||
if (report.cancelledCount > 0) {
|
if (report.cancelledCount > 0) {
|
||||||
doc.font(fonts.body).fontSize(8).fillColor('#555')
|
doc.font(fonts.body).fontSize(8).fillColor('#555')
|
||||||
@@ -746,6 +958,62 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
|
|||||||
'', '',
|
'', '',
|
||||||
].map(escape).join(','));
|
].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 content = lines.join('\r\n') + '\r\n';
|
||||||
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
||||||
return { content, filename, contentType: 'text/csv; charset=utf-8' };
|
return { content, filename, contentType: 'text/csv; charset=utf-8' };
|
||||||
@@ -756,5 +1024,5 @@ module.exports = {
|
|||||||
renderTaxReportPdf,
|
renderTaxReportPdf,
|
||||||
renderTaxReportCsv,
|
renderTaxReportCsv,
|
||||||
// Exposed for unit tests.
|
// Exposed for unit tests.
|
||||||
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate },
|
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3769,6 +3769,25 @@
|
|||||||
"grandTotalVat": "Gesamt MwSt.",
|
"grandTotalVat": "Gesamt MwSt.",
|
||||||
"grandTotalGross": "Gesamt Brutto",
|
"grandTotalGross": "Gesamt Brutto",
|
||||||
"cancelledFootnote": "{{count}} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).",
|
"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": {
|
"col": {
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"invoice": "Rechnung",
|
"invoice": "Rechnung",
|
||||||
@@ -3777,7 +3796,8 @@
|
|||||||
"vatRate": "MwSt %",
|
"vatRate": "MwSt %",
|
||||||
"net": "Netto",
|
"net": "Netto",
|
||||||
"vat": "MwSt.",
|
"vat": "MwSt.",
|
||||||
"total": "Brutto"
|
"total": "Brutto",
|
||||||
|
"skonto": "Skonto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quotes": {
|
"quotes": {
|
||||||
|
|||||||
@@ -3769,6 +3769,25 @@
|
|||||||
"grandTotalVat": "Total VAT",
|
"grandTotalVat": "Total VAT",
|
||||||
"grandTotalGross": "Total gross",
|
"grandTotalGross": "Total gross",
|
||||||
"cancelledFootnote": "{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).",
|
"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": {
|
"col": {
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"invoice": "Invoice",
|
"invoice": "Invoice",
|
||||||
@@ -3777,7 +3796,8 @@
|
|||||||
"vatRate": "VAT %",
|
"vatRate": "VAT %",
|
||||||
"net": "Net",
|
"net": "Net",
|
||||||
"vat": "VAT",
|
"vat": "VAT",
|
||||||
"total": "Gross"
|
"total": "Gross",
|
||||||
|
"skonto": "Skonto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quotes": {
|
"quotes": {
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
// rates in the period. With a single rate the breakdown is just a
|
// rates in the period. With a single rate the breakdown is just a
|
||||||
// restatement of the grand totals — pure noise.
|
// restatement of the grand totals — pure noise.
|
||||||
const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -260,7 +262,7 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
there are 2+ rates in the period (otherwise it duplicates
|
there are 2+ rates in the period (otherwise it duplicates
|
||||||
the grand totals). Cancelled footnote at the bottom when
|
the grand totals). Cancelled footnote at the bottom when
|
||||||
applicable. */}
|
applicable. */}
|
||||||
{report && report.rows.length > 0 && (
|
{hasAnyData && report && (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<div className="flex justify-between gap-3">
|
<div className="flex justify-between gap-3">
|
||||||
@@ -283,6 +285,45 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
|
{t('taxReport.summary.title', 'Income / costs')}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.income', 'Income')}</span>
|
||||||
|
<span className="tabular-nums text-emerald-700 dark:text-emerald-400">
|
||||||
|
{formatMinor(report.summary.incomeGrossMinor, report.currency, intlLocale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.costs', 'Costs')}</span>
|
||||||
|
<span className="tabular-nums text-rose-700 dark:text-rose-400">
|
||||||
|
−{formatMinor(report.summary.costGrossMinor, report.currency, intlLocale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 pt-1.5 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<span className="font-semibold text-neutral-900 dark:text-neutral-100">{t('taxReport.summary.result', 'Result')}</span>
|
||||||
|
<span className="tabular-nums font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{formatMinor(report.summary.resultGrossMinor, report.currency, intlLocale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
<span>{t('taxReport.summary.vatPayable', 'VAT payable (output − input)')}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatMinor(report.summary.vatPayableMinor, report.currency, intlLocale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showPerRateBreakdown && (
|
{showPerRateBreakdown && (
|
||||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
@@ -335,7 +376,7 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : !report || report.rows.length === 0 ? (
|
) : !hasAnyData ? (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('taxReport.empty', 'No invoices in this period.')}
|
{t('taxReport.empty', 'No invoices in this period.')}
|
||||||
@@ -343,9 +384,10 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 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
|
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. */
|
||||||
<Card padding="none">
|
<Card padding="none">
|
||||||
{/* Two nested wrappers: the OUTER clips the header row's
|
{/* Two nested wrappers: the OUTER clips the header row's
|
||||||
solid fill so the top corners stay rounded (matches
|
solid fill so the top corners stay rounded (matches
|
||||||
@@ -436,6 +478,98 @@ export const TaxReportPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<Card padding="none">
|
||||||
|
<div className="px-3 pt-3 pb-1">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('taxReport.costsTitle', 'Costs (incoming invoices + expenses)')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-2 text-right font-medium w-10">#</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.date', 'Date')}</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.cost.source', 'Type')}</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">{t('taxReport.cost.supplier', 'Supplier / description')}</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.event', 'Event')}</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.cost.taxTreatment', 'Tax treatment')}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.net', 'Net')}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 text-neutral-900 dark:text-neutral-100">
|
||||||
|
{report.costs.rows.map((row, i) => (
|
||||||
|
<tr key={`${row.source}-${row.id}`}>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums">{i + 1}</td>
|
||||||
|
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(String(row.date).slice(0, 10))}</td>
|
||||||
|
<td className="px-2 py-1.5 whitespace-nowrap">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded font-semibold ${
|
||||||
|
row.source === 'incoming'
|
||||||
|
? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
|
||||||
|
}`}>
|
||||||
|
{row.source === 'incoming'
|
||||||
|
? t('taxReport.cost.sourceIncoming', 'Incoming')
|
||||||
|
: t('taxReport.cost.sourceExpense', 'Expense')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 truncate max-w-[220px]" title={row.supplierLabel || row.description}>
|
||||||
|
{row.supplierLabel || row.description || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 truncate max-w-[160px]" title={row.eventName}>
|
||||||
|
{row.eventName || <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400">{row.taxTreatment}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||||
|
{formatMinor(row.netMinor, report.currency, intlLocale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||||
|
{formatMinor(row.vatMinor, report.currency, intlLocale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium">
|
||||||
|
{formatMinor(row.totalMinor, report.currency, intlLocale)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t-2 border-neutral-300 dark:border-neutral-700 font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
<tr>
|
||||||
|
<td className="px-2 py-2" colSpan={6}>{t('taxReport.cost.total', 'Total costs')}</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalNet, report.currency, intlLocale)}</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalVat, report.currency, intlLocale)}</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalGross, report.currency, intlLocale)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legal disclaimer — tax figures are a guideline. Per project
|
||||||
|
rule: any surface touching tax/financial output must point
|
||||||
|
the user at a professional. */}
|
||||||
|
{hasCosts && (
|
||||||
|
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
{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.',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,47 @@ export interface TaxReportBucket {
|
|||||||
totalMinor: number;
|
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 {
|
export interface TaxReport {
|
||||||
rows: TaxReportRow[];
|
rows: TaxReportRow[];
|
||||||
totalsByVatRate: TaxReportBucket[];
|
totalsByVatRate: TaxReportBucket[];
|
||||||
@@ -53,6 +94,11 @@ export interface TaxReport {
|
|||||||
grandTotalVat: number;
|
grandTotalVat: number;
|
||||||
grandTotal: number;
|
grandTotal: number;
|
||||||
cancelledCount: 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;
|
currency: string;
|
||||||
period: { from: string; to: string };
|
period: { from: string; to: string };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user