- ČNB exchange rate service with date-specific rates and caching - Invoice/received invoice stats convert foreign currencies to CZK - Dashboard revenue converts all currencies to CZK - Invoice PDF: VAT recap table always in CZK with CNB rate footer - Inline styles replaced with utility classes (step 4 cleanup) - Spinner animation exempt from prefers-reduced-motion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
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<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: Record<string, unknown> = {};
|
|
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<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: typeof monthInvoices,
|
|
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: 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<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 {
|
|
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");
|
|
},
|
|
);
|
|
}
|