import { describe, it, expect, afterEach } from "vitest"; import { Prisma } from "@prisma/client"; import prisma from "../config/database"; import { invoiceTotalWithVat, createInvoice, updateInvoice, } from "../services/invoices.service"; import { 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({ 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(); }); });