import prisma from "../config/database"; import { toCzk } from "./exchange-rates"; import { generateInvoiceNumber, releaseInvoiceNumber, } from "./numbering.service"; // 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; } interface ListInvoicesParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; status?: string; customer_id?: number; month?: number; year?: 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 async function markOverdueInvoices() { try { await prisma.invoices.updateMany({ where: { status: "issued", due_date: { lt: new Date() } }, data: { status: "overdue" }, }); // Reverse: if due_date was changed to future, set back to issued await prisma.invoices.updateMany({ where: { status: "overdue", due_date: { gte: new Date() } }, data: { status: "issued" }, }); } catch (err) { console.error("markOverdueInvoices failed:", err); } } export async function listInvoices(params: ListInvoicesParams) { const { page, limit, skip, sort, order, search, status, customer_id, month, year, } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where: Record = {}; if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (month && year) { const from = new Date(year, month - 1, 1); const to = new Date(year, month, 1); where.issue_date = { gte: from, lt: to }; } if (search) { where.OR = [ { invoice_number: { contains: search } }, { customers: { name: { contains: search } } }, { customers: { company_id: { contains: search } } }, ]; } 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 { data: enriched, total, page, limit }; } export { generateInvoiceNumber as getNextInvoiceNumberFormatted, previewInvoiceNumber as getNextInvoiceNumberPreview, } from "./numbering.service"; export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { const now = new Date(); const year = queryYear || now.getFullYear(); const month = queryMonth || 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 }, }); 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; }; 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 = async (invoices: typeof allInvoices) => { let total = 0; for (const inv of invoices) { const amount = invoiceTotalWithVat(inv); total += await toCzk(amount, inv.currency || "CZK"); } 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"); 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; // VAT also needs conversion let vatCzkConverted = 0; for (const [cur, amount] of Object.entries(vatMap)) { vatCzkConverted += await toCzk(amount, cur); } return { paid_month: aggregateByCurrency(paidInvoices), paid_month_czk: await sumCzk(paidInvoices), paid_month_count: paidInvoices.length, awaiting: aggregateByCurrency(awaitingInvoices), awaiting_czk: await sumCzk(awaitingInvoices), awaiting_count: awaitingInvoices.length, overdue: aggregateByCurrency(overdueInvoices), overdue_czk: await sumCzk(overdueInvoices), overdue_count: overdueInvoices.length, vat_month: vatAmounts, vat_month_czk: Math.round(vatCzkConverted * 100) / 100, month, year, }; } export async function getOrderDataForInvoice(orderId: number) { const order = await prisma.orders.findUnique({ where: { id: orderId }, include: { customers: true, order_items: { orderBy: { position: "asc" } }, }, }); if (!order) return null; const { order_items, customers, ...rest } = order; return { ...rest, items: order_items, customer_name: customers?.name || null, }; } export async function getInvoice(id: number) { 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 null; const { invoice_items, ...rest } = invoice; return { ...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] || [], }; } export async function createInvoice(body: Record) { const invoiceNumber = body.invoice_number !== undefined && body.invoice_number !== null ? String(body.invoice_number) : (await generateInvoiceNumber()).number; const invoice = await prisma.invoices.create({ data: { invoice_number: invoiceNumber, 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, billing_text: body.billing_text ? String(body.billing_text) : null, language: body.language ? String(body.language) : "cs", 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, })), }); } return invoice; } export async function updateInvoice(id: number, body: Record) { const existing = await prisma.invoices.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const }; 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: "invalid_transition" as const, currentStatus, newStatus }; } } const data: Record = { modified_at: new Date() }; // Allow full editing in 'issued' and 'overdue' states const isDraft = currentStatus === "issued" || currentStatus === "overdue"; if (isDraft) { const strFields = [ "currency", "payment_method", "constant_symbol", "bank_name", "bank_swift", "bank_iban", "bank_account", "issued_by", "billing_text", "language", ]; 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 && currentStatus !== "paid") 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, })), }); }); } return { id, invoice_number: existing.invoice_number }; } export async function deleteInvoice(id: number) { const existing = await prisma.invoices.findUnique({ where: { id } }); if (!existing) return null; await prisma.invoices.delete({ where: { id } }); const year = existing.created_at ? new Date(existing.created_at).getFullYear() : new Date().getFullYear(); await releaseInvoiceNumber(year); return existing; }