fix(drafts): exclude drafts from VAT/revenue/created aggregations + koncept audit labels
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
src/__tests__/drafts-aggregation.test.ts
Normal file
116
src/__tests__/drafts-aggregation.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -198,7 +198,11 @@ export default async function dashboardRoutes(
|
|||||||
prisma.quotations.count({ where: { status: "ordered" } }),
|
prisma.quotations.count({ where: { status: "ordered" } }),
|
||||||
prisma.quotations.count({ where: { status: "invalidated" } }),
|
prisma.quotations.count({ where: { status: "invalidated" } }),
|
||||||
prisma.quotations.count({
|
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 = {
|
result.offers = {
|
||||||
@@ -246,6 +250,7 @@ export default async function dashboardRoutes(
|
|||||||
FROM invoices i
|
FROM invoices i
|
||||||
JOIN invoice_items ii ON i.id = ii.invoice_id
|
JOIN invoice_items ii ON i.id = ii.invoice_id
|
||||||
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
|
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
|
||||||
|
AND i.status <> 'draft'
|
||||||
GROUP BY i.currency
|
GROUP BY i.currency
|
||||||
`,
|
`,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -127,13 +127,15 @@ export default async function invoicesRoutes(
|
|||||||
|
|
||||||
const invoice = await createInvoice(body);
|
const invoice = await createInvoice(body);
|
||||||
|
|
||||||
|
// A draft has no number yet — show "koncept" instead of "null".
|
||||||
|
const isDraft = invoice.status === "draft";
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
action: "create",
|
action: "create",
|
||||||
entityType: "invoice",
|
entityType: "invoice",
|
||||||
entityId: invoice.id,
|
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 both invoice_id and id for frontend compatibility
|
||||||
return success(
|
return success(
|
||||||
@@ -144,7 +146,9 @@ export default async function invoicesRoutes(
|
|||||||
invoice_number: invoice.invoice_number,
|
invoice_number: invoice.invoice_number,
|
||||||
},
|
},
|
||||||
201,
|
201,
|
||||||
"Faktura byla vystavena",
|
isDraft
|
||||||
|
? "Faktura byla uložena jako koncept"
|
||||||
|
: "Faktura byla vystavena",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
action: "create",
|
action: "create",
|
||||||
entityType: "issued_order",
|
entityType: "issued_order",
|
||||||
entityId: order.id,
|
entityId: order.id,
|
||||||
description: `Vytvořena vydaná objednávka ${order.po_number}`,
|
description: `Vytvořena vydaná objednávka ${order.po_number ?? "koncept"}`,
|
||||||
});
|
});
|
||||||
return success(
|
return success(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export default async function quotationsRoutes(
|
|||||||
action: "create",
|
action: "create",
|
||||||
entityType: "quotation",
|
entityType: "quotation",
|
||||||
entityId: quotation.id,
|
entityId: quotation.id,
|
||||||
description: `Vytvořena nabídka ${quotation.quotation_number}`,
|
description: `Vytvořena nabídka ${quotation.quotation_number ?? "koncept"}`,
|
||||||
});
|
});
|
||||||
return success(
|
return success(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
where: {
|
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 },
|
issue_date: { gte: monthStart, lte: monthEnd },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user