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