From 9e9aacdf0bbfe72b3aa28557ded3134882e244e4 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 19:04:10 +0200 Subject: [PATCH] fix(drafts): exclude drafts from VAT/revenue/created aggregations + koncept audit labels Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/drafts-aggregation.test.ts | 116 +++++++++++++++++++++++ src/routes/admin/dashboard.ts | 7 +- src/routes/admin/invoices.ts | 8 +- src/routes/admin/issued-orders.ts | 2 +- src/routes/admin/quotations.ts | 2 +- src/services/invoices.service.ts | 3 + 6 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/drafts-aggregation.test.ts diff --git a/src/__tests__/drafts-aggregation.test.ts b/src/__tests__/drafts-aggregation.test.ts new file mode 100644 index 0000000..e64d8e2 --- /dev/null +++ b/src/__tests__/drafts-aggregation.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { createInvoice, getInvoiceStats } from "../services/invoices.service"; +import { createOffer } from "../services/offers.service"; + +// These tests prove that DRAFT documents (a status introduced by the DB-drafts +// feature) are NOT counted in period reporting aggregations. A draft is not a +// real financial document, so its VAT/amount/created-count must be excluded. +// +// They hit the real `app_test` DB (per the suite convention) via the service +// layer directly. Every row we create is tracked and removed in afterEach so we +// leave the DB clean, and we reset the touched number sequences so a finalized +// (issued/active) doc created here doesn't leak a consumed number into other +// files. + +const invoiceIds: number[] = []; +const offerIds: number[] = []; + +afterEach(async () => { + for (const id of invoiceIds) + await prisma.invoices.deleteMany({ where: { id } }); + for (const id of offerIds) + await prisma.quotations.deleteMany({ where: { id } }); + invoiceIds.length = 0; + offerIds.length = 0; + await prisma.number_sequences.deleteMany({ + where: { type: { in: ["invoice", "offer"] } }, + }); +}); + +describe("drafts excluded from invoice monthly stats (VAT)", () => { + it("a draft invoice's VAT is NOT in vat_month while an issued one IS", async () => { + // Pick a far-future month so no seeded/other-test invoices fall in it and + // the totals are deterministic. issue_date drives the stats window. + const year = 2099; + const month = 7; + const issueDate = `${year}-0${month}-15`; + + // Single CZK line, 21% VAT: base 1000, VAT 210. Identical on both docs so + // the only difference between "draft excluded" and "issued counted" is the + // status filter under test. + const item = { quantity: 1, unit_price: 1000, vat_rate: 21 }; + + const draft = await createInvoice({ + status: "draft", + currency: "CZK", + apply_vat: true, + vat_rate: 21, + issue_date: issueDate, + items: [item], + }); + invoiceIds.push(draft.id); + expect(draft.status).toBe("draft"); + expect(draft.invoice_number).toBeNull(); + + // Sanity: with ONLY the draft present, vat_month must be empty (draft excluded). + const draftOnly = await getInvoiceStats(month, year); + expect(draftOnly.vat_month).toEqual([]); + expect(draftOnly.vat_month_czk).toBe(0); + + const issued = await createInvoice({ + status: "issued", + currency: "CZK", + apply_vat: true, + vat_rate: 21, + issue_date: issueDate, + items: [item], + }); + invoiceIds.push(issued.id); + expect(issued.status).toBe("issued"); + + // Now the issued invoice's VAT (210 CZK) must be counted — and ONLY that + // one, not the draft's (which would double it to 420 if drafts leaked in). + const stats = await getInvoiceStats(month, year); + const czk = stats.vat_month.find((v) => v.currency === "CZK"); + expect(czk?.amount).toBe(210); + expect(stats.vat_month_czk).toBe(210); + }); +}); + +describe("drafts excluded from quotations created-this-month count", () => { + it("a draft offer is NOT counted; an active one IS", async () => { + // Mirror the dashboard route's exact aggregation: count quotations created + // in the window, excluding drafts. Use a tight window around `now` (the + // route uses the current month) and assert the delta this test introduces. + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); + + const countNonDraft = () => + prisma.quotations.count({ + where: { + status: { not: "draft" }, + created_at: { gte: monthStart, lt: monthEnd }, + }, + }); + + const before = await countNonDraft(); + + const draft = await createOffer({ status: "draft" }); + if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`); + offerIds.push(draft.id); + expect(draft.status).toBe("draft"); + + // The draft must not move the non-draft count. + expect(await countNonDraft()).toBe(before); + + const active = await createOffer({ status: "active" }); + if ("error" in active) + throw new Error(`createOffer failed: ${active.error}`); + offerIds.push(active.id); + + // The active offer is a real document → the count goes up by exactly 1. + expect(await countNonDraft()).toBe(before + 1); + }); +}); diff --git a/src/routes/admin/dashboard.ts b/src/routes/admin/dashboard.ts index 4ee14da..85d143c 100644 --- a/src/routes/admin/dashboard.ts +++ b/src/routes/admin/dashboard.ts @@ -198,7 +198,11 @@ export default async function dashboardRoutes( prisma.quotations.count({ where: { status: "ordered" } }), prisma.quotations.count({ where: { status: "invalidated" } }), prisma.quotations.count({ - where: { created_at: { gte: monthStart, lt: monthEnd } }, + where: { + // Drafts aren't real offers — keep them out of the count. + status: { not: "draft" }, + created_at: { gte: monthStart, lt: monthEnd }, + }, }), ]); result.offers = { @@ -246,6 +250,7 @@ export default async function dashboardRoutes( FROM invoices i JOIN invoice_items ii ON i.id = ii.invoice_id WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr} + AND i.status <> 'draft' GROUP BY i.currency `, ]); diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 2c03225..cd02a5d 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -127,13 +127,15 @@ export default async function invoicesRoutes( const invoice = await createInvoice(body); + // A draft has no number yet — show "koncept" instead of "null". + const isDraft = invoice.status === "draft"; await logAudit({ request, authData: request.authData, action: "create", entityType: "invoice", entityId: invoice.id, - description: `Vytvořena faktura ${invoice.invoice_number}`, + description: `Vytvořena faktura ${invoice.invoice_number ?? "koncept"}`, }); // Return both invoice_id and id for frontend compatibility return success( @@ -144,7 +146,9 @@ export default async function invoicesRoutes( invoice_number: invoice.invoice_number, }, 201, - "Faktura byla vystavena", + isDraft + ? "Faktura byla uložena jako koncept" + : "Faktura byla vystavena", ); }, ); diff --git a/src/routes/admin/issued-orders.ts b/src/routes/admin/issued-orders.ts index 679bb63..9544995 100644 --- a/src/routes/admin/issued-orders.ts +++ b/src/routes/admin/issued-orders.ts @@ -81,7 +81,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { action: "create", entityType: "issued_order", entityId: order.id, - description: `Vytvořena vydaná objednávka ${order.po_number}`, + description: `Vytvořena vydaná objednávka ${order.po_number ?? "koncept"}`, }); return success( reply, diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index 0bc3fc9..822afe7 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -252,7 +252,7 @@ export default async function quotationsRoutes( action: "create", entityType: "quotation", entityId: quotation.id, - description: `Vytvořena nabídka ${quotation.quotation_number}`, + description: `Vytvořena nabídka ${quotation.quotation_number ?? "koncept"}`, }); return success( reply, diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index 704dc76..92d9c13 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -268,6 +268,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { 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, lte: monthEnd }, }, include: { invoice_items: true },