From f4bc038f8b1e443ad0f915cae63cfb29a53563c8 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 16:27:56 +0200 Subject: [PATCH] fix(received-invoices): treat amount as gross (VAT-inclusive), back-calc VAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amount on a received invoice is the GROSS total the user pays (VAT included), and VAT is the portion contained within it — not a net base with VAT added on top. Switch the formula from amount*rate/100 to the VAT-inclusive amount*rate/(100+rate) via a shared, tested vatFromGross() helper used by both the create (manual upload + AI import share this endpoint) and edit paths. Rate 0 → no VAT. - AI extraction now asks Claude for the total INCLUDING VAT (was "základ bez DPH"), reinforced in the structured-output schema description. - Label the amount field "Částka s DPH" in the AI review card and the manual upload/edit forms so the gross convention is explicit. - Add unit tests with the user-confirmed figures (22 542,91 @ 21% → 3 912,41, base 18 630,50) plus other rates and the no-VAT case. Existing rows keep their stored (gross) amounts; their vat_amount recomputes on next edit. A one-shot backfill can correct historical vat_amount if wanted. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/received-invoices-vat.test.ts | 26 +++++++++++++++++++ .../components/dashboard/DashAssistant.tsx | 2 +- src/admin/pages/ReceivedInvoices.tsx | 4 +-- src/routes/admin/received-invoices.ts | 23 +++++++++------- src/services/ai.service.ts | 16 +++++++++--- 5 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/received-invoices-vat.test.ts diff --git a/src/__tests__/received-invoices-vat.test.ts b/src/__tests__/received-invoices-vat.test.ts new file mode 100644 index 0000000..598ccfd --- /dev/null +++ b/src/__tests__/received-invoices-vat.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { vatFromGross } from "../routes/admin/received-invoices"; + +// `amount` on a received invoice is the GROSS total (VAT included). The VAT is +// the portion contained within it: gross * rate / (100 + rate). +describe("vatFromGross (VAT contained in a gross total)", () => { + it("derives VAT from a real gross total at 21%", () => { + // 22 542,91 @ 21% → 3 912,41 (base 18 630,50). User-confirmed figures. + expect(vatFromGross(22542.91, 21)).toBeCloseTo(3912.41, 2); + expect(22542.91 - vatFromGross(22542.91, 21)).toBeCloseTo(18630.5, 2); + }); + + it("derives VAT at other rates", () => { + expect(vatFromGross(1210, 21)).toBeCloseTo(210, 2); // base 1000 + expect(vatFromGross(1100, 10)).toBeCloseTo(100, 2); // base 1000 + expect(vatFromGross(1150, 15)).toBeCloseTo(150, 2); // base 1000 + }); + + it("returns 0 when there is no VAT", () => { + expect(vatFromGross(5000, 0)).toBe(0); + }); + + it("returns 0 for a zero amount", () => { + expect(vatFromGross(0, 21)).toBe(0); + }); +}); diff --git a/src/admin/components/dashboard/DashAssistant.tsx b/src/admin/components/dashboard/DashAssistant.tsx index e9cd1bf..dc39c98 100644 --- a/src/admin/components/dashboard/DashAssistant.tsx +++ b/src/admin/components/dashboard/DashAssistant.tsx @@ -522,7 +522,7 @@ export default function DashAssistant() { } /> patch(inv.uid, "amount", e.target.value)} /> diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index 3580ce6..d9c933c 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -932,7 +932,7 @@ export default function ReceivedInvoices({ @@ -1096,7 +1096,7 @@ export default function ReceivedInvoices({ sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }} > - + 0 ? roundMoney((gross * rate) / (100 + rate)) : 0; +} const ALLOWED_SORT_FIELDS = [ "id", "supplier_name", @@ -284,11 +293,8 @@ export default async function receivedInvoicesRoutes( const meta = invoicesMeta[i] || {}; const amount = Number(meta.amount ?? 0); const vatRate = Number(meta.vat_rate ?? 21); - // Amount is net — VAT = amount * rate / 100 - const vatAmount = - vatRate > 0 - ? Math.round(((amount * vatRate) / 100) * 100) / 100 - : 0; + // `amount` is the GROSS total (VAT included); VAT is the portion within it. + const vatAmount = vatFromGross(amount, vatRate); const issueDate = meta.issue_date ? new Date(String(meta.issue_date)) @@ -448,7 +454,7 @@ export default async function receivedInvoicesRoutes( } } - // Recalculate vat_amount when amount or vat_rate changes (matching PHP) + // Recalculate vat_amount when amount (gross) or vat_rate changes. const finalAmount = body.amount !== undefined ? Number(body.amount) @@ -457,9 +463,8 @@ export default async function receivedInvoicesRoutes( body.vat_rate !== undefined ? Number(body.vat_rate) : Number(existing.vat_rate); - // Amount is net — VAT = amount * rate / 100 - const computedVat = - finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0; + // `amount` is the GROSS total (VAT included); VAT is the portion within it. + const computedVat = vatFromGross(finalAmount, finalVatRate); // Auto-set paid_date when status transitions to paid (matching PHP) const newStatus = diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 40c2fad..65a745d 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -207,9 +207,16 @@ const INVOICE_SCHEMA: Record = { properties: { supplier_name: { type: "string" }, invoice_number: { type: ["string", "null"] }, - amount: { type: "number" }, + amount: { + type: "number", + description: + "Celková částka k úhradě VČETNĚ DPH (gross total), NE základ bez DPH.", + }, currency: { type: "string" }, - vat_rate: { type: "number" }, + vat_rate: { + type: "number", + description: "Sazba DPH v procentech; 0 pokud faktura nemá DPH.", + }, issue_date: { type: ["string", "null"] }, due_date: { type: ["string", "null"] }, description: { type: ["string", "null"] }, @@ -244,9 +251,10 @@ export async function extractInvoice( { type: "text", text: - "Vyčti z této přijaté faktury pole dodavatele, číslo faktury, částku (základ bez DPH), " + + "Vyčti z této přijaté faktury tato pole: dodavatele, číslo faktury, " + + "celkovou částku k úhradě VČETNĚ DPH (tj. konečný součet, NE základ bez DPH), " + "měnu (ISO kód), sazbu DPH v procentech, datum vystavení a splatnosti (YYYY-MM-DD) a krátký popis. " + - "Pokud pole chybí, vrať null.", + "Pokud faktura nemá DPH, vrať sazbu 0. Pokud pole chybí, vrať null.", }, ], },