fix(accounting): tax-report storno totals + hours-line date on Postgres

Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):

- taxReportService: income totals excluded only `status='cancelled'`, never
  `kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
  the totals on top of the already-excluded cancelled original → double-subtract,
  so a cancel-and-reissue read as 0 income instead of the reissued amount.
  Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
  Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
  on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
  invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
  Normalise via the Date branch like every other date read.
This commit is contained in:
Luca
2026-06-18 18:40:22 +02:00
parent 707c5d0277
commit db9e41d198
3 changed files with 58 additions and 6 deletions
+8 -3
View File
@@ -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);
+9 -3
View File
@@ -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;