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

@@ -22,6 +22,15 @@ const VALID_STATUSES = ["unpaid", "paid"] as const;
function roundMoney(n: number): number {
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 = [
"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 =