fix(dates): @db.Date filters built from local midnight queried the wrong day

Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 11:29:28 +02:00
parent 6a22195c7d
commit 975a555af5
9 changed files with 361 additions and 45 deletions

View File

@@ -258,6 +258,106 @@ describe("GET /api/admin/attendance (complete month, no truncation)", () => {
});
});
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
// previous day) shifted the whole window a day back: the June list
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
// 2098-06 / 2098-07 boundary.
const juneLast = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
leave_type: "work",
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
departure_time: new Date(2098, 5, 30, 16, 0, 0),
notes: "attendance_wire_test june-last-day",
},
});
const julyFirst = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
leave_type: "work",
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
departure_time: new Date(2098, 6, 1, 16, 0, 0),
notes: "attendance_wire_test july-first-day",
},
});
const june = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=6&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(june.statusCode).toBe(200);
const juneIds = june.json().data.map((r: { id: number }) => r.id);
expect(juneIds).toContain(juneLast.id);
expect(juneIds).not.toContain(julyFirst.id);
const july = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=7&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(july.statusCode).toBe(200);
const julyIds = july.json().data.map((r: { id: number }) => r.id);
expect(julyIds).toContain(julyFirst.id);
expect(julyIds).not.toContain(juneLast.id);
});
});
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
it("rejects a second leave record on the SAME day", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-first",
});
expect(first.statusCode).toBe(201);
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
// day — a same-day duplicate leave sailed through undetected.
const second = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "sick",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-second",
});
expect(second.statusCode).toBe(400);
expect(second.json().success).toBe(false);
});
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-first",
});
expect(first.statusCode).toBe(201);
// Old bug (the other half): creating a record for the NEXT day queried
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
// with a false "záznam o nepřítomnosti již existuje".
const nextDay = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-11",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-next",
});
expect(nextDay.statusCode).toBe(201);
expect(nextDay.json().success).toBe(true);
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { computeAlertWindow } from "../services/invoice-alerts";
import { utcMidnightOfLocalDay } from "../utils/date";
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
// a Date used in a WHERE filter to its UTC date part, so the alert window
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
// advance alert (due == today+3) could then never fire. All assertions below
// are timezone-independent (inputs are built from local components).
describe("utcMidnightOfLocalDay", () => {
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
// exactly what Prisma would truncate a bare Date to.
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
Date.UTC(2098, 6, 1),
);
});
it("returns UTC midnight of the local day for a mid-day instant", () => {
const noon = new Date(2098, 0, 15, 12, 0, 0);
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
});
});
describe("computeAlertWindow", () => {
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
// 00:30 local — the window where the old local-midnight computation
// produced yesterday's UTC date and the whole window slid a day early.
const now = new Date(2098, 6, 1, 0, 30, 0);
const w = computeAlertWindow(now);
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
expect(w.todayStr).toBe("2098-07-01");
expect(w.in3daysStr).toBe("2098-07-04");
});
it("rolls the +3-day bound over a month boundary", () => {
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
const w = computeAlertWindow(now);
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
expect(w.in3daysStr).toBe("2098-02-02");
});
});

View File

@@ -1,10 +1,12 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
@@ -179,3 +181,83 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
expect(row?.billing_text).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
const cleanup = async () => {
// invoice_items cascade on invoice delete.
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
};
beforeEach(cleanup);
afterEach(cleanup);
const listParams = {
page: 1,
limit: 100,
skip: 0,
sort: "id",
order: "asc" as const,
search: "",
};
async function createBoundaryFixtures() {
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
// status filter so they appear in the list/totals like any invoice.
const lastDayJan = await createInvoice({
status: "draft",
issue_date: "2098-01-31",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
});
const firstDayFeb = await createInvoice({
status: "draft",
issue_date: "2098-02-01",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
});
return { lastDayJan, firstDayFeb };
}
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
const janIds = jan.data.map((i) => i.id);
expect(janIds).toContain(lastDayJan.id);
expect(janIds).not.toContain(firstDayFeb.id);
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
const febIds = feb.data.map((i) => i.id);
expect(febIds).toContain(firstDayFeb.id);
expect(febIds).not.toContain(lastDayJan.id);
});
it("per-currency totals include the month's last day exactly once", async () => {
await createBoundaryFixtures();
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
const janTst = jan.totals.find((t) => t.currency === "TST");
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
// Feb 1 invoice (242) is NOT pulled into January.
expect(janTst?.amount).toBe(121);
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
const febTst = feb.totals.find((t) => t.currency === "TST");
expect(febTst?.amount).toBe(242);
});
});