Invoices now mirror the issued-orders document model: - New invoice_sections table (CZ/EN rich-text "Obsah") edited via the shared SectionsEditor, printed inline right after the items on the PDF. Full-replace on update, same transaction as items. - Printed notes dropped: the notes column is removed (migration merges existing content into internal_notes first); the form field is now "Interni poznamky", never printed. Legacy payloads sending notes are silently stripped. - Form cleanup: Cislo faktury and Vystavil fields removed (number lives in the header, issued_by auto-fills); page header title restyled to the orders/offers pattern (number span + status chip). - Unified per-page PDF header for the red-accent family: shared buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red rule) rendered by a Puppeteer headerTemplate on EVERY page of both invoices and issued orders (incl. the /file fallback render); body headers are print-hidden. htmlToPdf gained the headerTemplate option. - Footer parity: invoices get the per-page "Vystavil + Strana X z Y" footer; the invoice bottom block (notice + QR/VAT recap + Prevzal) is break-inside: avoid so a page break can never split it. - @page margins now match the template space (32mm top, 18mm bottom) - Chromium lays out by CSS @page margins, which also fixes issued orders' content running into the 18mm footer zone on full pages. BREAKING CHANGE: invoices.notes column dropped (data merged into internal_notes); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
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: "<p>obsah 1</p>" },
|
|
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
|
|
],
|
|
});
|
|
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: "<p>nový obsah</p>" },
|
|
],
|
|
});
|
|
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("<p>nový obsah</p>");
|
|
});
|
|
|
|
it("sections survive an items-only update untouched (absent = keep)", async () => {
|
|
const draft = await createInvoice(
|
|
CreateInvoiceSchema.parse({
|
|
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
|
|
}),
|
|
);
|
|
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: "<p>stará poznámka</p>",
|
|
internal_notes: "<p>interní</p>",
|
|
});
|
|
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("<p>interní</p>");
|
|
|
|
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);
|
|
});
|
|
});
|