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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user