Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.
- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
{total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
removed from OfferDetail/OrderDetail/IssuedOrderDetail/
OrderConfirmationModal/ReceivedOrders manual-create; list footers read
'Celkem bez DPH'; invoice-from-order prefill now takes the company default
VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
exact cs+en note text and VAT-free layout on all three PDFs
Invoices and received invoices keep their VAT handling unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|