DEV Community

Cover image for Kako provjeriti eRačun prije slanja: 62 pravila i jedna zamka
Boris Stiner
Boris Stiner

Posted on

Kako provjeriti eRačun prije slanja: 62 pravila i jedna zamka

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verzija koja je u primjeni objavljena je 13. ožujka 2026., a primjenjuje se od

  1. 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:

  1. 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.
  2. Vanjski servis. Ovisnost o mreži usred izdavanja računa.
  3. 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>
Enter fullscreen mode Exit fullscreen mode

U PHP-u:

if (preg_match('/\s/u', $brojRacuna) === 1) {
    // prekršeno
}
Enter fullscreen mode Exit fullscreen mode

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 &lt;= 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>
Enter fullscreen mode Exit fullscreen mode

Č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
}
Enter fullscreen mode Exit fullscreen mode

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"/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 xmlresolver na classpathu, 10.x je jedan samodostatan JAR.
  • SVRL ispisuje atribute u više redaka, pa ga parsirajte XML parserom. grep radi 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 ...", ...]
Enter fullscreen mode Exit fullscreen mode

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' => []]
Enter fullscreen mode Exit fullscreen mode

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)