import { describe, it, expect, afterEach, beforeEach } from "vitest"; import { Prisma } from "@prisma/client"; import prisma from "../config/database"; import { invoiceTotalWithVat, createInvoice, updateInvoice, getInvoice, listInvoices, getInvoiceListTotals, } from "../services/invoices.service"; import { CreateInvoiceSchema, UpdateInvoiceSchema, } from "../schemas/invoices.schema"; // `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using // real Decimals here — instead of the old `{ toNumber: () => N }` duck types — // means a regression in genuine Decimal handling (e.g. swapping `.toNumber()` // for `Number(decimal)`) is actually caught. const dec = (n: number | string) => new Prisma.Decimal(n); // Helper so each test reads as a small declarative table of lines. function buildInvoice(opts: { applyVat: boolean; vatRate: number | null; currency?: string; items: Array<{ quantity: number | null; unit_price: number | null; vat_rate: number | null; }>; }) { return { apply_vat: opts.applyVat, vat_rate: opts.vatRate === null ? null : dec(opts.vatRate), currency: opts.currency ?? "CZK", invoice_items: opts.items.map((i) => ({ quantity: i.quantity === null ? null : dec(i.quantity), unit_price: i.unit_price === null ? null : dec(i.unit_price), vat_rate: i.vat_rate === null ? null : dec(i.vat_rate), })), }; } describe("invoiceTotalWithVat", () => { it("calculates subtotal without VAT when apply_vat is false", () => { const inv = buildInvoice({ applyVat: false, vatRate: 21, items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }], }); expect(invoiceTotalWithVat(inv)).toBe(200); }); it("rounds each line VAT to 2 decimals before accumulation", () => { // 3 items @ 33.33 with 21% VAT // Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00 // Total VAT = 7.00 * 3 = 21.00 // Subtotal = 33.33 * 3 = 99.99 // Total = 99.99 + 21.00 = 120.99 const inv = buildInvoice({ applyVat: true, vatRate: 21, items: Array.from({ length: 3 }, () => ({ quantity: 1, unit_price: 33.33, vat_rate: 21, })), }); expect(invoiceTotalWithVat(inv)).toBe(120.99); }); it("treats a null quantity (and null unit_price) as 0 for that line", () => { // The line is genuinely null here (not 0): quantity?.toNumber() === undefined // -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed // line proves the null line contributes nothing while the rest still totals. const inv = buildInvoice({ applyVat: true, vatRate: 21, items: [ { quantity: null, unit_price: null, vat_rate: 21 }, { quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21 ], }); expect(invoiceTotalWithVat(inv)).toBe(121); }); it("returns 0 when every line is null", () => { const inv = buildInvoice({ applyVat: true, vatRate: 21, items: [{ quantity: null, unit_price: null, vat_rate: null }], }); expect(invoiceTotalWithVat(inv)).toBe(0); }); it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => { // Line 1: 2 x 100 @ 21% -> base 200, vat 42 // Line 2: 1 x 50 @ 12% -> base 50, vat 6 // Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice // default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15 // Subtotal = 350, VAT = 63, Total = 413 const inv = buildInvoice({ applyVat: true, vatRate: 15, items: [ { quantity: 2, unit_price: 100, vat_rate: 21 }, { quantity: 1, unit_price: 50, vat_rate: 12 }, { quantity: 1, unit_price: 100, vat_rate: 0 }, ], }); expect(invoiceTotalWithVat(inv)).toBe(413); }); it("handles a negative (discount) line correctly", () => { // Line 1: 1 x 1000 @ 21% -> base 1000, vat 210 // Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42 // Subtotal = 800, VAT = 168, Total = 968 const inv = buildInvoice({ applyVat: true, vatRate: 21, items: [ { quantity: 1, unit_price: 1000, vat_rate: 21 }, { quantity: 1, unit_price: -200, vat_rate: 21 }, ], }); expect(invoiceTotalWithVat(inv)).toBe(968); }); }); /* -------------------------------------------------------------------------- */ /* PUT regression — billing_text must survive UpdateInvoiceSchema */ /* -------------------------------------------------------------------------- */ // The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the // PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object, // which STRIPS unknown keys — so a field missing from the schema is silently // dropped before the service ever sees it, while the client still gets a // success response. This block mirrors that exact flow against the real DB. describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => { const invoiceIds: number[] = []; afterEach(async () => { for (const id of invoiceIds) await prisma.invoices.deleteMany({ where: { id } }); invoiceIds.length = 0; }); it("persists an edited billing_text (the schema must not strip it)", async () => { const draft = await createInvoice({ billing_text: "Původní text" }); invoiceIds.push(draft.id); // Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice. const parsed = UpdateInvoiceSchema.parse({ billing_text: "Fakturujeme Vám dle objednávky č. 123", }); expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123"); const res = await updateInvoice(draft.id, parsed); expect("error" in res).toBe(false); const row = await prisma.invoices.findUnique({ where: { id: draft.id } }); expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123"); }); it("clears billing_text when null is sent, and leaves it untouched when absent", async () => { const draft = await createInvoice({ billing_text: "Text na PDF" }); invoiceIds.push(draft.id); // Absent from the body -> updateInvoice's `!== undefined` guard skips it. await updateInvoice( draft.id, UpdateInvoiceSchema.parse({ internal_notes: "x" }), ); let row = await prisma.invoices.findUnique({ where: { id: draft.id } }); expect(row?.billing_text).toBe("Text na PDF"); // Explicit null -> cleared (the strFields path maps falsy -> null). await updateInvoice( draft.id, UpdateInvoiceSchema.parse({ billing_text: null }), ); row = await prisma.invoices.findUnique({ where: { id: draft.id } }); expect(row?.billing_text).toBeNull(); }); }); /* -------------------------------------------------------------------------- */ /* "Obsah" sections + internal-only notes (mirrors issued orders) */ /* -------------------------------------------------------------------------- */ describe("invoice sections round-trip; dropped `notes` is stripped", () => { const invoiceIds: number[] = []; afterEach(async () => { for (const id of invoiceIds) await prisma.invoices.deleteMany({ where: { id } }); invoiceIds.length = 0; }); it("creates, returns and replaces CZ/EN sections in position order", async () => { const parsed = CreateInvoiceSchema.parse({ billing_text: "Sekce test", sections: [ { title: "Scope", title_cz: "Rozsah", content: "
obsah 1
" }, { title: "", title_cz: "Podmínky", content: "obsah 2
" }, ], }); const draft = await createInvoice(parsed); invoiceIds.push(draft.id); const detail = await getInvoice(draft.id); expect(detail?.sections?.length).toBe(2); expect(detail?.sections?.[0].title_cz).toBe("Rozsah"); expect(detail?.sections?.[1].title_cz).toBe("Podmínky"); expect(detail?.sections?.[0].position).toBe(0); expect(detail?.sections?.[1].position).toBe(1); // Full-replace on update (same model as items). const upd = UpdateInvoiceSchema.parse({ sections: [ { title: "Only", title_cz: "Jediná", content: "nový obsah
" }, ], }); const res = await updateInvoice(draft.id, upd); expect("error" in res).toBe(false); const after = await getInvoice(draft.id); expect(after?.sections?.length).toBe(1); expect(after?.sections?.[0].title_cz).toBe("Jediná"); expect(after?.sections?.[0].content).toBe("nový obsah
"); }); it("sections survive an items-only update untouched (absent = keep)", async () => { const draft = await createInvoice( CreateInvoiceSchema.parse({ sections: [{ title: "", title_cz: "Drží", content: "x
" }], }), ); invoiceIds.push(draft.id); await updateInvoice( draft.id, UpdateInvoiceSchema.parse({ items: [{ description: "Práce", quantity: 1, unit_price: 100 }], }), ); const after = await getInvoice(draft.id); expect(after?.sections?.length).toBe(1); expect(after?.sections?.[0].title_cz).toBe("Drží"); expect(after?.items?.length).toBe(1); }); it("silently strips the dropped `notes` key from legacy payloads", async () => { // The printed-notes column was dropped (notes are internal-only now) — // a legacy client still sending `notes` must not 500 or write anything. const parsedCreate = CreateInvoiceSchema.parse({ notes: "stará poznámka
", internal_notes: "interní
", }); expect("notes" in parsedCreate).toBe(false); const draft = await createInvoice(parsedCreate); invoiceIds.push(draft.id); const row = await prisma.invoices.findUnique({ where: { id: draft.id } }); expect(row?.internal_notes).toBe("interní
"); const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" }); expect("notes" in parsedUpdate).toBe(false); const res = await updateInvoice(draft.id, parsedUpdate); expect("error" in res).toBe(false); }); }); /* -------------------------------------------------------------------------- */ /* Month filter boundaries — issue_date is @db.Date */ /* -------------------------------------------------------------------------- */ // issue_date is @db.Date: Prisma compares a Date filter by its UTC date part. // The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day) // shifted the window a day back — the list/totals included the previous // month's last day and DROPPED invoices issued on the selected month's LAST // day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals, // so both are exercised. Fixtures use the test-only "TST" currency + far-future // 2098 dates so totals can be asserted exactly without colliding with other // suites' rows. describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => { const cleanup = async () => { // invoice_items cascade on invoice delete. await prisma.invoices.deleteMany({ where: { currency: "TST" } }); }; beforeEach(cleanup); afterEach(cleanup); const listParams = { page: 1, limit: 100, skip: 0, sort: "id", order: "asc" as const, search: "", }; async function createBoundaryFixtures() { // Drafts: no invoice number is consumed, but buildInvoiceWhere has no // status filter so they appear in the list/totals like any invoice. const lastDayJan = await createInvoice({ status: "draft", issue_date: "2098-01-31", currency: "TST", vat_rate: 21, items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121 }); const firstDayFeb = await createInvoice({ status: "draft", issue_date: "2098-02-01", currency: "TST", vat_rate: 21, items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242 }); return { lastDayJan, firstDayFeb }; } it("a month's list contains its own LAST day and not the neighbor month's first day", async () => { const { lastDayJan, firstDayFeb } = await createBoundaryFixtures(); const jan = await listInvoices({ ...listParams, month: 1, year: 2098 }); const janIds = jan.data.map((i) => i.id); expect(janIds).toContain(lastDayJan.id); expect(janIds).not.toContain(firstDayFeb.id); const feb = await listInvoices({ ...listParams, month: 2, year: 2098 }); const febIds = feb.data.map((i) => i.id); expect(febIds).toContain(firstDayFeb.id); expect(febIds).not.toContain(lastDayJan.id); }); it("per-currency totals include the month's last day exactly once", async () => { await createBoundaryFixtures(); const jan = await getInvoiceListTotals({ month: 1, year: 2098 }); const janTst = jan.totals.find((t) => t.currency === "TST"); // 100 + 21 % VAT — proves the Jan 31 invoice is counted and the // Feb 1 invoice (242) is NOT pulled into January. expect(janTst?.amount).toBe(121); const feb = await getInvoiceListTotals({ month: 2, year: 2098 }); const febTst = feb.totals.find((t) => t.currency === "TST"); expect(febTst?.amount).toBe(242); }); });