import { describe, it, expect, afterEach } from "vitest"; import prisma from "../config/database"; import { createOffer, getOfferTotals } from "../services/offers.service"; import { createInvoice, getInvoiceListTotals, } from "../services/invoices.service"; import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices"; // Per-currency total aggregation for the offers, issued-invoices and // received-invoices lists. These hit the real `app_test` DB via the service // layer (suite convention) and prove the totals fns sum each document's TOTAL // per currency across the WHOLE filtered set, and that the filtering `where` // (search / month-year / status) is respected — mirroring order-totals.test.ts. // // For invoices a far-future month/year is used so seeded/other-test rows can't // fall in the window and skew the deterministic sums. Offers have NO date // filter, so they are isolated by a unique `project_code` searched on instead. // Received invoices store amount as GROSS (VAT-inclusive), summed directly. const STATS_YEAR = 2097; const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01) // Unique marker so the offers test (which has no date filter) can scope its // aggregation to only the rows it created via a `search` on project_code. const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`; const createdOfferIds: number[] = []; const createdInvoiceIds: number[] = []; const createdReceivedIds: number[] = []; afterEach(async () => { for (const id of createdOfferIds) await prisma.quotations.deleteMany({ where: { id } }); for (const id of createdInvoiceIds) await prisma.invoices.deleteMany({ where: { id } }); for (const id of createdReceivedIds) await prisma.received_invoices.deleteMany({ where: { id } }); createdOfferIds.length = 0; createdInvoiceIds.length = 0; createdReceivedIds.length = 0; // Finalized offers/invoices consume their sequences — reset so we don't leak // a consumed number into sibling test files. await prisma.number_sequences.deleteMany({ where: { type: { in: ["offer", "invoice"] } }, }); }); function amountFor( totals: { currency: string; amount: number }[], currency: string, ): number | undefined { return totals.find((t) => t.currency === currency)?.amount; } describe("getOfferTotals per-currency aggregation", () => { it("sums offer NET total per currency over the full filtered set", async () => { // Two CZK offers + one EUR. NET only (enrichQuotation math — offers are // not tax documents, no VAT anywhere). // CZK #1: 2 x 1000 = 2000 // CZK #2: 1 x 500 = 500 => CZK total 2500 // EUR : 3 x 100 = 300 => EUR total 300 const mk = async (currency: string, qty: number, price: number) => { const res = await createOffer({ status: "draft", // draft so no offer number is consumed currency, project_code: OFFER_MARKER, items: [{ description: "X", quantity: qty, unit_price: price }], }); if ("error" in res) throw new Error(JSON.stringify(res)); createdOfferIds.push(res.id); return res.id; }; await mk("CZK", 2, 1000); await mk("CZK", 1, 500); await mk("EUR", 3, 100); const { totals } = await getOfferTotals({ search: OFFER_MARKER }); expect(amountFor(totals, "CZK")).toBe(2500); expect(amountFor(totals, "EUR")).toBe(300); }); it("respects the where: a status filter narrows the result", async () => { const mk = async (status: string) => { const res = await createOffer({ status, currency: "CZK", project_code: OFFER_MARKER, items: [{ description: "X", quantity: 1, unit_price: 1000 }], }); if ("error" in res) throw new Error(JSON.stringify(res)); createdOfferIds.push(res.id); return res.id; }; // Two drafts + one invalidated, all sharing the marker. await mk("draft"); await mk("draft"); await mk("invalidated"); // Marker only: all three count -> 3 x 1000 = 3000 (net). const all = await getOfferTotals({ search: OFFER_MARKER }); expect(amountFor(all.totals, "CZK")).toBe(3000); // Status filter narrows to the two drafts -> 2 x 1000 = 2000. const onlyDraft = await getOfferTotals({ search: OFFER_MARKER, status: "draft", }); expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000); // Invalidated alone -> 1000. const onlyInvalid = await getOfferTotals({ search: OFFER_MARKER, status: "invalidated", }); expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000); }); }); describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => { // issue_date is settable at create, so no post-create pin needed. VAT is // applied per-line (computeInvoiceTotals) but with a single line per invoice // here the result matches the whole-subtotal math. const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`; const mk = async ( currency: string, qty: number, price: number, status = "draft", ) => { const inv = await createInvoice({ status, currency, vat_rate: 21, apply_vat: true, issue_date: dateStr, items: [{ description: "X", quantity: qty, unit_price: price }], }); createdInvoiceIds.push(inv.id); return inv.id; }; it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => { // CZK #1: 2 x 1000 @21% = 2420 // CZK #2: 1 x 500 @21% = 605 => CZK total 3025 // EUR : 3 x 100 @21% = 363 => EUR total 363 await mk("CZK", 2, 1000); await mk("CZK", 1, 500); await mk("EUR", 3, 100); const { totals } = await getInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, }); expect(amountFor(totals, "CZK")).toBe(3025); expect(amountFor(totals, "EUR")).toBe(363); }); it("respects the where: a different month and a status filter change the result", async () => { const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`; // Target month: one draft, one issued (both 1210). Next month: one draft. await mk("CZK", 1, 1000, "draft"); // An already-issued invoice numbers immediately; that's fine for the sum. const issued = await createInvoice({ status: "issued", currency: "CZK", vat_rate: 21, apply_vat: true, issue_date: dateStr, items: [{ description: "X", quantity: 1, unit_price: 1000 }], }); createdInvoiceIds.push(issued.id); const next = await createInvoice({ status: "draft", currency: "CZK", vat_rate: 21, apply_vat: true, issue_date: nextDateStr, items: [{ description: "X", quantity: 1, unit_price: 1000 }], }); createdInvoiceIds.push(next.id); // Whole target month: both count -> 2 x 1210 = 2420. const whole = await getInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, }); expect(amountFor(whole.totals, "CZK")).toBe(2420); // Status filter narrows to the single 'issued' in the target month -> 1210. const onlyIssued = await getInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, status: "issued", }); expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210); // Next month sees only the one invoice there -> 1210. const nextMonth = await getInvoiceListTotals({ month: STATS_MONTH + 1, year: STATS_YEAR, }); expect(amountFor(nextMonth.totals, "CZK")).toBe(1210); }); }); describe("received-invoices list-totals per-currency aggregation (GROSS)", () => { // No service create for received invoices — rows are written directly. The // list/totals `where` filters by the month/year COLUMNS (not issue_date). // amount is GROSS, summed directly per currency. const mkReceived = async (currency: string, amount: number) => { const row = await prisma.received_invoices.create({ data: { month: STATS_MONTH, year: STATS_YEAR, supplier_name: "Totals Test Supplier", amount, currency, vat_rate: 21, status: "unpaid", }, }); createdReceivedIds.push(row.id); return row.id; }; it("sums GROSS amount per currency over the full filtered set", async () => { // Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is. await mkReceived("CZK", 2420); await mkReceived("CZK", 605); await mkReceived("EUR", 363); const { totals } = await getReceivedInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, }); expect(amountFor(totals, "CZK")).toBe(3025); expect(amountFor(totals, "EUR")).toBe(363); }); it("respects the where: a different month and a search filter change the result", async () => { await mkReceived("CZK", 1000); // A row in the NEXT month must not count toward the target month's total. const other = await prisma.received_invoices.create({ data: { month: STATS_MONTH + 1, year: STATS_YEAR, supplier_name: "Totals Test Supplier", amount: 9999, currency: "CZK", vat_rate: 21, status: "unpaid", }, }); createdReceivedIds.push(other.id); const { totals } = await getReceivedInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, }); expect(amountFor(totals, "CZK")).toBe(1000); // Search on the supplier name still scopes to the target-month row. const { totals: searched } = await getReceivedInvoiceListTotals({ month: STATS_MONTH, year: STATS_YEAR, search: "Totals Test Supplier", }); expect(amountFor(searched, "CZK")).toBe(1000); }); });