Od 1. siječnja 2026. razmjena eRačuna u B2B segmentu je obavezna. Ako gradite
integraciju, prije ili kasnije dobit ćete od posrednika odbijenicu s porukom tipa:
[HR-BR-9] - Račun mora sadržavati ispravan OIB operatera
Ovaj tekst objašnjava odakle taj kod dolazi, gdje su sva pravila zapisana, zašto ih
ne možete jednostavno pokrenuti iz PHP-a, i koja jedna zamka u njima gotovo sigurno
čeka svakoga tko ih implementira čitajući ih.
Primjeri su u PHP-u, ali sve osim zadnjeg poglavlja vrijedi bez obzira na jezik.
Gdje su pravila zapisana
Porezna uprava objavljuje Schematron — datoteku s pravilima poslovne logike koja
se primjenjuje nakon provjere prema EN 16931. Nalazi se u sekciji
eRačun / dokumentacija,
pod nazivom Validator.
Unutra su dvije datoteke:
HRUBLSchematron/
HR-CIUS-EXT-EN16931-UBL.sch
codelist/HR-CIUS-EXT-EN16931-UBL-codes.sch
Verzija koja je u primjeni objavljena je 13. ožujka 2026., a primjenjuje se od
- ožujka 2026. Vrijedi to zapamtiti: pravila su se mijenjala nakon što je obveza već stupila na snagu, i mijenjat će se opet.
Ako izbrojite tvrdnje (assert) unutra, dobit ćete 73 tvrdnje pod 62 različite
oznake — neka se pravila provjeravaju na više mjesta u dokumentu. Sve su označene
flag="fatal". Nema upozorenja; svako prekršeno pravilo znači odbijen račun.
Numeracija nije neprekinuta. Nedostaju 3, 8, 12, 14, 15, 22, 23, 24, 31, 35, 38 i 39
— povučena su ili rezervirana. Nemojte pisati petlju od 1 do 56.
Zašto ih ne možete pokrenuti iz PHP-a
Schematron se prevodi u XSLT i pokreće nad dokumentom. Hrvatski Schematron ima
queryBinding="xslt2", dakle traži XSLT 2.0.
PHP-ov XSLTProcessor koristi libxslt, koji podržava samo XSLT 1.0. Ne postoji
prekidač koji to mijenja.
Ostaju vam tri opcije:
- SaxonC kao PECL ekstenzija. Radi, ali znači da svaki server na koji deployate mora imati prevedenu ekstenziju. Za biblioteku koju netko instalira Composerom to je neprihvatljivo.
- Vanjski servis. Ovisnost o mreži usred izdavanja računa.
- Reimplementirati pravila u PHP-u. Pravila su, kad ih rastavite, obična aritmetika i provjera pripadnosti skupu.
Treća opcija je jedina razumna za produkciju — ali ima očitu zamku: kako znate da ste
ih dobro pročitali?
Kako pravila izgledaju
Većina ih je bezazlena. HR-BR-1:
<assert test="not(matches(/*/cbc:ID, '\s'))" flag="fatal" id="HR-BR-1">
[HR-BR-1] - Broj računa ne smije sadržavati bjeline
</assert>
U PHP-u:
if (preg_match('/\s/u', $brojRacuna) === 1) {
// prekršeno
}
Od 62 pravila, njih 29 je otprilike ove težine: regularni izraz, duljina niza, raspon
datuma, provjera postojanja. Još 16 su četiri varijante istog oblika za četiri
kategorije PDV-a. Ozbiljnih ih je sedam — usklađivanje iznosa unutar hrvatskog
proširenja.
Zamka: HR-BR-4
A onda dođete do ovoga:
<assert test="($payableAmount > 0)
and (exists(cbc:DueDate) or exists(cac:PaymentMeans/cbc:PaymentDueDate))
or (($payableAmount <= 0))"
flag="fatal" id="HR-BR-4">
[HR-BR-4] - U slučaju pozitivnog iznosa koji dospijeva na plaćanje (BT-115),
datum dospijeća plaćanja (BT-9) mora biti naveden
</assert>
Čita se sasvim jasno: ako je iznos za plaćanje veći od nule, mora postojati datum
dospijeća. Napišete:
$payable = $racun->totals->payableAmount->toFloat();
if ($payable > 0 && $datumDospijeca === null) {
// prekršeno
}
I to je netočno.
Trideset redaka iznad te tvrdnje stoji definicija varijable:
<let name="payableAmount" value="
if (/ubl-invoice:Invoice) then
cac:LegalMonetaryTotal/cbc:PayableAmount
else
cac:LegalMonetaryTotal/cbc:PayableAmount * -1"/>
Za odobrenje (CreditNote) iznos se množi s -1.
Razlog je smislen: odobrenje u XML-u nosi pozitivan iznos, ali novac ide u suprotnom
smjeru. Nakon promjene predznaka iznos je negativan, uvjet > 0 nije zadovoljen, i
pravilo se na odobrenja nikad ne primjenjuje.
Ako to promašite, vaš validator prijavljuje grešku na svakom odobrenju bez datuma
dospijeća — a odobrenja rijetko imaju datum dospijeća, jer CreditNoteType u UBL-u
uopće nema element cbc:DueDate. Dobili ste lažno pozitivan rezultat na vrlo čestom
dokumentu.
Pouka je jednostavna i vrijedi za cijelu datoteku: čitajte let varijable, ne samo
tvrdnje. Tvrdnja je često samo vrh; definicija je iznad nje.
Ostale hrvatske specifičnosti
Ove nemaju europski ekvivalent, pa ih integratori redovito propuste:
Operater je obavezan. HR-BT-4 i HR-BT-5, u
cac:AccountingSupplierParty/cac:SellerContact — ime i OIB osobe koja je izdala
račun. Pravila HR-BR-37 i HR-BR-9. Ako vaša aplikacija nema pojam „tko je izdao
ovaj račun", morat ćete ga uvesti.
<cac:SellerContact>
<cbc:ID>12345678901</cbc:ID>
<cbc:Name>Operater1</cbc:Name>
</cac:SellerContact>
Vrijeme izdavanja je obavezno, u formatu hh:mm:ss (HR-BR-2). EN 16931 uopće
nema vrijeme izdavanja, samo datum.
Prazni XML elementi su zabranjeni (HR-BR-33), osim unutar bloka za potpis:
<!-- ovo ruši inače savršen račun -->
<cbc:Note></cbc:Note>
Ovo je vjerojatno najčešći uzrok odbijanja koji nema veze sa sadržajem računa. Većina
generatora XML-a rado ispiše prazan element za null vrijednost. Rješenje je da
funkcija koja upisuje element jednostavno ne upiše ništa kad vrijednost ne postoji.
KPD oznaka na svakoj stavci, najmanje šest znamenki, s atributom listID="CG"
(HR-BR-25). Uz jednu iznimku koja se lako previdi: obveza otpada za vrste dokumenata
386 81 83 261 262 296 308 381 396 420 458 532. Zato službeni primjeri odobrenja i
računa za predujam uopće nemaju klasifikaciju.
Još nešto o KPD-u: klasifikacija KPD 2025. koju objavljuje DZS ima 5.828 unosa na
tri razine, ali na stavci računa vrijedi samo 3.359 šesteroznamenkastih oznaka.
Popis dopuštenih je ugrađen u HR-BR-CL-2 u datoteci s kodovima. Provjeravate li
prema DZS katalogu, propustit ćete oznake koje će Porezna odbiti.
Datum izdavanja mora biti od 1. 1. 2026. nadalje (HR-BR-40). Nema
retroaktivnog izdavanja.
Kako provjeriti da ste dobro implementirali
Ovo je dio koji se preskače, a najviše vrijedi.
Ne morate Saxon vući u produkciju da biste ga koristili u provjeri. Pokrenete ga
jednom, u Dockeru, i usporedite s vlastitom implementacijom:
# 1. Schematron -> XSLT, pomoću SchXslt
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:HR-CIUS-EXT-EN16931-UBL.sch \
-xsl:schxslt/xslt/2.0/pipeline-for-svrl.xsl \
-o:hr-cius.xsl
# 2. pokrenite nad dokumentom -> SVRL izvještaj
docker run --rm -v "$PWD:/w" -w /w eclipse-temurin:21-jre \
java -cp saxon.jar net.sf.saxon.Transform \
-s:racun.xml -xsl:hr-cius.xsl
Rezultat je SVRL — XML u kojem svaki <svrl:failed-assert> nosi id prekršenog
pravila. To izvučete i usporedite s onim što prijavljuje vaš kod.
Dvije napomene iz iskustva:
- Koristite Saxon-HE 10.x. Verzija 12 traži
xmlresolverna classpathu, 10.x je jedan samodostatan JAR. - SVRL ispisuje atribute u više redaka, pa ga parsirajte XML parserom.
grepradi po redcima i vratit će vam prazan rezultat na potpuno ispravnom izvještaju.
Kad sam ovo napravio za svoju implementaciju, prvi pokretanje je našlo upravo onaj
HR-BR-4 iz gornjeg poglavlja. Nakon ispravka: 20 dokumenata, 0 razlika.
Iznenađenje: službeni primjeri ne prolaze
Porezna objavljuje 20 primjera eRačuna (Primjeri eRačuna, 12. 12. 2025.). Prirodno
je od njih napraviti testove i napisati „svi primjeri moraju proći".
Nemojte. Nijedan od njih ne prolazi trenutna pravila:
| Pravilo | Primjera | Zašto |
|---|---|---|
HR-BR-40 |
20/20 | Svi primjeri datirani su u 2025., a pravilo traži 2026. nadalje |
HR-BR-9 |
20/20 | OIB prodavatelja 12345678901 ne prolazi kontrolnu znamenku |
HR-BR-53 |
19/20 | Isti OIB kao porezni identifikator |
HR-BR-25 |
1/20 | Primjer za leasing je vrsta 394, koja nije izuzeta od KPD-a |
Objašnjenje je posve prozaično: primjeri su objavljeni u prosincu 2025., Schematron je
revidiran u ožujku 2026., i donja granica datuma je uvedena između. Primjeri nisu
osvježeni.
Praktična posljedica: primjeri su odlični kao ulaz za testiranje čitanja i
zapisivanja, ali očekivani rezultat validacije morate izmjeriti, ne pretpostaviti.
Paket
Sve gore opisano implementirano je u
stboris/laravel-eracun — MIT, PHP 8.3+,
bez ovisnosti o frameworku:
use Stboris\Eracun\Validation\Validator;
$rezultat = Validator::default()->validateFile('racun.xml');
$rezultat->brokenCodes(); // ["HR-BR-9", "HR-BR-40"]
$rezultat->messages(); // ["[HR-BR-9] HR-BT-5: Račun mora sadržavati ...", ...]
Poruke nose službene oznake pravila, pa se poklapaju s onim što vam javi posrednik.
Sva 62 pravila su implementirana i provjerena gore opisanim postupkom. Što paket
provjerava, a što ne, možete pitati njega samog umjesto da vjerujete dokumentaciji:
Validator::default()->coverage(); // ['ratio' => '62/62', 'missing' => []]
Da bude jasno: to je validator poslovnih pravila, a ne validator sukladnosti.
Prolazak ne jamči da će dokument biti prihvaćen, i paket nikoga ne čini usklađenim sa
Zakonom o fiskalizaciji. Fiskalizacija, slanje preko informacijskog posrednika i
potpisivanje namjerno su izvan opsega — za njih treba certifikat ili ugovor.
Za polja i oznake postoji
referenca sa
svim BT oznakama, kardinalnostima i pripadajućim PHP svojstvima.
Ako naiđete na dokument koji paket prihvaća a posrednik odbije — to je greška u
paketu i zanima me. Otvorite issue s priloženim XML-om.
Top comments (0)