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
TaxSubtotalgroup per exemption category. - Croatian VAT law (čl.79 ZPDV, NN) requires the specific statutory article reference in the UBL
TaxExemptionReasonCode(BT-121) andTaxExemptionReason(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 emittedEU_41for 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)
HrInvoiceItemnow carriesvatExemptionCode: String?fetched from the DB per item.buildCanonicalInvoicegroups items by(TaxCategory, rate, exemptionCode)— a mixed invoice produces multiple distinctTaxSubtotalgroups, each with its own VATEX code.vatexReasonCode(code)andvatexReasonText(code)helper functions map Bilko codes to EN 16931 VATEX values and Croatian-language reason text. ATODO(B5-art79)marker is left invatexReasonText()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 nullMixed-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
vatExemptionCodeper 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_LIVEintentional 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.
No comments to display
No comments to display