import { FastifyInstance } from 'fastify'; 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 { generateInvoiceNumber } from '../../services/numbering.service'; import { parseBody } from '../../schemas/common'; import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema'; // Status transition rules matching PHP const VALID_TRANSITIONS: Record = { issued: ['paid'], overdue: ['paid'], paid: [], }; const ALLOWED_SORT_FIELDS = ['id', 'invoice_number', 'status', 'issue_date', 'due_date', 'currency']; interface InvoiceItemInput { description?: string; quantity?: number; unit?: string; unit_price?: number; vat_rate?: number; position?: number } function computeInvoiceTotals(items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown) { const subtotal = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); const vatAmount = applyVat ? items.reduce((s, i) => { const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0); return s + base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100); }, 0) : 0; return { subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round((subtotal + vatAmount) * 100) / 100, }; } export default async function invoicesRoutes(fastify: FastifyInstance): Promise { // Auto-update overdue invoices on GET requests only (matches PHP behavior) fastify.addHook('onRequest', async (request) => { if (request.method !== 'GET') return; try { await prisma.invoices.updateMany({ where: { status: 'issued', due_date: { lt: new Date() } }, data: { status: 'overdue' }, }); } catch { /* silent */ } }); // GET /api/admin/invoices fastify.get('/', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { const query = request.query as Record; const { page, limit, skip, order, search } = parsePagination(query); const where: Record = {}; if (query.status) where.status = String(query.status); if (query.customer_id) where.customer_id = Number(query.customer_id); if (search) { where.OR = [ { invoice_number: { contains: search } }, { customers: { name: { contains: search } } }, { customers: { company_id: { contains: search } } }, ]; } const sortField = ALLOWED_SORT_FIELDS.includes(String(query.sort || '')) ? String(query.sort) : 'id'; const orderBy: Record = { [sortField]: order }; const [invoices, total] = await Promise.all([ prisma.invoices.findMany({ where, skip, take: limit, orderBy, include: { customers: { select: { id: true, name: true } }, invoice_items: true, orders: { select: { id: true, order_number: true } }, }, }), prisma.invoices.count({ where }), ]); const enriched = invoices.map(inv => { const totals = computeInvoiceTotals(inv.invoice_items, inv.apply_vat, inv.vat_rate); const { invoice_items, ...rest } = inv; return { ...rest, items: invoice_items, customer_name: inv.customers?.name || null, order_number: inv.orders?.order_number || null, ...totals, }; }); return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) }); }); // GET /api/admin/invoices/next-number fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => { // Match PHP: prefix = YY + invoice_type_code from company_settings const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } }); const typeCode = settings?.invoice_type_code || '81'; const year = new Date().getFullYear(); const yy = String(year).slice(-2); const prefix = `${yy}${typeCode}`; // Atomic numbering via number_sequences table const nextNum = await generateInvoiceNumber(year); const number = `${prefix}${String(nextNum).padStart(4, '0')}`; return success(reply, { number, next_number: number }); }); // GET /api/admin/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 monthStart = new Date(year, month - 1, 1); const monthEnd = new Date(year, month, 0, 23, 59, 59); const allInvoices = await prisma.invoices.findMany({ include: { invoice_items: true }, }); // Helper: compute invoice total WITH VAT (matching PHP) const invoiceTotalWithVat = (inv: typeof allInvoices[0]) => { const sub = inv.invoice_items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0); const vat = inv.apply_vat ? inv.invoice_items.reduce((s, i) => { const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0); return s + base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100); }, 0) : 0; return sub + vat; }; // Helper: aggregate by currency → CurrencyAmount[] const aggregateByCurrency = (invoices: typeof allInvoices) => { const map: Record = {}; for (const inv of invoices) { const cur = inv.currency || 'CZK'; map[cur] = (map[cur] || 0) + invoiceTotalWithVat(inv); } return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency })); }; const sumCzk = (invoices: typeof allInvoices) => { let total = 0; for (const inv of invoices) { total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion } return Math.round(total * 100) / 100; }; const monthInvoices = allInvoices.filter(inv => { const issueDate = inv.issue_date ? new Date(inv.issue_date) : null; return issueDate && issueDate >= monthStart && issueDate <= monthEnd; }); const paidInvoices = monthInvoices.filter(i => i.status === 'paid'); const awaitingInvoices = allInvoices.filter(i => i.status === 'issued'); const overdueInvoices = allInvoices.filter(i => i.status === 'overdue'); // VAT by currency const vatMap: Record = {}; for (const inv of monthInvoices) { if (!inv.apply_vat) continue; const cur = inv.currency || 'CZK'; for (const item of inv.invoice_items) { const base = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); const vat = base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100); vatMap[cur] = (vatMap[cur] || 0) + vat; } } const vatAmounts = Object.entries(vatMap).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency })); let vatCzk = 0; for (const [, v] of Object.entries(vatMap)) vatCzk += v; return success(reply, { paid_month: aggregateByCurrency(paidInvoices), paid_month_czk: sumCzk(paidInvoices), paid_month_count: paidInvoices.length, awaiting: aggregateByCurrency(awaitingInvoices), awaiting_czk: sumCzk(awaitingInvoices), awaiting_count: awaitingInvoices.length, overdue: aggregateByCurrency(overdueInvoices), overdue_czk: sumCzk(overdueInvoices), overdue_count: overdueInvoices.length, vat_month: vatAmounts, vat_month_czk: Math.round(vatCzk * 100) / 100, month, year, }); }); // GET /api/admin/invoices/order-data/:id fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { const orderId = parseId(request.params.id, reply); if (orderId === null) return; const order = await prisma.orders.findUnique({ where: { id: orderId }, include: { customers: true, order_items: { orderBy: { position: 'asc' } }, }, }); if (!order) return error(reply, 'Objednávka nenalezena', 404); const { order_items, customers, ...rest } = order; return success(reply, { ...rest, items: order_items, customer_name: customers?.name || null }); }); // GET /api/admin/invoices/:id 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.invoices.findUnique({ where: { id }, include: { customers: true, invoice_items: { orderBy: { position: 'asc' } }, orders: { select: { id: true, order_number: true } }, }, }); if (!invoice) return error(reply, 'Faktura nenalezena', 404); const { invoice_items, ...rest } = invoice; return success(reply, { ...rest, items: invoice_items, customer: invoice.customers, customer_name: invoice.customers?.name || null, order_number: invoice.orders?.order_number || null, valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [], }); }); // POST /api/admin/invoices fastify.post('/', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { const parsed = parseBody(CreateInvoiceSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const invoice = await prisma.invoices.create({ data: { invoice_number: body.invoice_number ? String(body.invoice_number) : null, order_id: body.order_id ? Number(body.order_id) : null, customer_id: body.customer_id ? Number(body.customer_id) : null, status: body.status ? String(body.status) : 'issued', currency: body.currency ? String(body.currency) : 'CZK', vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0, apply_vat: body.apply_vat !== false, payment_method: body.payment_method ? String(body.payment_method) : null, constant_symbol: body.constant_symbol ? String(body.constant_symbol) : null, bank_name: body.bank_name ? String(body.bank_name) : null, bank_swift: body.bank_swift ? String(body.bank_swift) : null, bank_iban: body.bank_iban ? String(body.bank_iban) : null, bank_account: body.bank_account ? String(body.bank_account) : null, issue_date: body.issue_date ? new Date(String(body.issue_date)) : null, due_date: body.due_date ? new Date(String(body.due_date)) : null, tax_date: body.tax_date ? new Date(String(body.tax_date)) : null, issued_by: body.issued_by ? String(body.issued_by) : null, notes: body.notes ? String(body.notes) : null, internal_notes: body.internal_notes ? String(body.internal_notes) : null, }, }); if (Array.isArray(body.items)) { await prisma.invoice_items.createMany({ data: (body.items as InvoiceItemInput[]).map((item, i) => ({ invoice_id: invoice.id, description: item.description ?? null, quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); } await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` }); // Return both invoice_id and id for frontend compatibility return success(reply, { id: invoice.id, invoice_id: invoice.id, invoice_number: invoice.invoice_number }, 201, 'Faktura byla vystavena'); }); // PUT /api/admin/invoices/:id 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(UpdateInvoiceSchema, request.body); if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; const existing = await prisma.invoices.findUnique({ where: { id } }); if (!existing) return error(reply, 'Faktura nenalezena', 404); const currentStatus = existing.status as string; // Handle status transition if (body.status !== undefined && body.status !== currentStatus) { const newStatus = String(body.status); const allowed = VALID_TRANSITIONS[currentStatus] || []; if (!allowed.includes(newStatus)) { return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400); } } const data: Record = { modified_at: new Date() }; // Only allow full editing in 'issued' state const isDraft = currentStatus === 'issued'; if (isDraft) { const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by']; for (const f of strFields) { if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; } if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null; if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate); if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1'; if (body.issue_date !== undefined) data.issue_date = body.issue_date ? new Date(String(body.issue_date)) : null; if (body.due_date !== undefined) data.due_date = body.due_date ? new Date(String(body.due_date)) : null; if (body.tax_date !== undefined) data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null; } // Notes editable in issued/overdue if (currentStatus === 'issued' || currentStatus === 'overdue') { if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null; if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null; } // Status change if (body.status !== undefined) { data.status = String(body.status); // Auto-set paid_date when transitioning to paid if (String(body.status) === 'paid' && !existing.paid_date) { data.paid_date = new Date(); } } if (body.paid_date !== undefined) data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null; await prisma.invoices.update({ where: { id }, data }); // Only allow items update in draft state if (isDraft && Array.isArray(body.items)) { await prisma.$transaction(async (tx) => { await tx.invoice_items.deleteMany({ where: { invoice_id: id } }); await tx.invoice_items.createMany({ data: (body.items as InvoiceItemInput[]).map((item, i) => ({ invoice_id: id, description: item.description ?? null, quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); }); } await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${existing.invoice_number}` }); return success(reply, { id }, 200, 'Faktura byla aktualizována'); }); // DELETE /api/admin/invoices/:id 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.invoices.findUnique({ where: { id } }); if (!existing) return error(reply, 'Faktura nenalezena', 404); await prisma.invoices.delete({ where: { id } }); await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` }); return success(reply, null, 200, 'Faktura smazána'); }); }