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>
233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import prisma from "../config/database";
|
|
import { createOrder, getOrderTotals } from "../services/orders.service";
|
|
import {
|
|
createIssuedOrder,
|
|
getIssuedOrderTotals,
|
|
} from "../services/issued-orders.service";
|
|
|
|
// Per-currency total aggregation for both order lists. These hit the real
|
|
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
|
// endpoints sum order NET total per currency across the WHOLE filtered set
|
|
// (orders are not tax documents — no VAT anywhere), and that the where
|
|
// (month/year + status) is respected.
|
|
//
|
|
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
|
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
|
|
|
const STATS_YEAR = 2097;
|
|
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
|
|
|
|
const createdOrderIds: number[] = [];
|
|
const createdIssuedIds: number[] = [];
|
|
const createdCustomerIds: number[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const id of createdOrderIds)
|
|
await prisma.orders.deleteMany({ where: { id } });
|
|
for (const id of createdIssuedIds)
|
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
|
for (const id of createdCustomerIds)
|
|
await prisma.customers.deleteMany({ where: { id } });
|
|
createdOrderIds.length = 0;
|
|
createdIssuedIds.length = 0;
|
|
createdCustomerIds.length = 0;
|
|
// Finalized orders consume the shared/issued sequences — reset so we don't
|
|
// leak a consumed number into sibling test files.
|
|
await prisma.number_sequences.deleteMany({
|
|
where: { type: { in: ["shared", "issued_order", "project"] } },
|
|
});
|
|
});
|
|
|
|
function amountFor(
|
|
totals: { currency: string; amount: number }[],
|
|
currency: string,
|
|
): number | undefined {
|
|
return totals.find((t) => t.currency === currency)?.amount;
|
|
}
|
|
|
|
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|
it("sums NET total per currency over the full filtered set", async () => {
|
|
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
|
// (enrichOrder math — orders carry no VAT).
|
|
// 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 createOrder({
|
|
status: "prijata",
|
|
currency,
|
|
language: "cs",
|
|
create_project: false,
|
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
|
});
|
|
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
|
createdOrderIds.push(res.id!);
|
|
// created_at is auto-managed; pin it into the stats window so the
|
|
// month/year filter is deterministic.
|
|
await prisma.orders.update({
|
|
where: { id: res.id },
|
|
data: {
|
|
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
|
|
},
|
|
});
|
|
return res.id!;
|
|
};
|
|
|
|
await mk("CZK", 2, 1000);
|
|
await mk("CZK", 1, 500);
|
|
await mk("EUR", 3, 100);
|
|
|
|
const { totals } = await getOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
|
expect(amountFor(totals, "EUR")).toBe(300);
|
|
});
|
|
|
|
it("respects the where: a different month and a status filter change the result", async () => {
|
|
const mk = async (status: string, monthOffset: number) => {
|
|
const res = await createOrder({
|
|
status,
|
|
currency: "CZK",
|
|
language: "cs",
|
|
create_project: false,
|
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
|
});
|
|
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
|
createdOrderIds.push(res.id!);
|
|
await prisma.orders.update({
|
|
where: { id: res.id },
|
|
data: {
|
|
created_at: new Date(
|
|
STATS_YEAR,
|
|
STATS_MONTH - 1 + monthOffset,
|
|
15,
|
|
12,
|
|
0,
|
|
0,
|
|
),
|
|
},
|
|
});
|
|
return res.id!;
|
|
};
|
|
|
|
// One 'prijata' in the target month, one 'stornovana' in the target month,
|
|
// one 'prijata' in the NEXT month.
|
|
await mk("prijata", 0);
|
|
await mk("stornovana", 0);
|
|
await mk("prijata", 1);
|
|
|
|
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
|
const whole = await getOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
|
|
|
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
|
const onlyPrijata = await getOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
status: "prijata",
|
|
});
|
|
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
|
|
|
// Next month sees only the one 'prijata' there -> 1000.
|
|
const nextMonth = await getOrderTotals({
|
|
month: STATS_MONTH + 1,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
|
});
|
|
});
|
|
|
|
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
|
it("sums NET total per currency over the full filtered set", async () => {
|
|
// order_date is settable at create, so no post-create pin needed. NET only
|
|
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
|
// CZK #1: 2 x 1000 = 2000
|
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
|
// EUR : 3 x 100 = 300 => EUR total 300
|
|
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
|
const mk = async (currency: string, qty: number, price: number) => {
|
|
const o = await createIssuedOrder({
|
|
currency,
|
|
order_date: dateStr,
|
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
|
});
|
|
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
|
createdIssuedIds.push(o.id);
|
|
return o.id;
|
|
};
|
|
|
|
await mk("CZK", 2, 1000);
|
|
await mk("CZK", 1, 500);
|
|
await mk("EUR", 3, 100);
|
|
|
|
const { totals } = await getIssuedOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
|
expect(amountFor(totals, "EUR")).toBe(300);
|
|
});
|
|
|
|
it("respects the where: a different month and a status filter change the result", async () => {
|
|
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
|
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
|
|
|
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
|
const draft = await createIssuedOrder({
|
|
currency: "CZK",
|
|
order_date: inMonth,
|
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
|
});
|
|
if ("error" in draft)
|
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
|
createdIssuedIds.push(draft.id);
|
|
|
|
const sent = await createIssuedOrder({
|
|
status: "sent",
|
|
currency: "CZK",
|
|
order_date: inMonth,
|
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
|
});
|
|
if ("error" in sent)
|
|
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
|
createdIssuedIds.push(sent.id);
|
|
|
|
const next = await createIssuedOrder({
|
|
currency: "CZK",
|
|
order_date: nextMonth,
|
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
|
});
|
|
if ("error" in next)
|
|
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
|
createdIssuedIds.push(next.id);
|
|
|
|
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
|
const whole = await getIssuedOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
|
|
|
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
|
const onlySent = await getIssuedOrderTotals({
|
|
month: STATS_MONTH,
|
|
year: STATS_YEAR,
|
|
status: "sent",
|
|
});
|
|
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
|
|
|
// Next month sees only the one order there -> 1000.
|
|
const nextStats = await getIssuedOrderTotals({
|
|
month: STATS_MONTH + 1,
|
|
year: STATS_YEAR,
|
|
});
|
|
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
|
});
|
|
});
|