Files
app/src/routes/admin/received-invoices.ts
BOHA 975a555af5 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>
2026-06-10 11:29:28 +02:00

697 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import { received_invoices_status } from "@prisma/client";
import { z } from "zod";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
CreateReceivedInvoiceSchema,
UpdateReceivedInvoiceSchema,
} 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;
/** Round a monetary value to 2 decimal places to avoid floating-point drift. */
function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* VAT contained WITHIN a gross (VAT-inclusive) amount. `amount` is the total to
* pay including tax, so the tax portion is gross * rate / (100 + rate) — e.g.
* 22542.91 @ 21% → 3912.41 (base 18630.50). Returns 0 when there is no VAT.
*/
export function vatFromGross(gross: number, rate: number): number {
return rate > 0 ? roundMoney((gross * rate) / (100 + rate)) : 0;
}
const ALLOWED_SORT_FIELDS = [
"id",
"supplier_name",
"amount",
"issue_date",
"due_date",
"status",
"created_at",
];
interface ReceivedInvoiceFilterParams {
year?: number;
month?: number;
status?: string;
supplier_name?: string;
search?: string;
}
/**
* Shared `where` builder for the received-invoices list and the per-currency
* list-totals aggregation, so both stay in sync. The list filters by
* year/month/status/supplier_name/search.
*/
export function buildReceivedWhere(
params: ReceivedInvoiceFilterParams,
): Record<string, unknown> {
const { year, month, status, supplier_name, search } = params;
const where: Record<string, unknown> = {};
if (year) where.year = year;
if (month) where.month = month;
if (status) where.status = status;
if (supplier_name) where.supplier_name = { contains: supplier_name };
if (search) {
where.OR = [
{ supplier_name: { contains: search } },
{ invoice_number: { contains: search } },
{ description: { contains: search } },
];
}
return where;
}
/**
* Sum received-invoice GROSS `amount` per currency over the WHOLE filtered set
* (not one page), reusing `buildReceivedWhere` so the totals track the list.
* `amount` is VAT-inclusive (GROSS), so it is summed directly. Returns one
* entry per currency, rounded to 2dp, zero/empty totals dropped. Exported so
* the route and the totals test share one implementation.
*/
export async function getReceivedInvoiceListTotals(
params: ReceivedInvoiceFilterParams,
): Promise<{ totals: Array<{ amount: number; currency: string }> }> {
const where = buildReceivedWhere(params);
const invoices = await prisma.received_invoices.findMany({
where,
select: { amount: true, currency: true },
});
const byCurrency: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(inv.amount) || 0);
}
const totals = Object.entries(byCurrency)
.map(([currency, amount]) => ({
currency,
amount: Math.round(amount * 100) / 100,
}))
.filter((t) => t.amount > 0);
return { totals };
}
export default async function receivedInvoicesRoutes(
fastify: FastifyInstance,
): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
fastify.get(
"/",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const where = buildReceivedWhere({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
status: query.status ? String(query.status) : undefined,
supplier_name: query.supplier_name
? String(query.supplier_name)
: undefined,
search: query.search ? String(query.search) : undefined,
});
// Sort field whitelisting
const sortField =
query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort))
? String(query.sort)
: "id";
const [invoices, total] = await Promise.all([
prisma.received_invoices.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
}),
prisma.received_invoices.count({ where }),
]);
return paginated(
reply,
invoices,
buildPaginationMeta(total, page, limit),
);
},
);
// GET /api/admin/received-invoices/stats
fastify.get(
"/stats",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const now = new Date();
const year = Number(query.year) || now.getFullYear();
const month = Number(query.month) || now.getMonth() + 1;
const where: Record<string, unknown> = { year, month };
const monthInvoices = await prisma.received_invoices.findMany({ where });
// Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = (
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
const map: Record<string, number> = {};
for (const inv of invs) {
const cur = inv.currency || "CZK";
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
}
return Object.entries(map)
.filter(([, v]) => v > 0)
.map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
};
const sumCzk = async (
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
let total = 0;
for (const inv of invs) {
const amount = Number(inv[field]) || 0;
// `toCzk` throws on an unknown/unsupported currency. A single bad row
// must not 500 the whole stats endpoint — log and skip it instead.
try {
total += await toCzk(amount, inv.currency);
} catch (e) {
request.log.warn(
e,
`Přeskočen převod na CZK pro měnu "${inv.currency}" ve statistikách přijatých faktur`,
);
}
}
return Math.round(total * 100) / 100;
};
// All-time unpaid invoices (no soft-delete column, so just filter by status)
const unpaidCount = await prisma.received_invoices.count({
where: { status: { not: "paid" } },
});
const allUnpaid = await prisma.received_invoices.findMany({
where: { status: { not: "paid" } },
select: { amount: true, currency: true },
});
return success(reply, {
total_month: aggregateByCurrency(monthInvoices, "amount"),
total_month_czk: await sumCzk(monthInvoices, "amount"),
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: unpaidCount,
month_count: monthInvoices.length,
});
},
);
// GET /api/admin/received-invoices/list-totals — per-currency total summed
// over the WHOLE filtered set (not one page), matching the received-invoice
// list's filters (year/month/status/supplier_name/search). `amount` is GROSS
// (VAT-inclusive), so it is summed directly per currency. Distinct path from
// "/stats" (the monthly KPI cards) to avoid colliding with that endpoint.
// Registered BEFORE "/:id" so the literal path isn't captured as an id.
fastify.get(
"/list-totals",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const result = await getReceivedInvoiceListTotals({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
status: query.status ? String(query.status) : undefined,
supplier_name: query.supplier_name
? String(query.supplier_name)
: undefined,
search: query.search ? String(query.search) : undefined,
});
return success(reply, { totals: result.totals });
},
);
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
fastify.get(
"/suppliers",
{ preHandler: requirePermission("invoices.view") },
async (_request, reply) => {
const results = await prisma.received_invoices.findMany({
select: { supplier_name: true },
distinct: ["supplier_name"],
orderBy: { supplier_name: "asc" },
});
return success(
reply,
results.map((r) => r.supplier_name),
);
},
);
// GET /api/admin/received-invoices/:id/file
fastify.get<{ Params: { id: string } }>(
"/:id/file",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({
where: { id },
select: {
file_name: true,
file_mime: true,
year: true,
month: true,
},
});
if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404);
const relPath = nasInvoicesManager.buildReceivedPath(
invoice.file_name,
invoice.year,
invoice.month,
);
const nasFile = nasInvoicesManager.readReceived(relPath);
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf";
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
// window for viewing, not as a forced download.
return reply
.type(mime)
.header(
"Content-Disposition",
contentDisposition("inline", invoice.file_name),
)
.send(nasFile.data);
},
);
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const invoice = await prisma.received_invoices.findUnique({
where: { id },
});
if (!invoice) return error(reply, "Přijatá faktura nenalezena", 404);
return success(reply, {
...invoice,
has_file: !!invoice.file_name,
});
},
);
fastify.post(
"/",
{ preHandler: requirePermission("invoices.create") },
async (request, reply) => {
const contentType = request.headers["content-type"] || "";
// Multipart upload: files[] + invoices JSON metadata
if (contentType.includes("multipart/form-data")) {
const parts = request.parts();
const files: Array<{
data: Buffer;
name: string;
mime: string;
size: number;
}> = [];
let invoicesMeta: Array<Record<string, unknown>> = [];
for await (const part of parts) {
if (part.type === "file") {
const buf = await part.toBuffer();
files.push({
data: buf,
name: part.filename || "file",
mime: part.mimetype || "application/octet-stream",
size: buf.length,
});
} else if (part.fieldname === "invoices") {
try {
const parsed: unknown = JSON.parse(part.value as string);
// The frontend sends a partial payload (one element per uploaded
// file, only the editable fields). Validate each entry against a
// permissive variant of the create schema so untyped keys (NaN,
// huge numbers, weird date strings) can't reach the
// calculations below.
const arrSchema = z.array(CreateReceivedInvoiceSchema.partial());
const validated = arrSchema.safeParse(parsed);
if (validated.success) {
invoicesMeta = validated.data as Array<Record<string, unknown>>;
} else {
return error(
reply,
"Neplatná metadata faktur: " +
validated.error.issues.map((i) => i.message).join(", "),
400,
);
}
} catch {
return error(reply, "Neplatný JSON v poli 'invoices'", 400);
}
}
}
if (files.length === 0)
return error(reply, "Vyberte alespoň jeden soubor", 400);
const now = new Date();
const createdIds: number[] = [];
const useNas = nasInvoicesManager.isConfigured();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0);
const vatRate = Number(meta.vat_rate ?? 21);
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
const vatAmount = vatFromGross(amount, vatRate);
const issueDate = meta.issue_date
? new Date(String(meta.issue_date))
: null;
const invoiceMonth = issueDate
? issueDate.getMonth() + 1
: Number(meta.month) || now.getMonth() + 1;
const invoiceYear = issueDate
? issueDate.getFullYear()
: Number(meta.year) || now.getFullYear();
if (!useNas) {
return error(reply, "NAS úložiště není nakonfigurováno", 503);
}
const nasResult = nasInvoicesManager.saveReceived(
file.name,
invoiceYear,
invoiceMonth,
file.data,
);
if ("error" in nasResult) {
return error(reply, nasResult.error, 503);
}
const invoice = await prisma.received_invoices.create({
data: {
month: invoiceMonth,
year: invoiceYear,
supplier_name: meta.supplier_name
? String(meta.supplier_name)
: file.name,
invoice_number: meta.invoice_number
? String(meta.invoice_number)
: null,
description: meta.description ? String(meta.description) : null,
amount,
currency: meta.currency ? String(meta.currency) : "CZK",
vat_rate: vatRate,
vat_amount: vatAmount,
issue_date: meta.issue_date
? new Date(String(meta.issue_date))
: null,
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
status: "unpaid",
notes: meta.notes ? String(meta.notes) : null,
uploaded_by: request.authData?.userId,
file_name: nasResult.filePath
? path.basename(nasResult.filePath)
: file.name,
file_mime: file.mime,
file_size: file.size,
},
});
createdIds.push(invoice.id);
}
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "invoice",
entityId: createdIds[0],
description: `Nahráno ${createdIds.length} přijatých faktur`,
});
return success(
reply,
{ ids: createdIds, count: createdIds.length },
201,
`Nahráno ${createdIds.length} faktur`,
);
}
// JSON body: single invoice creation (no file)
const parsed = parseBody(CreateReceivedInvoiceSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const status = body.status;
if (!VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])) {
return error(reply, "Neplatný stav", 400);
}
const amount = body.amount;
const vatRate = body.vat_rate;
const invoice = await prisma.received_invoices.create({
data: {
month: Number(body.month),
year: Number(body.year),
supplier_name: String(body.supplier_name),
invoice_number: body.invoice_number
? String(body.invoice_number)
: null,
description: body.description ? String(body.description) : null,
amount,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: vatRate,
// `amount` is the GROSS total (VAT included); VAT is the portion
// within it — consistent with the multipart create and update paths.
vat_amount: vatFromGross(amount, vatRate),
issue_date: body.issue_date
? new Date(String(body.issue_date))
: null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
status: status as received_invoices_status,
notes: body.notes ? String(body.notes) : null,
uploaded_by: request.authData?.userId,
},
});
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "invoice",
entityId: invoice.id,
description: `Vytvořena přijatá faktura od ${invoice.supplier_name}`,
});
return success(reply, { id: invoice.id }, 201, "Faktura byla vytvořena");
},
);
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateReceivedInvoiceSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
const existing = await prisma.received_invoices.findUnique({
where: { id },
});
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
if (body.status !== undefined) {
const status = String(body.status);
if (
!VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])
) {
return error(reply, "Neplatný stav", 400);
}
// Prevent reverting paid status (matching PHP)
if (String(existing.status) === "paid" && status !== "paid") {
return error(reply, "Nelze vrátit stav uhrazené faktury", 400);
}
}
if (String(existing.status) === "paid") {
const attempted = Object.keys(body).filter(
(k) => !["status", "paid_date", "notes"].includes(k),
);
if (attempted.length > 0) {
return error(reply, "Nelze upravit uhrazenou fakturu", 400);
}
}
// Recalculate vat_amount when amount (gross) or vat_rate changes.
const finalAmount =
body.amount !== undefined
? Number(body.amount)
: Number(existing.amount);
const finalVatRate =
body.vat_rate !== undefined
? Number(body.vat_rate)
: Number(existing.vat_rate);
// `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).
// 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 (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"
? utcMidnightOfLocalDay()
: body.paid_date !== undefined
? body.paid_date
? new Date(String(body.paid_date))
: null
: undefined;
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
if (body.issue_date && !body.month && !body.year) {
const issueDate = new Date(String(body.issue_date));
if (!isNaN(issueDate.getTime())) {
autoMonth = issueDate.getMonth() + 1;
autoYear = issueDate.getFullYear();
}
}
await prisma.received_invoices.update({
where: { id },
data: {
supplier_name:
body.supplier_name !== undefined
? String(body.supplier_name)
: undefined,
invoice_number:
body.invoice_number !== undefined
? body.invoice_number
? String(body.invoice_number)
: null
: undefined,
description:
body.description !== undefined
? body.description
? String(body.description)
: null
: undefined,
amount: body.amount !== undefined ? Number(body.amount) : undefined,
currency:
body.currency !== undefined ? String(body.currency) : undefined,
vat_rate:
body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
vat_amount:
body.amount !== undefined || body.vat_rate !== undefined
? computedVat
: body.vat_amount !== undefined
? Number(body.vat_amount)
: undefined,
issue_date:
body.issue_date !== undefined
? body.issue_date
? new Date(String(body.issue_date))
: null
: undefined,
due_date:
body.due_date !== undefined
? body.due_date
? new Date(String(body.due_date))
: null
: undefined,
paid_date: paidDate,
status:
body.status !== undefined
? (String(body.status) as received_invoices_status)
: undefined,
notes:
body.notes !== undefined
? body.notes
? String(body.notes)
: null
: undefined,
month: autoMonth,
year: autoYear,
modified_at: new Date(),
},
});
await logAudit({
request,
authData: request.authData,
action: "update",
entityType: "invoice",
entityId: id,
description: `Upravena přijatá faktura`,
});
return success(reply, { id }, 200, "Faktura byla uložena");
},
);
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await prisma.received_invoices.findUnique({
where: { id },
});
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
await prisma.received_invoices.delete({ where: { id } });
if (existing.file_name) {
const relPath = nasInvoicesManager.buildReceivedPath(
existing.file_name,
existing.year,
existing.month,
);
nasInvoicesManager.deleteReceived(relPath);
}
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "invoice",
entityId: id,
description: `Smazána přijatá faktura`,
});
return success(reply, null, 200, "Přijatá faktura smazána");
},
);
}