← Dnevnik

Postmortem25. 10. 2026.5 min čitanja

Hrvatska fiskalizacija iz Next.js aplikacije: cron koji je tiho zakazao

ispiti.hr prijavljuje svaki račun Poreznoj upravi preko pružatelja usluge fiskalizacije. Dana 7. listopada 2026. pružatelj je bio nedostupan 75 minuta, jednom računu ponestalo je ponovnih pokušaja unutar tog prozora, a sustav je bio zadovoljan; ovo je ono što je puklo i ono što to sada nadzire.

U Hrvatskoj račun nije gotov kad kupac plati. Gotov je kad Porezna uprava odgovori JIR-om, jedinstvenim identifikatorom računa, a taj odgovor stiže preko servera kojim ja ne upravljam. ispiti.hr ima više od 260 korisnika koji plaćaju, svako plaćanje je račun, i svaki račun prolazi kroz API pružatelja usluge fiskalizacije na putu do Porezne uprave. Popodne 7. listopada 2026. taj API bio je nedostupan 75 minuta. Jedan račun potrošio je sve svoje ponovne pokušaje unutar tih 75 minuta i onda ostao ondje, nefiskaliziran, dok je svaki dio sustava javljao da je obavio svoj posao.

Što fiskalizacija traži od aplikacije

Fiskalizacija je hrvatski sustav prijave računa Poreznoj upravi u trenutku izdavanja. Aplikacija pošalje račun, sustav Porezne uprave odgovori JIR-om, i JIR ide na račun. Važeći zakon, Zakon o fiskalizaciji (NN 89/2025), jasno kaže što se događa kad veza pukne: račun svejedno izdaješ, bez JIR-a, i imaš dva radna dana, računajući od dana kad je veza prekinuta, da ga prijaviš. Ako to napraviš, račun se smatra fiskaliziranim. Prethodni zakon (NN 133/2012) govorio je dva dana, bez „radna“. Pravilnik dodaje jednu rečenicu koju bi svaki developer trebao zalijepiti iznad stola: greška nikad ne smije blokirati prodaju. Uzmeš novac, prijaviš kasnije.

Dakle, problem dizajna nije „prijavi račun“. Nego „prijavi račun, a kad ne možeš, zapamti da ga prijaviš kasnije, unutar roka“. Baš je kod tog „kasnije“ pošlo po zlu.

Što sam imao

Prvi pokušaj događa se u Stripe webhooku, u trenutku kad plaćanje sjedne. Ako pružatelj ne odgovori, ili odgovori greškom, račun ostaje Vercel cronu koji se pokreće svakih 15 minuta i pokušava ponovno, ukupno pet puta. Nakon pete greške račun pada u sporu traku koja ga ponovno pokušava jednom dnevno, a poruka na Slacku javlja da je budžet potrošen. I sama spora traka je popravak iz lipnja: revizija je otkrila da je cron prije zauvijek izbacivao iscrpljene račune iz filtra, pa bi dulji ispad zauvijek ostavio plaćene račune bez prijave.

Pored crona stoji health probe. Svakih pet minuta dohvaća host pružatelja s timeoutom od osam sekundi, nakon dva uzastopna neuspjeha označava servis kao nedostupan i javi se na Slack jednom pri svakoj promjeni stanja. Od kraja srpnja stajao je na „up“. Jedina greška pružatelja u zapisima prije listopada bio je 500 s njihove strane 18. kolovoza, koji je cron ponovno pokušao i riješio 15 minuta kasnije. Za kratko štucanje dizajn je bio ispravan.

Popodne 7. listopada

Otprilike od 13:00 do 14:15 UTC host pružatelja uopće nije odgovarao. Veze se nikad nisu otvorile, pa je svaki pokušaj pao na isti način, „fetch failed“. Dva računa uhvaćena su u ispadu. Drugi je uhvaćen pred kraj i cron od 15 minuta fiskalizirao ga je u 14:30, kako je i predviđeno. Prvi je plaćen dovoljno rano da bude pokušan pet puta prije 14:15, a pet pokušaja u razmaku od 15 minuta pokriva sat vremena: budžet ponovnih pokušaja bio je kraći od ispada. Dok se pružatelj vratio, račun je već odustao. Sad je bio u sporoj traci, s idućim pokušajem oko 14:00 sljedećeg dana.

Probe je odradio svoj posao. Nakon dvije neuspjele provjere označio je pružatelja kao nedostupnog i to javio na Slacku, a kad je host ponovno odgovorio, javio je i to.

Ništa se nije srušilo, nijedan zahtjev nije izgubljen, nijedna iznimka nije ostala neuhvaćena. Svaki račun pao je točno onako kako je kod rekao da treba. Cronova poruka o odustajanju za taj račun stajala je na Slacku od pete greške. Pročitao sam je i otvorio issue u 18:23 UTC, četiri sata nakon što je ispad završio.

Taj tihi dio je pouka. Logika ponovnih pokušaja tretirala je svaki račun kao zaseban mali problem, a taj se problem riješio sam od sebe, odustajanjem. Probe je znao da se pružatelj vratio; računima to nitko nije rekao. U sustavu nije postojalo mjesto gdje se susreću „pružatelj je opet dostupan“ i „ovi računi su odustali dok je bio nedostupan“. Dizajn je pretpostavljao štucanja. Ispad nije štucanje; to je isto štucanje za sve odjednom, dulje nego što budžet dopušta.

Što se promijenilo sljedeći dan

Popravak je izašao 8. listopada kao četiri pull requesta, a prvi klik na novoj stranici fiskalizirao je račun, dan nakon ispada i dobrano unutar dva radna dana.

Logika ponovnih pokušaja sad je jedan modul s tri načina rada. Cron ga pokreće kao i prije. Health probe ga pokreće u načinu oporavka čim vidi da se pružatelj vratio, i u tom se načinu iscrpljeni računi pokušavaju odmah umjesto da čekaju cijeli dan. Admin stranica prikazuje status pružatelja, račune koji još nemaju potvrdu zajedno s posljednjom greškom, i gumb za ručnu fiskalizaciju jednog ili svih. Crvena oznaka u bočnoj traci admina broji nefiskalizirane račune starije od pet minuta, pa mi je taj broj pred nosom svaki put kad otvorim admin, a nula je dosadna, što je i poanta.

Dvije stvari namjerno se nisu promijenile. Cron i dalje pokušava dok je pružatelj nedostupan; pauziranje je namjerno ostavljeno izvan ticketa, jer promašeni pokušaj prema nedostupnom hostu ne košta ništa, a pauzirani cron je još jedna stvar koja se možda neće uspjeti ponovno pokrenuti. I probe svaki HTTP odgovor računa kao „up“, čak i 500, pa ga greška na strani pružatelja ne aktivira; taj slučaj ostaje cronovim upozorenjima i dnevnom watchdogu.

Što bih napravio drugačije

Probe bih od početka spojio s ponovnim pokušajima. Probe postoji od lipnja, a ponovni pokušaji od prve prodaje; nedostajala je ta jedna linija između njih, i ta linija je cijeli popravak. Ponovni pokušaji nisu monitoring. Oni su mišljenje o tome koliko dugo greška traje, a 7. listopada to je mišljenje promašilo za petnaestak minuta.

Više izvora

Želite nešto slično?

Proizvodi u ledgeru izgrađeni su ovako. Isti inženjer, isti proces, za Vaš proizvod. Projekti od 10.000 €, dva slobodna termina u Q1 2027.

Pokrenite projekt