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

## Context &amp; 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](https://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

<table id="bkmrk-bilko-codeen-16931-v"><thead><tr><th>Bilko Code</th><th>EN 16931 VATEX</th><th>BT-120 Text (HR)</th><th>ZPDV Article</th></tr></thead><tbody><tr><td>`EU_41`</td><td>`vatex-eu-ic`</td><td>Oslobođenje od PDV-a — isporuka unutar EU</td><td>čl. 41 ZPDV</td></tr><tr><td>`EXPORT_45`</td><td>`vatex-eu-g`</td><td>Oslobođenje od PDV-a — izvoz u treće zemlje</td><td>čl. 45 ZPDV</td></tr><tr><td>`EXEMPT_39`</td><td>`vatex-eu-o`</td><td>Oslobođenje od PDV-a — usluge/isporuke po čl. 39 ZPDV</td><td>čl. 39 ZPDV</td></tr><tr><td>`EXEMPT_40`</td><td>`vatex-eu-e`</td><td>Oslobođenje od PDV-a — financijske/osigurateljne usluge</td><td>čl. 40 ZPDV</td></tr></tbody></table>

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](https://narodne-novine.nn.hr). This is an open item for Lexicon/legal review before the printed-invoice feature ships.

## Test Evidence

<table id="bkmrk-suitetestspassnotesv"><thead><tr><th>Suite</th><th>Tests</th><th>Pass</th><th>Notes</th></tr></thead><tbody><tr><td>VatExemptionB5Test (new)</td><td>7</td><td>7</td><td>All B5-specific assertions</td></tr><tr><td>HrEInvoiceCanonicalInvoiceTest (B3)</td><td>5</td><td>5</td><td>Regression</td></tr><tr><td>TaxCorrectnessB4Test</td><td>~15</td><td>~15</td><td>Regression</td></tr><tr><td>PostingRuleEngineTest</td><td>~20</td><td>~20</td><td>Regression</td></tr><tr><td>CreditNote381ComplianceTest</td><td>~5</td><td>~5</td><td>Regression</td></tr><tr><td>Full suite</td><td>1564</td><td>1560</td><td>4 pre-existing INFRA-SKIP (SVERACUN\_SENDER\_VAT env var missing, not B5)</td></tr></tbody></table>

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.