diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index 5a87fd39..9fa9b8de 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -276,6 +276,47 @@ describe('getTaxReport', () => { ]); }); + it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => { + // The real cancel-and-reissue flow produces THREE rows in the period: + // the cancelled original, its negative Storno (kind='storno', status='sent'), + // and the reissue. Totals must read the reissued amount, not 0. + invoiceRowsForRun = [ + { + id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01', + currency: 'CHF', status: 'cancelled', kind: 'invoice', 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 GmbH', event_name: 'Wedding A', + }, + { + id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02', + currency: 'CHF', status: 'sent', kind: 'storno', 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 GmbH', event_name: 'Wedding A', + }, + { + id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03', + currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7, + net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, + late_fee_amount_minor: 0, replaces_invoice_id: 20, + customer_company_name: 'ACME GmbH', event_name: 'Wedding A', + }, + ]; + replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }]; + + const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' }); + expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail + // The negative storno must NOT net against the totals (the cancelled + // original is already excluded) — the reissued revenue stands. + expect(out.grandTotalNet).toBe(10000); + expect(out.grandTotalVat).toBe(770); + expect(out.grandTotal).toBe(10770); + expect(out.totalsByVatRate).toEqual([ + { vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 }, + ]); + }); + it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => { invoiceRowsForRun = [ { diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index 8fcbf399..6f6230c8 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -127,9 +127,14 @@ function isEntryLocked(entry, invoice) { */ function buildLineItemFromEntry(entry, rateMinor) { const hours = (entry.duration_minutes / 60).toFixed(2); - // ISO date input is already YYYY-MM-DD; admin's locale formatting - // happens at PDF render time, so keep the entry description portable. - const datePart = String(entry.entry_date).slice(0, 10); + // Keep the entry description portable (admin's locale formatting happens at + // PDF render time). `entry_date` is a `date` column: Postgres hands it back as + // a JS Date, SQLite as a 'YYYY-MM-DD' string — so `String(dateObj).slice(0,10)` + // would bake "Wed Apr 06" into the invoice line on PG. Normalise via the Date + // branch (see feedback_pg_date_columns_serialize). + const datePart = entry.entry_date instanceof Date + ? entry.entry_date.toISOString().slice(0, 10) + : String(entry.entry_date).slice(0, 10); const note = (entry.description || '').trim(); const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`; const qty = Number(hours); diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 60f2fadb..f4495992 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -433,9 +433,15 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { const rows = dbRows.map((r) => { const reported = computeReportedAmounts(r); const isCancelled = r.status === 'cancelled'; - if (isCancelled) { - cancelledCount += 1; - } else { + if (isCancelled) cancelledCount += 1; + // Exclude BOTH the cancelled original AND its negative Storno row from the + // totals. Both stay visible in the row list for the gap-free audit trail, + // but a Storno (kind='storno', status='sent', amounts stored negative) + // would otherwise double-subtract: the cancelled original is already + // netted out by exclusion, so adding the negative storno on top deducts + // the revenue a second time — making a cancel-and-reissue read as 0 income + // instead of the reissued amount. See feedback_storno_filter_everywhere. + if (!isCancelled && r.kind !== 'storno') { grandTotalNet += reported.netMinor; grandTotalVat += reported.vatMinor; grandTotal += reported.totalMinor;