Skip to main content

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 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

  • 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

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.