fix(received-invoices): treat amount as gross (VAT-inclusive), back-calc VAT

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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 16:27:56 +02:00
parent 58e5fd2b1d
commit f4bc038f8b
5 changed files with 55 additions and 16 deletions

View File

@@ -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);
});
});

View File

@@ -522,7 +522,7 @@ export default function DashAssistant() {
} }
/> />
<TextField <TextField
label="Částka" label="Částka s DPH"
value={inv.amount} value={inv.amount}
onChange={(e) => patch(inv.uid, "amount", e.target.value)} onChange={(e) => patch(inv.uid, "amount", e.target.value)}
/> />

View File

@@ -932,7 +932,7 @@ export default function ReceivedInvoices({
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}> <Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Field <Field
label="Částka" label="Částka s DPH"
required required
error={uploadErrors[idx]?.amount} error={uploadErrors[idx]?.amount}
> >
@@ -1096,7 +1096,7 @@ export default function ReceivedInvoices({
sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }} sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}
> >
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Field label="Částka" required> <Field label="Částka s DPH" required>
<TextField <TextField
type="number" type="number"
slotProps={{ slotProps={{

View File

@@ -22,6 +22,15 @@ const VALID_STATUSES = ["unpaid", "paid"] as const;
function roundMoney(n: number): number { function roundMoney(n: number): number {
return Math.round(n * 100) / 100; return Math.round(n * 100) / 100;
} }
/**
* VAT contained WITHIN a gross (VAT-inclusive) amount. `amount` is the total to
* pay including tax, so the tax portion is gross * rate / (100 + rate) — e.g.
* 22542.91 @ 21% → 3912.41 (base 18630.50). Returns 0 when there is no VAT.
*/
export function vatFromGross(gross: number, rate: number): number {
return rate > 0 ? roundMoney((gross * rate) / (100 + rate)) : 0;
}
const ALLOWED_SORT_FIELDS = [ const ALLOWED_SORT_FIELDS = [
"id", "id",
"supplier_name", "supplier_name",
@@ -284,11 +293,8 @@ export default async function receivedInvoicesRoutes(
const meta = invoicesMeta[i] || {}; const meta = invoicesMeta[i] || {};
const amount = Number(meta.amount ?? 0); const amount = Number(meta.amount ?? 0);
const vatRate = Number(meta.vat_rate ?? 21); const vatRate = Number(meta.vat_rate ?? 21);
// Amount is net — VAT = amount * rate / 100 // `amount` is the GROSS total (VAT included); VAT is the portion within it.
const vatAmount = const vatAmount = vatFromGross(amount, vatRate);
vatRate > 0
? Math.round(((amount * vatRate) / 100) * 100) / 100
: 0;
const issueDate = meta.issue_date const issueDate = meta.issue_date
? new Date(String(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 = const finalAmount =
body.amount !== undefined body.amount !== undefined
? Number(body.amount) ? Number(body.amount)
@@ -457,9 +463,8 @@ export default async function receivedInvoicesRoutes(
body.vat_rate !== undefined body.vat_rate !== undefined
? Number(body.vat_rate) ? Number(body.vat_rate)
: Number(existing.vat_rate); : Number(existing.vat_rate);
// Amount is net — VAT = amount * rate / 100 // `amount` is the GROSS total (VAT included); VAT is the portion within it.
const computedVat = const computedVat = vatFromGross(finalAmount, finalVatRate);
finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0;
// Auto-set paid_date when status transitions to paid (matching PHP) // Auto-set paid_date when status transitions to paid (matching PHP)
const newStatus = const newStatus =

View File

@@ -207,9 +207,16 @@ const INVOICE_SCHEMA: Record<string, unknown> = {
properties: { properties: {
supplier_name: { type: "string" }, supplier_name: { type: "string" },
invoice_number: { type: ["string", "null"] }, 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" }, 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"] }, issue_date: { type: ["string", "null"] },
due_date: { type: ["string", "null"] }, due_date: { type: ["string", "null"] },
description: { type: ["string", "null"] }, description: { type: ["string", "null"] },
@@ -244,9 +251,10 @@ export async function extractInvoice(
{ {
type: "text", type: "text",
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. " + "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.",
}, },
], ],
}, },