VAT Rate Statement Check (§14 Abs. 4 Nr. 8) — Design
Draft

VAT Rate Statement Check (§14 Abs. 4 Nr. 8)

2026-05-18Danielspec

Problem

§ 14 Abs. 4 Satz 1 Nr. 8 UStG requires an invoice to state both the applicable tax rate and the tax amount ("der anzuwendende Steuersatz sowie der auf das Entgelt entfallende Steuerbetrag" — "and", not "or"). Some supplier invoices print the net total, the tax amount, and the gross total but omit the explicit rate (e.g. no "19 %" anywhere). That is a formal compliance defect we should surface.

Today we never flag it. check-required-fields already requires tax-rate for the EU tier, but the check runs on extracted structured-data, where the LLM derives tax-rate: 19 from tax-amount ÷ net even when no rate is printed. So has-tax-rate? is satisfied by an inferred value and the invoice passes.

Root cause

Every downstream check (check-required-fields, the tax-compliance analyzer) consumes post-extraction structured-data, which has no "printed vs inferred" signal. document.provenance is edit-provenance only (human vs LLM since last ingestion), not source evidence. The signal does not exist and must be created at extraction time.

Concrete instance: invoice RE_CFG-20260518100554_TPM (bikosigma GmbH) prints Summe, Umsatzsteuer 5.044,36 €, and Gesamtbetrag but no rate. It currently passes required-fields.

Goals & non-goals

Goals

Non-goals

Approach

An extraction-emitted boolean is the only reliable source of truth (chosen over an OCR-text heuristic or extending the structured-data-only tax-compliance analyzer). It mirrors the discipline already applied to tax-amount ("extract only if printed, do NOT calculate") and keeps the deterministic check deterministic.

Pros

  • High precision; LLM reliably reports presence of a printed token
  • Consistent with existing tax-amount pattern
  • Deterministic check stays deterministic
  • UVR backstop covers extraction false-negatives

Cons

  • Extraction-prompt change → needs a regression/eval pass
  • Retroactive coverage needs re-ingest, not just recompute
Decision

Bare boolean :tax-rate-stated? (schema-optional / nilable), rate-only scope, surfaced as a warning, with UVR as the final decision-maker. A separate UVR sub-path — not folded into required-fields — because the semantics differ: required-fields = "data we could not find"; this = "the data exists but the invoice omits the printed rate".

Design

extraction check-tax-rate-stated deterministic uncertain UVR resolver vision-LLM · final say pass · not-applicable · warning findings → banner + DATEV cover pass / not-applicable → stored directly, no warning
Deterministic check only escalates; UVR is the sole decision-maker.

1. Extraction signal

Add [:tax-rate-stated? {:optional true} [:maybe :boolean]] to InvoiceData (schema/invoice/structured_data.clj). Optional and nilable, mirroring [:freight-included [:maybe :boolean]] and the {:optional true} precedent on :summary-page-range. One prompt instruction in extraction.clj, parallel to the existing tax-amount rule:

Warning

It must not be a required key. InvoiceData is an open Malli :map validated non-blockingly (ingestion.clj only logs + sets :valid-structured-data), but a required key would fail the existing structured_data_test.clj fixtures and flip every legacy doc to "schema invalid" on re-ingest/recompute. The deterministic check treats absent OR nil identically → pass (legacy docs stay silent).

prompttax-rate-stated? = true ONLY if the applicable VAT rate is printed
verbatim on the invoice (e.g. "19 %", "MwSt 19%", "USt 19 %",
"zzgl. 19% USt") OR an explicit exemption / reverse-charge note is
present. false if the rate is absent and only derivable from amounts.
Do NOT infer.

2. Deterministic check (validation.clj)

New check-tax-rate-stated, sibling to check-required-fields. Returns one of pass / not-applicable / uncertain. It never terminally warns. Evaluation is an ordered cond — rows are tried top to bottom, first match wins:

#ConditionResult
1:tax-rate-stated? is true (extractor saw it printed)pass — short-circuits everything below
2:tax-rate-stated? absent or nil (legacy / pre-feature doc)pass
3invoice-tier is :non-eupass (out of scope, as today)
4No positive VAT: neither a printed tax-amount nor any positive-rate tax-rate-breakdowns entrypass
5Reverse-charge / exempt: a compliance-statements entry of type reverse-charge / vat-exemption (or legal-basis §13b), with tax-rate 0/nilnot-applicable
Ordered trigger matrix. "Positive VAT" is decided from printed fields (tax-amount / tax-rate-breakdowns) — never the LLM-inferred invoice-level tax-rate, which is the value that defeats the existing check. Kleinbetrag is in scope (§33 UStDV still requires the rate; only the net/tax split is waived), but row 4 runs before any tier logic so a degenerate nil-total:kleinbetrag classification on a zero-VAT doc cannot warn.
Decision

Reverse-charge precedence: the extractor-set :tax-rate-stated? = true (row 1) always wins — including when it set it true because a reverse-charge note is present. The row-5 deterministic exempt branch only matters when :tax-rate-stated? is false/nil. The prompt and the deterministic check therefore cannot contradict each other.

3. UVR integration (uncertain_validations.clj)

4. Surfacing

Warning

Check ordering is duplicated across five hardcoded lists, not just findings.clj. All five must include :tax-rate-stated or the warning renders in the banner card but is invisible in the issue-count badge and the document-list semaphore, or renders with no status text / empty tooltip.

SurfaceChange
findings.cljformal-requirement-checksAdd :tax-rate-stated to ordering (rides formal-finding → banner + DATEV cover page, severity :warning)
findings.cljvalidation-check-labelsAdd :tax-rate-stated → "VAT Rate Statement"
view/invoice.clj:201 — hardcoded formal-issue-count literalAdd :tax-rate-stated so the "Validation N" header badge counts it
components.cljvalidation-check-order (≈1944)Add :tax-rate-stated so the document-list validation-semaphore reflects it
components.cljformal-status-texts (≈1971) & validation-check-descriptions (≈1958)Add status-text map + tooltip text, else the row renders raw status / empty tooltip
All consumers of the per-check ordering. The contract-document path has its own parallel set and is out of scope (invoice-only feature).

Message: "Invoice does not state the applicable VAT rate (§14 Abs. 4 Nr. 8 UStG); only the tax amount is shown."

5. Testing & retroactivity

Open questions

References