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