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:
@@ -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({
|
||||
|
||||
50
src/__tests__/invoice-alerts.test.ts
Normal file
50
src/__tests__/invoice-alerts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user