From 975a555af52740324ad503cdfb466011ecb546c0 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 11:29:28 +0200 Subject: [PATCH] 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 --- src/__tests__/attendance.test.ts | 100 +++++++++++++++++++++++++ src/__tests__/invoice-alerts.test.ts | 50 +++++++++++++ src/__tests__/invoices.service.test.ts | 84 ++++++++++++++++++++- src/routes/admin/received-invoices.ts | 8 +- src/schemas/attendance.schema.ts | 6 +- src/services/attendance.service.ts | 63 +++++++++++----- src/services/invoice-alerts.ts | 36 +++++++-- src/services/invoices.service.ts | 43 +++++++---- src/utils/date.ts | 16 ++++ 9 files changed, 361 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/invoice-alerts.test.ts diff --git a/src/__tests__/attendance.test.ts b/src/__tests__/attendance.test.ts index 05ee731..4e81790 100644 --- a/src/__tests__/attendance.test.ts +++ b/src/__tests__/attendance.test.ts @@ -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({ diff --git a/src/__tests__/invoice-alerts.test.ts b/src/__tests__/invoice-alerts.test.ts new file mode 100644 index 0000000..0794a67 --- /dev/null +++ b/src/__tests__/invoice-alerts.test.ts @@ -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"); + }); +}); diff --git a/src/__tests__/invoices.service.test.ts b/src/__tests__/invoices.service.test.ts index ab8e797..a253f3d 100644 --- a/src/__tests__/invoices.service.test.ts +++ b/src/__tests__/invoices.service.test.ts @@ -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); + }); +}); diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index 5653db8..aa5c0a2 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -15,6 +15,7 @@ import { } from "../../schemas/received-invoices.schema"; import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { toCzk } from "../../services/exchange-rates"; +import { utcMidnightOfLocalDay } from "../../utils/date"; import path from "path"; const VALID_STATUSES = ["unpaid", "paid"] as const; @@ -561,14 +562,17 @@ export default async function receivedInvoicesRoutes( // `amount` is the GROSS total (VAT included); VAT is the portion within it. const computedVat = vatFromGross(finalAmount, finalVatRate); - // Auto-set paid_date when status transitions to paid (matching PHP) + // Auto-set paid_date when status transitions to paid (matching PHP). + // paid_date is @db.Date (truncated to the UTC date part) — + // utcMidnightOfLocalDay keeps the LOCAL calendar day even during the + // 00:00–02:00 Prague window (a bare new Date() stored yesterday there). const newStatus = body.status !== undefined ? String(body.status) : String(existing.status); const paidDate = newStatus === "paid" && String(existing.status) !== "paid" - ? new Date() + ? utcMidnightOfLocalDay() : body.paid_date !== undefined ? body.paid_date ? new Date(String(body.paid_date)) diff --git a/src/schemas/attendance.schema.ts b/src/schemas/attendance.schema.ts index f9014ad..1c17998 100644 --- a/src/schemas/attendance.schema.ts +++ b/src/schemas/attendance.schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { intIdFromForm, + isoDateString, nullableDateTimeString, nullableIntIdFromForm, numberFromForm, @@ -73,7 +74,10 @@ export const AttendancePunchSchema = z.object({ // NOT bare HH:MM times. export const CreateAttendanceSchema = z.object({ user_id: intIdFromForm.optional(), - shift_date: z.string(), + // Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's + // duplicate-validation window and the stored @db.Date both depend on it. + // isoDateString leniently strips a trailing time component. + shift_date: isoDateString, arrival_time: nullableDateTimeString.optional(), arrival_lat: numberFromForm.nullish(), arrival_lng: numberFromForm.nullish(), diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index 3ddcd70..378d76c 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -189,12 +189,16 @@ export async function getStatus(userId: number) { const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); - const todayStart = new Date(y, m, d, 0, 0, 0); - const todayEnd = new Date(y, m, d, 23, 59, 59); + // shift_date is @db.Date: Prisma compares it by its UTC date part, so the + // range boundaries must be UTC midnights of the LOCAL calendar day. A local + // midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window + // one day back. Half-open [gte, lt) ranges. + const todayStart = new Date(Date.UTC(y, m, d)); + const todayEnd = new Date(Date.UTC(y, m, d + 1)); // Monthly fund range (used by query #4) - const monthStart = new Date(y, m, 1); - const monthEnd = new Date(y, m + 1, 0, 23, 59, 59); + const monthStart = new Date(Date.UTC(y, m, 1)); + const monthEnd = new Date(Date.UTC(y, m + 1, 1)); // Queries 1-4 are independent of one another → run them in parallel. const [ongoingShift, todayShiftsRaw, balance, monthRecords] = @@ -212,7 +216,7 @@ export async function getStatus(userId: number) { prisma.attendance.findMany({ where: { user_id: userId, - shift_date: { gte: todayStart, lte: todayEnd }, + shift_date: { gte: todayStart, lt: todayEnd }, departure_time: { not: null }, }, include: { @@ -228,7 +232,7 @@ export async function getStatus(userId: number) { prisma.attendance.findMany({ where: { user_id: userId, - shift_date: { gte: monthStart, lte: monthEnd }, + shift_date: { gte: monthStart, lt: monthEnd }, }, }), ]); @@ -585,10 +589,13 @@ export async function getWorkfund(year: number) { : bizDays; const fund = bizDays * 8; const fundToDate = bizDaysToDate * 8; - const monthStart = new Date(year, m, 1); - const monthEnd = new Date(year, m + 1, 0, 23, 59, 59); + // shift_date is @db.Date (compared by UTC date part) → UTC-midnight + // month boundaries, half-open range. Local midnights would double-count + // each previous month's last day. + const monthStart = new Date(Date.UTC(year, m, 1)); + const monthEnd = new Date(Date.UTC(year, m + 1, 1)); const monthRecords = await prisma.attendance.findMany({ - where: { shift_date: { gte: monthStart, lte: monthEnd } }, + where: { shift_date: { gte: monthStart, lt: monthEnd } }, select: { user_id: true, shift_date: true, @@ -846,13 +853,16 @@ export async function getPrintData( const yr = Number(yearStr); const mo = Number(monthNumStr); - const monthStart = new Date(yr, mo - 1, 1); - const monthEnd = new Date(yr, mo, 0, 23, 59, 59); + // shift_date is @db.Date (compared by UTC date part) → UTC-midnight month + // boundaries, half-open range (local midnights pulled in the previous + // month's last day). + const monthStart = new Date(Date.UTC(yr, mo - 1, 1)); + const monthEnd = new Date(Date.UTC(yr, mo, 1)); const users = await getAttendanceUsers(); const where: Record = { - shift_date: { gte: monthStart, lte: monthEnd }, + shift_date: { gte: monthStart, lt: monthEnd }, }; if (filterUserId) where.user_id = filterUserId; @@ -1043,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) { where.user_id = params.userId; } if (params.month && params.year) { + // shift_date is @db.Date: Prisma compares by UTC date part, so the month + // boundaries must be UTC midnights. Local midnights shifted the window a + // day back (included prev-month's last day, dropped this month's last day). where.shift_date = { - gte: new Date(params.year, params.month - 1, 1), - lt: new Date(params.year, params.month, 1), + gte: new Date(Date.UTC(params.year, params.month - 1, 1)), + lt: new Date(Date.UTC(params.year, params.month, 1)), }; } @@ -1527,15 +1540,25 @@ export async function createAttendance( ) { const userId = data.user_id ?? authUserId; const shiftDate = new Date(data.shift_date); + // shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the + // @db.Date column is compared by UTC date part. The duplicate/overlap + // window must therefore be the UTC day [shiftDate, shiftDate+1) — building + // it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous + // day, so the validation queried ONLY yesterday's records (same-day + // overlaps undetected, false duplicates when yesterday had records). const startOfDay = new Date( - shiftDate.getFullYear(), - shiftDate.getMonth(), - shiftDate.getDate(), + Date.UTC( + shiftDate.getUTCFullYear(), + shiftDate.getUTCMonth(), + shiftDate.getUTCDate(), + ), ); const endOfDay = new Date( - shiftDate.getFullYear(), - shiftDate.getMonth(), - shiftDate.getDate() + 1, + Date.UTC( + shiftDate.getUTCFullYear(), + shiftDate.getUTCMonth(), + shiftDate.getUTCDate() + 1, + ), ); // Multiple work shifts per day are allowed — only block when the new diff --git a/src/services/invoice-alerts.ts b/src/services/invoice-alerts.ts index 4fd8738..634154e 100644 --- a/src/services/invoice-alerts.ts +++ b/src/services/invoice-alerts.ts @@ -1,7 +1,11 @@ import prisma from "../config/database"; import { config } from "../config/env"; import { sendMail } from "./mailer"; -import { localDateCzStr, localDateStr } from "../utils/date"; +import { + localDateCzStr, + localDateStr, + utcMidnightOfLocalDay, +} from "../utils/date"; import { getSystemSettings } from "./system-settings"; interface AlertInvoice { @@ -33,17 +37,35 @@ function formatAmount(n: number | { toNumber?: () => number }): string { }); } +/** + * Alert window [today, today+3] for the due-date queries + the local date + * strings the classifier matches against. + * + * due_date is @db.Date: Prisma truncates the filter Dates to their UTC date + * part, so the window boundaries must be UTC midnights of the LOCAL calendar + * day. A local midnight (22:00/23:00 UTC of the previous day) shifted the + * whole window a day early — the "splatnost za 3 dny" advance alert + * (due == today+3) could then never fire. + * + * Exported (with an injectable `now`) so the window math is unit-testable. + */ +export function computeAlertWindow(now: Date = new Date()) { + const today = utcMidnightOfLocalDay(now); + const todayStr = localDateStr(now); + const in3days = new Date(today); + in3days.setUTCDate(in3days.getUTCDate() + 3); + const in3daysStr = localDateStr( + new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3), + ); + return { today, todayStr, in3days, in3daysStr }; +} + export async function checkInvoiceAlerts(): Promise { const settings = await getSystemSettings(); const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert; if (!alertEmail) return; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayStr = localDateStr(today); - const in3days = new Date(today); - in3days.setDate(in3days.getDate() + 3); - const in3daysStr = localDateStr(in3days); + const { today, todayStr, in3days, in3daysStr } = computeAlertWindow(); // Classify a due date into an alert type/label, or null if it doesn't match. const classify = ( diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index a43738f..33844ff 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -1,4 +1,5 @@ import prisma from "../config/database"; +import { utcMidnightOfLocalDay } from "../utils/date"; import { toCzk } from "./exchange-rates"; import { generateInvoiceNumber, @@ -139,8 +140,12 @@ function buildInvoiceWhere( if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (month && year) { - const from = new Date(year, month - 1, 1); - const to = new Date(year, month, 1); + // issue_date is @db.Date: Prisma compares by UTC date part, so the month + // boundaries must be UTC midnights. Local midnights shifted the window a + // day back — the filter included the previous month's last day and + // DROPPED invoices issued on the selected month's last day. + const from = new Date(Date.UTC(year, month - 1, 1)); + const to = new Date(Date.UTC(year, month, 1)); where.issue_date = { gte: from, lt: to }; } if (search) { @@ -180,13 +185,18 @@ function computeInvoiceTotals( export async function markOverdueInvoices() { try { + // due_date is @db.Date (UTC midnight of the calendar day). Compare + // against UTC midnight of the LOCAL today — a bare new Date() has + // yesterday's UTC date during the 00:00–02:00 Prague window, so the + // overdue flip lagged a day there. Due TODAY = not yet overdue. + const today = utcMidnightOfLocalDay(); await prisma.invoices.updateMany({ - where: { status: "issued", due_date: { lt: new Date() } }, + where: { status: "issued", due_date: { lt: today } }, data: { status: "overdue" }, }); - // Reverse: if due_date was changed to future, set back to issued + // Reverse: if due_date was changed to today/future, set back to issued await prisma.invoices.updateMany({ - where: { status: "overdue", due_date: { gte: new Date() } }, + where: { status: "overdue", due_date: { gte: today } }, data: { status: "issued" }, }); } catch (err) { @@ -312,10 +322,13 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { const year = queryYear || now.getFullYear(); const month = queryMonth || now.getMonth() + 1; - const monthStart = new Date(year, month - 1, 1); - const monthEnd = new Date(year, month, 0, 23, 59, 59); - const startOfYear = new Date(year, 0, 1); - const endOfYear = new Date(year, 11, 31, 23, 59, 59); + // issue_date is @db.Date (compared by UTC date part) → UTC-midnight period + // boundaries, half-open [gte, lt) ranges. Local-midnight bounds included + // the previous period's boundary day in the stats. + const monthStart = new Date(Date.UTC(year, month - 1, 1)); + const nextMonthStart = new Date(Date.UTC(year, month, 1)); + const startOfYear = new Date(Date.UTC(year, 0, 1)); + const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1)); const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([ prisma.invoices.findMany({ @@ -323,21 +336,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { // Drafts are not real financial documents — exclude them so their // VAT/amounts never inflate the monthly stats (vat_month etc.). status: { not: "draft" }, - issue_date: { gte: monthStart, lte: monthEnd }, + issue_date: { gte: monthStart, lt: nextMonthStart }, }, include: { invoice_items: true }, }), prisma.invoices.findMany({ where: { status: "issued", - issue_date: { gte: startOfYear, lte: endOfYear }, + issue_date: { gte: startOfYear, lt: startOfNextYear }, }, include: { invoice_items: true }, }), prisma.invoices.findMany({ where: { status: "overdue", - issue_date: { gte: startOfYear, lte: endOfYear }, + issue_date: { gte: startOfYear, lt: startOfNextYear }, }, include: { invoice_items: true }, }), @@ -601,9 +614,11 @@ export async function updateInvoice(id: number, body: InvoiceInput) { // Status change if (body.status !== undefined) { data.status = String(body.status); - // Auto-set paid_date when transitioning to paid + // Auto-set paid_date when transitioning to paid. paid_date is @db.Date + // (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the + // LOCAL calendar day even during the 00:00–02:00 Prague window. if (String(body.status) === "paid" && !existing.paid_date) { - data.paid_date = new Date(); + data.paid_date = utcMidnightOfLocalDay(); } } diff --git a/src/utils/date.ts b/src/utils/date.ts index bbd864b..c14c6c5 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -8,6 +8,22 @@ * of JSON serialization (e.g., building lookup keys, shift_date strings). */ +/** + * UTC midnight of the LOCAL calendar day of `d` (default: now). + * + * Prisma truncates a JS Date used against a `@db.Date` column (filter or + * write) to its **UTC** date part. With TZ=Europe/Prague, a local-midnight + * Date — `new Date(y, m, d)` — is 22:00/23:00 UTC of the PREVIOUS day, so it + * filters/stores as the previous calendar date; and a bare `new Date()` + * written to a `@db.Date` column stores yesterday during the 00:00–02:00 + * Prague window. Always build `@db.Date` values and range boundaries from + * `Date.UTC(...)` of the LOCAL calendar components — this helper does exactly + * that for "today" (or any reference Date). + */ +export function utcMidnightOfLocalDay(d: Date = new Date()): Date { + return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); +} + /** YYYY-MM-DD in local time */ export function localDateStr(d: Date): string { const y = d.getFullYear();