Bilko B5 — Per-Line VAT Exemption Classification (MC #103593, 2026-06-15)

Context & Decision

Date: 2026-06-15  |  MC: #103593  |  Decision authority: CEO (Alem Basic)

Domain expert Vlado Brkanć reviewed the existing VAT exemption approach during his B5 classification review (MC #103508). The prior system derived a single document-level allExempt flag in GlBridge.kt and auto-selected the VATEX code from the organisation VAT number prefix (orgVatNum.startsWith("EU")).

Why this was legally insufficient:

CEO decision 2026-06-14: Option C — Hybrid model. The system auto-suggests an exemption code per line at invoice-creation time; the accountant must confirm or override before issuing to Sveracun. Exemption code is NOT a hard-block on invoice creation (create always succeeds; validation runs at the SveRacun submit boundary).

What Changed (B5 Implementation)

Database — V90 Migration

File: apps/api/src/main/resources/db/migration/V90__invoice_item_vat_exemption_code.sql

Schema (Prisma)

InvoiceItem model: vatExemptionCode String? field added (nullable, no default).

Exposed Table

InvoiceItems in Tables.kt: vatExemptionCode column wired to V90.

Canonical Invoice Builder (HrEInvoiceService.kt)

UBL Output (StorecoveHrFiskEInvoiceAdapter.kt)

HrUblBuilder emits TaxExemptionReasonCode (BT-121) and TaxExemptionReason (BT-120) in each TaxSubtotal block, one per exemption code group.

GL Bridge (GlBridge.kt)

The allExempt heuristic is replaced with a per-item lookup:

// Before (B1 heuristic):
val allExempt = items.all { it[InvoiceItems.vatExempt] }
val vatExemptionCode = when {
    allExempt && jurisdiction == "HR" && orgVatNum?.startsWith("EU") == true -> "EU_41"
    allExempt -> "EXEMPT_39"
    else -> null
}

// After (B5):
val itemCodes = items.mapNotNull { it[InvoiceItems.vatExemptionCode] }.distinct()
val vatExemptionCode = if (itemCodes.size == 1) itemCodes.first() else null

Mixed-code invoices result in null at document level — GL rule R-5 (standard/mixed) applies.

Credit Notes (CN, type 381)

Full CN (storno): vatExemptionCode is inherited per-line from the original invoice items. Partial CN: inherited from caller-supplied item map. Both paths persist to invoice_items and the GL bridge receives the correct code without the allExempt re-derivation. The createCreditNote path in InvoiceService.kt was updated accordingly.

Invoice Service (InvoiceService.kt)

createInvoice and updateInvoice: accept and persist vatExemptionCode per item. Code validation against the four allowed values happens here (not in the DB).

VATEX Mapping Table

Bilko CodeEN 16931 VATEXBT-120 Text (HR)ZPDV Article
EU_41vatex-eu-icOslobođenje od PDV-a — isporuka unutar EUčl. 41 ZPDV
EXPORT_45vatex-eu-gOslobođenje od PDV-a — izvoz u treće zemlječl. 45 ZPDV
EXEMPT_39vatex-eu-oOslobođenje od PDV-a — usluge/isporuke po čl. 39 ZPDVčl. 39 ZPDV
EXEMPT_40vatex-eu-eOslobođenje od PDV-a — financijske/osigurateljne uslugečl. 40 ZPDV

EN 16931 BT-121 carries the VATEX code; BT-120 carries the human-readable reason text. A mixed invoice produces one TaxSubtotal group per distinct code.

Hybrid UX Model

Open Item — čl.79 ZPDV Statutory Text

A TODO(B5-art79) marker is present in HrEInvoiceService.vatexReasonText(). Before embedding the exact Croatian statutory article text in the printed invoice UI, the precise subpoint of čl.79 st.1 ZPDV NN must be confirmed by fetching the operative text from narodne-novine.nn.hr. This is an open item for Lexicon/legal review before the printed-invoice feature ships.

Test Evidence

SuiteTestsPassNotes
VatExemptionB5Test (new)77All B5-specific assertions
HrEInvoiceCanonicalInvoiceTest (B3)55Regression
TaxCorrectnessB4Test~15~15Regression
PostingRuleEngineTest~20~20Regression
CreditNote381ComplianceTest~5~5Regression
Full suite156415604 pre-existing INFRA-SKIP (SVERACUN_SENDER_VAT env var missing, not B5)

Commit: 256d539e on branch feat/103593-b5-exemption. Build: PASS (0 compile errors; 4 pre-existing infra-skip failures are env-var-gated and not introduced by B5).

Deploy Status

NOT yet deployed. Branch work is Proveo-verified (build PASS, all B5 tests PASS). Deploy is pending the Azure migration — GCP billing is inactive; the production environment is moving to Azure. This page will be updated when the deploy completes.

Prerequisite: Flyway V90 migration must run against the target database before the service starts.


Revision #1
Created 2026-06-14 23:05:47 UTC by John
Updated 2026-06-14 23:05:48 UTC by John