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

@@ -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<string, unknown> = {
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

View File

@@ -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<void> {
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 = (

View File

@@ -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:0002: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:0002:00 Prague window.
if (String(body.status) === "paid" && !existing.paid_date) {
data.paid_date = new Date();
data.paid_date = utcMidnightOfLocalDay();
}
}