import prisma from "../config/database"; import { utcMidnightOfLocalDay } from "../utils/date"; import { toCzk } from "./exchange-rates"; import { generateInvoiceNumber, releaseInvoiceNumber, assignInvoiceNumber, } from "./numbering.service"; // Status transition rules matching PHP. // draft -> issued is the finalize step (consumes + assigns the invoice number). const VALID_TRANSITIONS: Record = { draft: ["issued"], issued: ["paid"], overdue: ["paid"], paid: [], }; const ALLOWED_SORT_FIELDS = [ "id", "invoice_number", "status", "issue_date", "due_date", "currency", ]; interface InvoiceItemInput { description?: string | null; item_description?: string | null; quantity?: number; unit?: string | null; unit_price?: number; vat_rate?: number; position?: number; } interface InvoiceSectionInput { title?: string | null; title_cz?: string | null; content?: string | null; } /** * Body shape accepted by createInvoice/updateInvoice. All fields optional and * loosely typed because the payload arrives validated-but-coerced from the Zod * route layer (numbers may still be string-from-form). Kept permissive so the * existing String()/Number() coercions below stay correct; replaces the old * `Record`. */ export interface InvoiceInput { invoice_number?: string | number | null; order_id?: number | string | null; customer_id?: number | string | null; status?: string; currency?: string; vat_rate?: number | string | null; apply_vat?: boolean | number | string; payment_method?: string | null; constant_symbol?: string | null; bank_name?: string | null; bank_swift?: string | null; bank_iban?: string | null; bank_account?: string | null; issue_date?: string | null; due_date?: string | null; tax_date?: string | null; issued_by?: string | null; billing_text?: string | null; language?: string; internal_notes?: string | null; paid_date?: string | null; items?: InvoiceItemInput[]; sections?: InvoiceSectionInput[]; [key: string]: unknown; } /** * Single rounding-correct money/VAT core. All three former implementations * (computeInvoiceTotals, invoiceTotalWithVat, the stats VAT loop) funnel through * this so the subtotal/accumulation/rounding logic cannot diverge again. * * Each caller resolves its own per-line VAT *rate* before calling (the three * paths historically differ in how a line rate of 0 is treated — that nuance * is preserved by keeping rate resolution caller-side via `resolveRate`). * * - `subtotal` = Σ(qty × unit_price) over lines. * - `vat` = Σ of per-line VAT. When `roundPerLine` is true each line's VAT is * rounded to 2 decimals BEFORE accumulation (invoice totals); when false the * raw per-line VAT is accumulated and the caller rounds the sum (stats). * * Numeric outputs are byte-for-byte identical to the previous three code paths. */ function computeMoney( lines: T[], applyVat: boolean | null, getBase: (line: T) => number, resolveRate: (line: T) => number, roundPerLine: boolean, ): { subtotal: number; vat: number } { let subtotal = 0; let vat = 0; for (const line of lines) { const base = getBase(line); subtotal += base; if (applyVat) { const lineVat = base * (resolveRate(line) / 100); vat += roundPerLine ? Math.round(lineVat * 100) / 100 : lineVat; } } return { subtotal, vat }; } interface InvoiceFilterParams { search?: string; status?: string; customer_id?: number; month?: number; year?: number; } interface ListInvoicesParams extends InvoiceFilterParams { page: number; limit: number; skip: number; sort: string; order: "asc" | "desc"; search: string; } export interface CurrencyAmount { amount: number; currency: string; } /** * Shared `where` builder for the issued-invoices list and the per-currency * list-totals aggregation, so both stay in sync (any new filter applies to * both). month/year filter on issue_date (matching the list). */ function buildInvoiceWhere( params: InvoiceFilterParams, ): Record { const { status, customer_id, month, year, search } = params; const where: Record = {}; if (status) where.status = status; if (customer_id) where.customer_id = customer_id; if (month && year) { // issue_date is @db.Date: Prisma compares by UTC date part, so the month // boundaries must be UTC midnights. Local midnights shifted the window a // day back — the filter included the previous month's last day and // DROPPED invoices issued on the selected month's last day. const from = new Date(Date.UTC(year, month - 1, 1)); const to = new Date(Date.UTC(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 } } }, ]; } return where; } function computeInvoiceTotals( items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown, ) { const { subtotal, vat: vatAmount } = computeMoney( items, applyVat, (i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), // Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used. (i) => i.vat_rate != null && i.vat_rate !== "" ? Number(i.vat_rate) : defaultVatRate != null ? Number(defaultVatRate) : 21, true, // round each line's VAT before accumulation ); 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 { // due_date is @db.Date (UTC midnight of the calendar day). Compare // against UTC midnight of the LOCAL today — a bare new Date() has // yesterday's UTC date during the 00:00–02:00 Prague window, so the // overdue flip lagged a day there. Due TODAY = not yet overdue. const today = utcMidnightOfLocalDay(); await prisma.invoices.updateMany({ where: { status: "issued", due_date: { lt: today } }, data: { status: "overdue" }, }); // Reverse: if due_date was changed to today/future, set back to issued await prisma.invoices.updateMany({ where: { status: "overdue", due_date: { gte: today } }, data: { status: "issued" }, }); } catch (err) { console.error("markOverdueInvoices failed:", err); } } export async function listInvoices(params: ListInvoicesParams) { const { page, limit, skip, sort, order } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where = buildInvoiceWhere(params); 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 }; } /** * Sum issued-invoice TOTAL (incl. VAT) per currency across the WHOLE filtered * set (not a single page). Reuses `buildInvoiceWhere` so the filters track the * list, and `computeInvoiceTotals` so the per-invoice math matches the * list/detail exactly. Returns one entry per currency, rounded to 2dp, * zero/empty totals dropped. * * NOTE: This is SEPARATE from `getInvoiceStats` (the monthly KPI cards). That * function is untouched — this only sums the list's visible rows per currency. */ export async function getInvoiceListTotals( params: InvoiceFilterParams, ): Promise<{ totals: CurrencyAmount[] }> { const where = buildInvoiceWhere(params); const invoices = await prisma.invoices.findMany({ where, include: { invoice_items: true }, }); const byCurrency: Record = {}; for (const inv of invoices) { const { total } = computeInvoiceTotals( inv.invoice_items, inv.apply_vat, inv.vat_rate, ); const cur = inv.currency || "CZK"; byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0); } const totals: CurrencyAmount[] = Object.entries(byCurrency) .map(([currency, amount]) => ({ currency, amount: Math.round(amount * 100) / 100, })) .filter((t) => t.amount > 0); return { totals }; } export { generateInvoiceNumber as getNextInvoiceNumberFormatted, previewInvoiceNumber as getNextInvoiceNumberPreview, } from "./numbering.service"; export function invoiceTotalWithVat(inv: { apply_vat: boolean | null; vat_rate: { toNumber(): number } | null; currency: string | null; invoice_items: Array<{ quantity: { toNumber(): number } | null; unit_price: { toNumber(): number } | null; vat_rate: { toNumber(): number } | null; }>; }) { const { subtotal, vat } = computeMoney( inv.invoice_items, inv.apply_vat, (i) => (Number(i.quantity?.toNumber()) || 0) * (Number(i.unit_price?.toNumber()) || 0), // Preserve original `||` semantics: a line rate of 0/NaN falls through to // the invoice default, then 21. (i) => Number(i.vat_rate?.toNumber()) || Number(inv.vat_rate?.toNumber()) || 21, true, // round each line's VAT before accumulation ); return subtotal + vat; } export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { const now = new Date(); const year = queryYear || now.getFullYear(); const month = queryMonth || now.getMonth() + 1; // issue_date is @db.Date (compared by UTC date part) → UTC-midnight period // boundaries, half-open [gte, lt) ranges. Local-midnight bounds included // the previous period's boundary day in the stats. const monthStart = new Date(Date.UTC(year, month - 1, 1)); const nextMonthStart = new Date(Date.UTC(year, month, 1)); const startOfYear = new Date(Date.UTC(year, 0, 1)); const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1)); const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([ prisma.invoices.findMany({ where: { // Drafts are not real financial documents — exclude them so their // VAT/amounts never inflate the monthly stats (vat_month etc.). status: { not: "draft" }, issue_date: { gte: monthStart, lt: nextMonthStart }, }, include: { invoice_items: true }, }), prisma.invoices.findMany({ where: { status: "issued", issue_date: { gte: startOfYear, lt: startOfNextYear }, }, include: { invoice_items: true }, }), prisma.invoices.findMany({ where: { status: "overdue", issue_date: { gte: startOfYear, lt: startOfNextYear }, }, include: { invoice_items: true }, }), ]); const aggregateByCurrency = ( invoices: Parameters[0][], ) => { 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: Parameters[0][], ) => { 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 paidInvoices = monthInvoices.filter((i) => i.status === "paid"); // VAT for the month, per currency. Uses the shared money core with // roundPerLine=false (the stats path historically accumulates RAW per-line // VAT and rounds only the final sum — preserved byte-for-byte). const vatMap: Record = {}; for (const inv of monthInvoices) { if (!inv.apply_vat) continue; const cur = inv.currency || "CZK"; const { vat } = computeMoney( inv.invoice_items, inv.apply_vat, (item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), (item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21, false, // accumulate raw per-line VAT; round only the per-currency sum ); 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, })); // VAT also needs CZK conversion — run each currency's toCzk concurrently. const vatCzkConverted = ( await Promise.all( Object.entries(vatMap).map(([cur, amount]) => toCzk(amount, cur)), ) ).reduce((s, v) => s + v, 0); // These aggregates/conversions are independent — run them concurrently // instead of serially awaiting each in the return literal. const [paidMonthCzk, awaitingCzk, overdueCzk] = await Promise.all([ sumCzk(paidInvoices), sumCzk(awaitingInvoices), sumCzk(overdueInvoices), ]); return { paid_month: aggregateByCurrency(paidInvoices), paid_month_czk: paidMonthCzk, paid_month_count: paidInvoices.length, awaiting: aggregateByCurrency(awaitingInvoices), awaiting_czk: awaitingCzk, awaiting_count: awaitingInvoices.length, overdue: aggregateByCurrency(overdueInvoices), overdue_czk: overdueCzk, 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 }, // This result is spread into the order-data API response — never include // the PO attachment blob (served only by GET /orders/:id/attachment). omit: { attachment_data: true }, 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, }; } // NOTE: returns `null` (not `{ error, status }`) on not-found — intentional and // load-bearing: routes/admin/invoices.ts checks `if (!invoice) 404`. Returning a // truthy `{ error }` object here would bypass that 404 guard, so this stays null. export async function getInvoice(id: number) { const invoice = await prisma.invoices.findUnique({ where: { id }, include: { customers: true, invoice_items: { orderBy: { position: "asc" } }, // id tiebreak: position can repeat, keep the order deterministic. invoice_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] }, orders: { select: { id: true, order_number: true } }, }, }); if (!invoice) return null; const { invoice_items, invoice_sections, ...rest } = invoice; return { ...rest, items: invoice_items, sections: invoice_sections, 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: InvoiceInput) { const status = body.status ? String(body.status) : "draft"; // Deferred numbering: a draft carries NO invoice_number (the column is // nullable-unique so many drafts coexist). The official number is consumed // only when the draft is finalized (assignInvoiceNumber on draft->issued). // A caller-supplied number, or an invoice created already-finalized, numbers now. const explicit = body.invoice_number !== undefined && body.invoice_number !== null ? String(body.invoice_number) : null; const invoiceNumber = explicit ?? (status === "draft" ? null : (await generateInvoiceNumber()).number); // Header + items + sections are ONE write — a failure mid-way must not // leave a headerless/partial invoice behind. const invoice = await prisma.$transaction(async (tx) => { const created = await tx.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, currency: body.currency ? String(body.currency) : "CZK", vat_rate: body.vat_rate != null ? 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", internal_notes: body.internal_notes ? String(body.internal_notes) : null, }, }); if (Array.isArray(body.items)) { await tx.invoice_items.createMany({ data: body.items.map((item, i) => ({ invoice_id: created.id, description: item.description ?? null, item_description: item.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, })), }); } if (Array.isArray(body.sections)) { await tx.invoice_sections.createMany({ data: body.sections.map((s, i) => ({ invoice_id: created.id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: i, })), }); } return created; }); return invoice; } export async function updateInvoice(id: number, body: InvoiceInput) { 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 'draft', 'issued' and 'overdue' states. const editable = currentStatus === "draft" || currentStatus === "issued" || currentStatus === "overdue"; if (editable) { 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; } // Internal notes editable in draft/issued/overdue (never printed) if (editable) { 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. 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. if (String(body.status) === "paid" && !existing.paid_date) { data.paid_date = utcMidnightOfLocalDay(); } } if (body.paid_date !== undefined && currentStatus !== "paid") data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null; // Finalize transition draft -> issued: consume + write the official invoice // number ATOMICALLY with the status change, so there is never a window where // the invoice is "issued" but still has a null invoice_number. // assignInvoiceNumber is idempotent (won't re-number an already-numbered row). const finalizing = currentStatus === "draft" && body.status !== undefined && String(body.status) === "issued"; let assignedNumber: string | null = null; if (finalizing) { await prisma.$transaction(async (tx) => { await tx.invoices.update({ where: { id }, data }); assignedNumber = await assignInvoiceNumber(id, tx); }); } else { await prisma.invoices.update({ where: { id }, data }); } // Allow items/sections full-replace while editable (draft/issued/overdue) — // both in ONE transaction so a failure can't leave a half-replaced document. const replaceItems = editable && Array.isArray(body.items); const replaceSections = editable && Array.isArray(body.sections); if (replaceItems || replaceSections) { await prisma.$transaction(async (tx) => { if (replaceItems) { await tx.invoice_items.deleteMany({ where: { invoice_id: id } }); await tx.invoice_items.createMany({ data: body.items!.map((item, i) => ({ invoice_id: id, description: item.description ?? null, item_description: item.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, })), }); } if (replaceSections) { await tx.invoice_sections.deleteMany({ where: { invoice_id: id } }); await tx.invoice_sections.createMany({ data: body.sections!.map((s, i) => ({ invoice_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: i, })), }); } }); } // Reflect the number the finalize transition just assigned (existing was read // before the assign, so its invoice_number is still the pre-finalize null). return { id, invoice_number: assignedNumber ?? 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 } }); // Derive the sequence year from the invoice number ("/"). Guard the // split: a non-standard number (no "/" or a non-4-digit / non-numeric part) // falls back to the current year rather than releasing a bogus sequence. const yearPart = existing.invoice_number?.split("/")[1]; const parsedYear = yearPart && /^\d{4}$/.test(yearPart) ? Number(yearPart) : NaN; const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear; await releaseInvoiceNumber(year, existing.invoice_number ?? undefined); return existing; }