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 { const { year, month, status, supplier_name, search } = params; const where: Record = {}; 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 = {}; 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 { await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } }); fastify.get( "/", { preHandler: requirePermission("invoices.view") }, async (request, reply) => { const query = request.query as Record; 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; const now = new Date(); const year = Number(query.year) || now.getFullYear(); const month = Number(query.month) || now.getMonth() + 1; const where: Record = { 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 = {}; 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; 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> = []; 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>; } 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: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" ? 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"); }, ); }