feat(vat)!: remove VAT entirely from offers, orders and issued orders
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>
This commit is contained in:
@@ -8,8 +8,9 @@ import {
|
||||
|
||||
// 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 TOTAL (incl. VAT) per currency across the WHOLE filtered
|
||||
// set, and that the where (month/year + status) is respected.
|
||||
// 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).
|
||||
@@ -46,19 +47,17 @@ function amountFor(
|
||||
}
|
||||
|
||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
||||
// applied on the whole subtotal (enrichOrder math).
|
||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
||||
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",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
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 () => {
|
||||
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
@@ -122,48 +119,43 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
||||
// 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(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
||||
// 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(1210);
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1210.
|
||||
// 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(1210);
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
||||
// order here the result matches the on-the-whole subtotal math.
|
||||
// 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
|
||||
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,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: dateStr,
|
||||
items: [
|
||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||
createdIssuedIds.push(o.id);
|
||||
@@ -178,23 +170,19 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
||||
expect(amountFor(totals, "EUR")).toBe(363);
|
||||
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 1210). Next month: one.
|
||||
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
||||
const draft = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in draft)
|
||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||
@@ -203,12 +191,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
const sent = await createIssuedOrder({
|
||||
status: "sent",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if ("error" in sent)
|
||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||
@@ -216,37 +200,33 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
||||
|
||||
const next = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: nextMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
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 1210 = 2420.
|
||||
// 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(2420);
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
||||
// 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(1210);
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||
|
||||
// Next month sees only the one order there -> 1210.
|
||||
// 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(1210);
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user