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: Mixed-exemption invoices (e.g. one line: EU IC supply čl.41, second line: domestic exempt čl.39) cannot be represented by a single document-level code. EN 16931 mandates a separate TaxSubtotal group per exemption category. Croatian VAT law ( čl.79 ZPDV, NN ) requires the specific statutory article reference in the UBL TaxExemptionReasonCode (BT-121) and TaxExemptionReason (BT-120). Auto-deriving the code from the country field or the organisation VAT prefix is not deterministic — a Croatian organisation may issue both EU IC supplies and domestically exempt supplies on the same invoice. The allExempt && jurisdiction == "HR" && orgVatNum?.startsWith("EU") heuristic silently emitted EU_41 for any fully-exempt HR invoice whose organisation happened to have an EU-format VAT number, regardless of the actual legal basis. 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 ALTER TABLE invoice_items ADD COLUMN IF NOT EXISTS vat_exemption_code VARCHAR(20) NULL Additive only — no default, no NOT NULL. Pre-B5 rows remain NULL. No CHECK constraint in DB; application layer ( InvoiceService ) validates the allowed codes so future codes can be added without a migration. Partial index on non-null values: idx_invoice_items_exemption_code ON invoice_items (invoice_id, vat_exemption_code) WHERE vat_exemption_code IS NOT NULL — selective and fast for GL and UBL grouping. Column comment records B5 origin and all four allowed values. 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) HrInvoiceItem now carries vatExemptionCode: String? fetched from the DB per item. buildCanonicalInvoice groups items by (TaxCategory, rate, exemptionCode) — a mixed invoice produces multiple distinct TaxSubtotal groups , each with its own VATEX code. vatexReasonCode(code) and vatexReasonText(code) helper functions map Bilko codes to EN 16931 VATEX values and Croatian-language reason text. A TODO(B5-art79) marker is left in vatexReasonText() pending statutory text confirmation from narodne-novine.nn.hr . 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 Code EN 16931 VATEX BT-120 Text (HR) ZPDV Article EU_41 vatex-eu-ic Oslobođenje od PDV-a — isporuka unutar EU čl. 41 ZPDV EXPORT_45 vatex-eu-g Oslobođenje od PDV-a — izvoz u treće zemlje čl. 45 ZPDV EXEMPT_39 vatex-eu-o Oslobođenje od PDV-a — usluge/isporuke po čl. 39 ZPDV čl. 39 ZPDV EXEMPT_40 vatex-eu-e Oslobođ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 At invoice creation/edit, Bilko auto-suggests vatExemptionCode per line (e.g. from line context, item category, or existing customer-level setting). The accountant must confirm or override each suggested code before the invoice is submitted to SveRačun. Exemption code is not a hard-block on invoice creation . Validation (including OIB, jurisdiction, EUR currency check) runs at the SveRačun issue boundary ( SVERACUN_HR_LIVE intentional design). This satisfies Vlado Brkanć domain principle: bookkeeping operators must remain in control; the system guides but does not impose. 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 Suite Tests Pass Notes VatExemptionB5Test (new) 7 7 All B5-specific assertions HrEInvoiceCanonicalInvoiceTest (B3) 5 5 Regression TaxCorrectnessB4Test ~15 ~15 Regression PostingRuleEngineTest ~20 ~20 Regression CreditNote381ComplianceTest ~5 ~5 Regression Full suite 1564 1560 4 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.