import { FastifyInstance } from "fastify"; import multipart from "@fastify/multipart"; import { received_invoices_status } from "@prisma/client"; import prisma from "../../config/database"; import { requirePermission } from "../../middleware/auth"; import { logAudit } from "../../services/audit"; import { success, error, parseId } from "../../utils/response"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parseBody } from "../../schemas/common"; import { CreateReceivedInvoiceSchema, UpdateReceivedInvoiceSchema, } from "../../schemas/received-invoices.schema"; import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { toCzk } from "../../services/exchange-rates"; const VALID_STATUSES = ["unpaid", "paid"] as const; const ALLOWED_SORT_FIELDS = [ "id", "supplier_name", "amount", "issue_date", "due_date", "status", "created_at", ]; 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: Record = {}; if (query.year) where.year = Number(query.year); if (query.month) where.month = Number(query.month); if (query.status) where.status = String(query.status); if (query.supplier_name) where.supplier_name = { contains: String(query.supplier_name) }; // Search across supplier_name, invoice_number, description if (query.search) { const search = String(query.search); where.OR = [ { supplier_name: { contains: search } }, { invoice_number: { contains: search } }, { description: { contains: search } }, ]; } // 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 reply.send({ success: true, data: invoices, pagination: 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: typeof monthInvoices, 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: typeof monthInvoices, field: "amount" | "vat_amount", ) => { let total = 0; for (const inv of invs) { const amount = Number(inv[field]) || 0; total += await toCzk(amount, inv.currency); } return Math.round(total * 100) / 100; }; // Also get all-time unpaid const allUnpaid = await prisma.received_invoices.findMany({ where: { status: { not: "paid" } }, }); 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: allUnpaid.length, month_count: monthInvoices.length, }); }, ); // 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 = nasFinancialsManager.buildReceivedPath( invoice.file_name, invoice.year, invoice.month, ); const nasFile = nasFinancialsManager.readReceivedInvoice(relPath); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404); const mime = invoice.file_mime || "application/pdf"; return reply .type(mime) .header( "Content-Disposition", `inline; filename="${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 { invoicesMeta = JSON.parse(part.value as string); } catch { // Malformed invoices metadata — ignore, use defaults } } } if (files.length === 0) return error(reply, "Vyberte alespoň jeden soubor", 400); const now = new Date(); const createdIds: number[] = []; const useNas = nasFinancialsManager.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 includes VAT — extract VAT portion: amount - amount/(1 + rate/100) const vatAmount = vatRate > 0 ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 : 0; 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 = nasFinancialsManager.saveReceivedInvoice( 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: 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, vat_amount: vatRate > 0 ? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100 : 0, 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); } } // Recalculate vat_amount when amount or vat_rate changes (matching PHP) 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 includes VAT — extract VAT portion: amount - amount/(1 + rate/100) const computedVat = finalVatRate > 0 ? Math.round( (finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100, ) / 100 : 0; // Auto-set paid_date when status transitions to paid (matching PHP) const newStatus = body.status !== undefined ? String(body.status) : String(existing.status); const paidDate = newStatus === "paid" && String(existing.status) !== "paid" ? new Date() : 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); if (existing.file_name) { const relPath = nasFinancialsManager.buildReceivedPath( existing.file_name, existing.year, existing.month, ); nasFinancialsManager.deleteReceivedInvoice(relPath); } await prisma.received_invoices.delete({ where: { id } }); 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"); }, ); }