diff --git a/Fisk/data/counters/pp.txt b/Fisk/data/counters/pp.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/Fisk/data/counters/pp.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/Fisk/data/counters/total.txt b/Fisk/data/counters/total.txt
new file mode 100644
index 0000000..d8263ee
--- /dev/null
+++ b/Fisk/data/counters/total.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/internal/config/moduli.go b/internal/config/moduli.go
index db15eca..1cad799 100644
--- a/internal/config/moduli.go
+++ b/internal/config/moduli.go
@@ -16,10 +16,10 @@ const (
// Nazivi modula koje program pali/gasi prema profilu firme.
const (
- ModulPdv = "pdv"
- ModulFiskalizacija = "fiskalizacija"
- ModulKpo = "kpo"
- ModulDvojno = "dvojno"
+ ModulPdv = "pdv" // KIR/KPR evidencija, PDV obračun
+ ModulFiskalizacija = "fiskalizacija" // Teron L-PFR, fiskalni računi
+ ModulKpo = "kpo" // Knjiga o ostvarenom prometu (paušalci)
+ ModulDvojno = "dvojno" // Dvojno knjigovodstvo (DOO, preduzetnici koji ga vode)
)
// ModulUkljucen vraća da li je dati zakonski modul aktivan za firmu, na osnovu
@@ -36,20 +36,30 @@ func ModulUkljucen(podesavanja map[string]string, modul string) bool {
return false
}
+ pravniOblik := podesavanja[KljucPravniOblik]
+
switch modul {
case ModulFiskalizacija:
- // fiskalizacija je nezavisna od pravnog oblika — zaseban prekidač
- // „izdaje li račune građanima" (Project.md §3, napomena *).
+ // Fiskalizacija je obavezna za SVE koji izdaju račune građanima (maloprodaja),
+ // bez obzira na pravni oblik. Nezavisan prekidač.
return podesavanja[KljucFiskalizacija] == "da"
+
case ModulPdv:
- // PDV evidencija se vodi kad je firma u sistemu PDV-a.
+ // PDV evidencija se vodi kad je firma u sistemu PDV-a (obavezno preko 8M,
+ // ili dobrovoljno). Dostupno svim pravnim oblicima.
return podesavanja[KljucPdvObveznik] == "da"
+
case ModulKpo:
- // KPO (knjiga o ostvarenom prometu) je samo za paušalce.
- return podesavanja[KljucPravniOblik] == "pausalac"
+ // KPO (knjiga o ostvarenom prometu) vode paušalci i preduzetnici
+ // koji vode proste poslovne knjige (ne dvojno).
+ return pravniOblik == "pausalac" || pravniOblik == "preduzetnik_knjige"
+
case ModulDvojno:
- // dvojno knjigovodstvo je za DOO (za preduzetnike je buduća podela, Project.md §8).
- return podesavanja[KljucPravniOblik] == "doo"
+ // Dvojno knjigovodstvo je obavezno za DOO. Preduzetnici koji vode
+ // knjige mogu birati prosto ili dvojno — dvojno je opciono
+ // (budući toggle: firma_knjigovodstvo = "dvojno").
+ return pravniOblik == "doo"
+
default:
return false
}
diff --git a/internal/config/moduli_test.go b/internal/config/moduli_test.go
index 4f89f6c..5535a71 100644
--- a/internal/config/moduli_test.go
+++ b/internal/config/moduli_test.go
@@ -44,12 +44,12 @@ func TestModulUkljucen(t *testing.T) {
{"pun + obveznik da", pun, ModulPdv, true},
{"pun + obveznik ne (čak i doo)", izmeni(pun, KljucPdvObveznik, "ne"), ModulPdv, false},
- // kpo — samo paušalac
+ // kpo — paušalac i preduzetnik knjigaš (proste knjige)
{"pun + paušalac → kpo", izmeni(pun, KljucPravniOblik, "pausalac"), ModulKpo, true},
{"pun + doo → nema kpo", pun, ModulKpo, false},
- {"pun + preduzetnik → nema kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, false},
+ {"pun + preduzetnik → kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, true},
- // dvojno — samo doo
+ // dvojno — doo uvek, preduzetnik opciono (budući toggle)
{"pun + doo → dvojno", pun, ModulDvojno, true},
{"pun + paušalac → nema dvojno", izmeni(pun, KljucPravniOblik, "pausalac"), ModulDvojno, false},
{"pun + preduzetnik → nema dvojno (buduća podela)", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulDvojno, false},
diff --git a/internal/db/repository.go b/internal/db/repository.go
index 063c76e..c55d5fb 100644
--- a/internal/db/repository.go
+++ b/internal/db/repository.go
@@ -367,3 +367,10 @@ type PodsetnikRepository interface {
Obrisi(ctx context.Context, id int64) error
BrojAktivnih(ctx context.Context, filter PodsetnikFilter) (int, error)
}
+
+// FiskalRepository definiše operacije nad fiskalnim računima (Teron L-PFR).
+// Svaki prodajni nalog ima najviše jedan fiskalni račun (UNIQUE prodaja_id).
+type FiskalRepository interface {
+ Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
+ DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
+}
diff --git a/internal/db/sqlite/fiskal.go b/internal/db/sqlite/fiskal.go
new file mode 100644
index 0000000..258dc38
--- /dev/null
+++ b/internal/db/sqlite/fiskal.go
@@ -0,0 +1,71 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "ntech/internal/model"
+)
+
+// FiskalRepo je SQLite implementacija FiskalRepository interfejsa
+type FiskalRepo struct {
+ db *sql.DB
+}
+
+// NoviFiskalRepo kreira novi FiskalRepo
+func NoviFiskalRepo(db *sql.DB) *FiskalRepo {
+ return &FiskalRepo{db: db}
+}
+
+// Kreiraj dodaje novi fiskalni račun u bazu. Vraća ID kreiranog reda.
+// ProdajaID je UNIQUE — drugi poziv sa istim ID-jem pada na constraint.
+func (r *FiskalRepo) Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error) {
+ rez, err := r.db.ExecContext(ctx, `
+ INSERT INTO fiskalni_racuni (
+ prodaja_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
+ brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
+ poreske_stavke, ukupno_za_naplatu, ukupan_porez,
+ sirovi_odgovor, potpisao, zatrazio, poruka
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, fr.ProdajaID, fr.TipRacuna, fr.TipTransakcije, fr.PfrBroj, fr.PfrVreme,
+ fr.Brojac, fr.EkstenzijaBrojaca, fr.UrlVerifikacija, fr.QRKod,
+ fr.PoreskeStavke, fr.UkupnoZaNaplatu, fr.UkupanPorez,
+ fr.SiroviOdgovor, fr.Potpisao, fr.Zatrazio, fr.Poruka,
+ )
+ if err != nil {
+ return 0, fmt.Errorf("ntech: FiskalRepo.Kreiraj: %w", err)
+ }
+ id, err := rez.LastInsertId()
+ if err != nil {
+ return 0, fmt.Errorf("ntech: FiskalRepo.Kreiraj.LastInsertId: %w", err)
+ }
+ return id, nil
+}
+
+// DohvatiPoProdaji vraća fiskalni račun za dati prodajni nalog.
+// Vraća nil bez greške ako račun ne postoji (nije fiskalizovano).
+func (r *FiskalRepo) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error) {
+ fr := &model.FiskalniRacun{}
+ var storniran int
+ err := r.db.QueryRowContext(ctx, `
+ SELECT id, prodaja_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
+ brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
+ poreske_stavke, ukupno_za_naplatu, ukupan_porez,
+ sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
+ FROM fiskalni_racuni WHERE prodaja_id = ?
+ `, prodajaID).Scan(
+ &fr.ID, &fr.ProdajaID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
+ &fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
+ &fr.PoreskeStavke, &fr.UkupnoZaNaplatu, &fr.UkupanPorez,
+ &fr.SiroviOdgovor, &fr.Potpisao, &fr.Zatrazio, &fr.Poruka, &storniran, &fr.VremeKreiranja,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil // nije fiskalizovano — nije greška
+ }
+ if err != nil {
+ return nil, fmt.Errorf("ntech: FiskalRepo.DohvatiPoProdaji: %w", err)
+ }
+ fr.Storniran = storniran != 0
+ return fr, nil
+}
diff --git a/internal/fiskal/fiskal.go b/internal/fiskal/fiskal.go
new file mode 100644
index 0000000..f52ba9e
--- /dev/null
+++ b/internal/fiskal/fiskal.go
@@ -0,0 +1,155 @@
+package fiskal
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Klijent je HTTP klijent za Teron L-PFR mock server (ili pravi Teron uređaj).
+// Komunicira JSON-om preko HTTP POST/GET na baseURL/api/....
+type Klijent struct {
+ BaseURL string
+ APIKey string
+ HTTPKlijent *http.Client
+}
+
+// NoviKlijent kreira novi fiskalni klijent. baseURL je adresa PFR servera
+// (npr. "http://127.0.0.1:4566"), apiKey je Bearer token.
+func NoviKlijent(baseURL, apiKey string) *Klijent {
+ return &Klijent{
+ BaseURL: strings.TrimRight(baseURL, "/"),
+ APIKey: apiKey,
+ HTTPKlijent: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat.
+type InvoiceRequest struct {
+ InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
+}
+
+// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
+type InvoiceRequestBody struct {
+ InvoiceType string `json:"invoiceType"`
+ TransactionType string `json:"transactionType"`
+ Payment []PaymentItem `json:"payment"`
+ Items []InvoiceItem `json:"items"`
+ Cashier string `json:"cashier"`
+ BuyerID string `json:"buyerId,omitempty"`
+ ReferentDocumentNumber string `json:"referentDocumentNumber,omitempty"`
+}
+
+// PaymentItem opisuje jedno plaćanje (može više — keš + kartica).
+type PaymentItem struct {
+ Amount float64 `json:"amount"`
+ PaymentType string `json:"paymentType"`
+}
+
+// InvoiceItem je jedna stavka fiskalnog računa.
+type InvoiceItem struct {
+ Name string `json:"name"`
+ Labels []string `json:"labels"`
+ TotalAmount float64 `json:"totalAmount"`
+ UnitPrice float64 `json:"unitPrice"`
+ Quantity float64 `json:"quantity"`
+}
+
+// InvoiceResponse je odgovor PFR servera na zahtev za fiskalizaciju.
+// Polja prate stvarni JSON koji Fisk server vraća (vidi NtechFisk.md §3.2).
+type InvoiceResponse struct {
+ RequestedBy string `json:"requestedBy"`
+ SignedBy string `json:"signedBy"`
+ SdcDateTime string `json:"sdcDateTime"`
+ InvoiceCounter string `json:"invoiceCounter"`
+ InvoiceCounterExtension string `json:"invoiceCounterExtension"`
+ InvoiceNumber string `json:"invoiceNumber"`
+ VerificationURL string `json:"verificationUrl"`
+ VerificationQRCode string `json:"verificationQRCode"`
+ Journal string `json:"journal"`
+ TaxItems []TaxItem `json:"taxItems"`
+ TotalAmount float64 `json:"totalAmount"`
+ TotalTax float64 `json:"totalTax"`
+ Messages string `json:"messages"`
+}
+
+// TaxItem je stavka poreskog obračuna u fiskalnom odgovoru.
+type TaxItem struct {
+ Label string `json:"label"`
+ CategoryName string `json:"categoryName"`
+ CategoryType int `json:"categoryType"`
+ Rate float64 `json:"rate"`
+ Amount float64 `json:"amount"`
+}
+
+// Status vraća trenutni status PFR servera (GET /api/status).
+func (k *Klijent) Status(ctx context.Context) (map[string]any, error) {
+ url := k.BaseURL + "/api/status"
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.Status: %w", err)
+ }
+ k.dodajAuth(req)
+
+ resp, err := k.HTTPKlijent.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.Status: server nedostupan: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ telo, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("ntech: fiskal.Status: HTTP %d: %s", resp.StatusCode, string(telo))
+ }
+
+ var result map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.Status: JSON parse: %w", err)
+ }
+ return result, nil
+}
+
+// IzdajRacun šalje zahtev za fiskalizaciju PFR serveru (POST /api/invoices).
+func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*InvoiceResponse, error) {
+ telo, err := json.Marshal(zahtev)
+ if err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: marshal: %w", err)
+ }
+
+ url := k.BaseURL + "/api/invoices"
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(telo))
+ if err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ k.dodajAuth(req)
+
+ resp, err := k.HTTPKlijent.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: server nedostupan: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ teloOdg, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: HTTP %d: %s", resp.StatusCode, string(teloOdg))
+ }
+
+ var odgovor InvoiceResponse
+ if err := json.NewDecoder(resp.Body).Decode(&odgovor); err != nil {
+ return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: JSON parse: %w", err)
+ }
+ return &odgovor, nil
+}
+
+// dodajAuth postavlja Authorization header ako je API ključ podešen.
+func (k *Klijent) dodajAuth(req *http.Request) {
+ if k.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+k.APIKey)
+ }
+}
diff --git a/internal/fiskal/mapiranje.go b/internal/fiskal/mapiranje.go
new file mode 100644
index 0000000..7f8cf0c
--- /dev/null
+++ b/internal/fiskal/mapiranje.go
@@ -0,0 +1,117 @@
+package fiskal
+
+import (
+ "math"
+
+ "ntech/internal/model"
+)
+
+// OznakaPDV mapira NTech PDV stopu na ćiriličnu oznaku koju Fisk/Teron očekuje.
+// ⚠️ 0% → "А" (neobveznici PDV-a), NE "Е" (Е = 10% posebna stopa).
+func OznakaPDV(stopa float64) string {
+ switch {
+ case stopa >= 19.9 && stopa <= 20.1:
+ return "Ж" // opšta stopa 20%
+ case stopa >= 9.9 && stopa <= 10.1:
+ return "Ђ" // snižena stopa 10%
+ case stopa >= -0.01 && stopa <= 0.01:
+ return "А" // oslobođen / neobveznik PDV-a
+ default:
+ return "А" // fallback — ne rizikujemo pogrešnu oznaku
+ }
+}
+
+// BrutoCena konvertuje neto cenu u bruto (sa PDV-om) za zadatu stopu.
+// Teron zahteva bruto iznose u unitPrice i totalAmount.
+// Formula: bruto = neto * (1 + stopa/100), zaokruženo na 2 decimale.
+func BrutoCena(neto, stopa float64) float64 {
+ return math.Round(neto*(1+stopa/100)*100) / 100
+}
+
+// TipPlacanja mapira NTech način plaćanja na Teron enumeraciju.
+func TipPlacanja(nacin string) string {
+ switch nacin {
+ case "Gotovina":
+ return "Cash"
+ case "Kartica":
+ return "Card"
+ case "Virman", "Račun":
+ return "WireTransfer"
+ case "Ček":
+ return "Check"
+ case "Vaučer":
+ return "Voucher"
+ default:
+ return "Other"
+ }
+}
+
+// NapraviZahtev konvertuje prodajni nalog i stavke u Teron InvoiceRequest.
+// VAŽNO: sve cene se konvertuju iz neto u bruto pre slanja (BrutoCena).
+// pib/jmbg se koriste za buyerId identifikaciju kupca.
+func NapraviZahtev(
+ nalog model.ProdajniNalog,
+ stavke []model.StavkaProdajeSaArtiklom,
+ pib, jmbg, kasir string,
+) InvoiceRequest {
+ items := make([]InvoiceItem, 0, len(stavke))
+ ukupanIznos := 0.0
+
+ for _, s := range stavke {
+ stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena
+ if s.PdvStopa > 0 {
+ stopa = s.PdvStopa
+ }
+ // CenaPoKomadu je neto cena — konvertujemo u bruto
+ netoCena := s.CenaBezPdv
+ if netoCena == 0 {
+ netoCena = s.CenaPoKomadu // fallback: ako CenaBezPdv nije popunjena
+ }
+ brutoCena := BrutoCena(netoCena, stopa)
+ brutoTotal := math.Round(brutoCena*float64(s.Kolicina)*100) / 100
+
+ items = append(items, InvoiceItem{
+ Name: s.ArtikalNaziv,
+ Labels: []string{OznakaPDV(stopa)},
+ TotalAmount: brutoTotal,
+ UnitPrice: brutoCena,
+ Quantity: float64(s.Kolicina),
+ })
+ ukupanIznos += brutoTotal
+ }
+
+ // Ukupno za plaćanje — iz naloga (bruto)
+ ukupnoPlacanje := nalog.Ukupno
+ if ukupnoPlacanje == 0 {
+ ukupnoPlacanje = ukupanIznos
+ }
+
+ nacinPlacanja := nalog.NacinPlacanja
+ if nacinPlacanja == "" {
+ nacinPlacanja = "Gotovina"
+ }
+
+ zahtev := InvoiceRequest{
+ InvoiceRequest: InvoiceRequestBody{
+ InvoiceType: "Normal",
+ TransactionType: "Sale",
+ Payment: []PaymentItem{
+ {
+ Amount: ukupnoPlacanje,
+ PaymentType: TipPlacanja(nacinPlacanja),
+ },
+ },
+ Items: items,
+ Cashier: kasir,
+ },
+ }
+
+ // buyerId: "10:PIB" za pravno lice, "01:JMBG" za fizičko
+ if pib != "" {
+ zahtev.InvoiceRequest.BuyerID = "10:" + pib
+ } else if jmbg != "" {
+ zahtev.InvoiceRequest.BuyerID = "01:" + jmbg
+ }
+
+ return zahtev
+}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 78acbdc..f2f7ed2 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -11,6 +11,7 @@ import (
"ntech/internal/config"
"ntech/internal/db"
"ntech/internal/db/sqlite"
+ "ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
)
@@ -44,6 +45,7 @@ type Handler struct {
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
+ FiskalRepo db.FiskalRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
@@ -112,6 +114,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
+ FiskalRepo: sqlite.NoviFiskalRepo(baza),
}
}
@@ -145,6 +148,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
+ h.FiskalRepo = sqlite.NoviFiskalRepo(novaDB)
}
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
@@ -232,3 +236,18 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
return ps
}
+
+// fiskalKlijent vraća inicijalizovan fiskalni HTTP klijent na osnovu trenutnih
+// podešavanja. Vraća nil ako pfr_url nije podešen. URL se čita dinamički pri
+// svakom pozivu — nema keširanja, pa promena podešavanja stupa na snagu odmah.
+func (h *Handler) fiskalKlijent() *fiskal.Klijent {
+ url, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_url")
+ key, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_api_key")
+ if url == "" {
+ return nil
+ }
+ if key == "" {
+ key = "test"
+ }
+ return fiskal.NoviKlijent(url, key)
+}
diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go
index a63afe4..7a827dd 100644
--- a/internal/handler/podesavanja.go
+++ b/internal/handler/podesavanja.go
@@ -7,6 +7,7 @@ import (
"html"
"io"
"log/slog"
+ "net"
"net/http"
"net/url"
"os"
@@ -924,12 +925,12 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
pfrURL = vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566")
}
- // SSRF zaštita: URL validiramo, izvlačimo SAMO port, a host hardkodiramo.
- // Port konvertujemo u integer da presečemo CodeQL taint lanac — broj ne može
- // da sadrži URL manipulaciju, pa rekonstruisani URL nije korisnički unos.
+ // SSRF zaštita: dozvoljeni su samo localhost i privatne mreže (192.168.x.x,
+ // 10.x.x.x, 172.16-31.x.x, 127.x.x.x). Port izvlačimo kao integer da presečemo
+ // CodeQL taint lanac — broj ne može da sadrži URL manipulaciju.
parsedURL, err := url.Parse(pfrURL)
- if err != nil || (parsedURL.Hostname() != "127.0.0.1" && parsedURL.Hostname() != "localhost") {
- http.Error(w, "Nevažeći PFR URL — dozvoljen samo localhost", http.StatusBadRequest)
+ if err != nil || !jePrivatnaAdresa(parsedURL.Hostname()) {
+ http.Error(w, "Nevažeći PFR URL — dozvoljeni su samo lokalni/privatni hostovi (127.x, 192.168.x, 10.x, 172.16-31.x)", http.StatusBadRequest)
return
}
port := 4566
@@ -938,7 +939,7 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
port = n
}
}
- statusURL := fmt.Sprintf("http://127.0.0.1:%d/api/status", port)
+ statusURL := fmt.Sprintf("http://%s:%d/api/status", parsedURL.Hostname(), port)
klijent := &http.Client{Timeout: 5 * time.Second}
resp, err := klijent.Get(statusURL)
@@ -967,6 +968,26 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
`, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime))
}
+// jePrivatnaAdresa proverava da li je hostname u opsegu privatnih/lokalnih mreža.
+// Dozvoljeni: 127.x.x.x, localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x.
+func jePrivatnaAdresa(hostname string) bool {
+ if hostname == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(hostname)
+ if ip == nil {
+ return false
+ }
+ ip4 := ip.To4()
+ if ip4 == nil {
+ return false
+ }
+ return ip4[0] == 127 || // loopback
+ ip4[0] == 10 || // privatna klasa A
+ (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // privatna klasa B
+ (ip4[0] == 192 && ip4[1] == 168) // privatna klasa C
+}
+
// PodesavanjaSistem renderuje stranicu sa sistemskim podešavanjima (backup)
func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go
index 9607ec8..9fbe0fe 100644
--- a/internal/handler/prodaja.go
+++ b/internal/handler/prodaja.go
@@ -10,8 +10,10 @@ import (
"strings"
"time"
+ "ntech/internal/config"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
+ "ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
@@ -39,10 +41,11 @@ type PodaciFormeProdaje struct {
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
type PodaciDetaljiProdaje struct {
model.PodaciStranice
- Nalog model.ProdajniNalog
- Stavke []model.StavkaProdajeSaArtiklom
- KlijentNaziv string
- Sacuvano bool
+ Nalog model.ProdajniNalog
+ Stavke []model.StavkaProdajeSaArtiklom
+ KlijentNaziv string
+ FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
+ Sacuvano bool
}
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
@@ -213,6 +216,63 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
}
}
+ // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije)
+ if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
+ klijent := h.fiskalKlijent()
+ if klijent != nil {
+ // učitaj nalog sa ID-jem (potreban za buyerId)
+ spremljen, e := h.ProdajaRepo.DohvatiID(r.Context(), id)
+ if e == nil {
+ spremljeneStavke, e2 := h.ProdajaRepo.DohvatiStavke(r.Context(), id)
+ if e2 == nil {
+ kasir, _ := sqlite.DohvatiPodesavanje(r.Context(), h.DB, "pfr_kasir")
+ if kasir == "" {
+ kasir = "NTech"
+ }
+
+ var pib, jmbg string
+ if spremljen.KlijentID != nil {
+ if kk, e3 := h.KlijentiRepo.DohvatiID(r.Context(), *spremljen.KlijentID); e3 == nil {
+ pib, jmbg = kk.PIB, kk.JMBG
+ }
+ }
+
+ zahtev := fiskal.NapraviZahtev(*spremljen, spremljeneStavke, pib, jmbg, kasir)
+
+ odgovor, errFisk := klijent.IzdajRacun(r.Context(), zahtev)
+ if errFisk != nil {
+ slog.Error("fiskalizacija nije uspela", "prodaja_id", id, "error", errFisk)
+ } else {
+ poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
+ siroviJSON, _ := json.Marshal(odgovor)
+
+ fr := &model.FiskalniRacun{
+ ProdajaID: id,
+ TipRacuna: "Normal",
+ TipTransakcije: "Sale",
+ PfrBroj: odgovor.InvoiceNumber,
+ PfrVreme: odgovor.SdcDateTime,
+ Brojac: odgovor.InvoiceCounter,
+ EkstenzijaBrojaca: odgovor.InvoiceCounterExtension,
+ UrlVerifikacija: odgovor.VerificationURL,
+ QRKod: odgovor.VerificationQRCode,
+ PoreskeStavke: string(poreskeJSON),
+ UkupnoZaNaplatu: odgovor.TotalAmount,
+ UkupanPorez: odgovor.TotalTax,
+ SiroviOdgovor: string(siroviJSON),
+ Potpisao: odgovor.SignedBy,
+ Zatrazio: odgovor.RequestedBy,
+ Poruka: odgovor.Messages,
+ }
+ if _, errFisk := h.FiskalRepo.Kreiraj(r.Context(), fr); errFisk != nil {
+ slog.Error("greška pri čuvanju fiskalnog računa", "prodaja_id", id, "error", errFisk)
+ }
+ }
+ }
+ }
+ }
+ }
+
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
@@ -254,6 +314,9 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
}
}
+ // učitaj fiskalni račun ako postoji
+ fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id)
+
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "prodaja"
ps.NaslovStranice = "Detalji prodaje"
@@ -262,6 +325,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
Nalog: *nalog,
Stavke: stavke,
KlijentNaziv: klijentNaziv,
+ FiskalniRacun: fr,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
diff --git a/internal/handler/servis.go b/internal/handler/servis.go
index 3581e7f..8994cc7 100644
--- a/internal/handler/servis.go
+++ b/internal/handler/servis.go
@@ -1426,7 +1426,19 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
}
var preostaloSve float64
var imaAvans bool
- if nalog.CenaKonacna != nil {
+ if nalog.PopravkaOdbijena {
+ // klijent odbio popravku — naplaćuje se samo dijagnostika
+ ukupnoSve := nalog.CenaDijagnostike
+ avans := 0.0
+ if nalog.Avans != nil && *nalog.Avans > 0 {
+ avans = *nalog.Avans
+ imaAvans = true
+ }
+ preostaloSve = ukupnoSve - avans
+ if preostaloSve < 0 {
+ preostaloSve = 0
+ }
+ } else if nalog.CenaKonacna != nil {
ukupnoSve := *nalog.CenaKonacna + ukupnoDelovi
avans := 0.0
if nalog.Avans != nil && *nalog.Avans > 0 {
@@ -2282,6 +2294,16 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
+ // kad je klijent odbio popravku, ne prikazujemo radove i delove
+ if nalog.PopravkaOdbijena {
+ ugrRadovi = nil
+ ugrDelovi = nil
+ predRadovi = nil
+ predDelovi = nil
+ ukupnoSve = 0
+ ukupnoPredlog = 0
+ }
+
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
diff --git a/internal/model/fiskal.go b/internal/model/fiskal.go
new file mode 100644
index 0000000..41ab07c
--- /dev/null
+++ b/internal/model/fiskal.go
@@ -0,0 +1,25 @@
+package model
+
+// FiskalniRacun je veza između prodajnog naloga i odgovora fiskalne kase (Teron L-PFR).
+// Polja prate tačno ono što Fisk server vraća u JSON odgovoru (vidi NtechFisk.md §3.2).
+type FiskalniRacun struct {
+ ID int64
+ ProdajaID int64
+ TipRacuna string // "Normal", "Advance", "Copy", "Training"
+ TipTransakcije string // "Sale", "Refund"
+ PfrBroj string // invoiceNumber — jedinstven broj fiskalnog računa
+ PfrVreme string // sdcDateTime — PFR vreme izdavanja
+ Brojac string // invoiceCounter (npr. "1/1ПП")
+ EkstenzijaBrojaca string // invoiceCounterExtension (npr. "ПП")
+ UrlVerifikacija string // verificationUrl — link ka PURS proveri
+ QRKod string // verificationQRCode — base64 PNG, direktno u
+ PoreskeStavke string // JSON niz taxItems[] iz odgovora
+ UkupnoZaNaplatu float64 // totalAmount
+ UkupanPorez float64 // totalTax
+ SiroviOdgovor string // ceo JSON odgovor — sadrži journal, taxItems i ostalo za audit
+ Potpisao string // signedBy — identifikator PFR-a koji je potpisao
+ Zatrazio string // requestedBy — identifikator ESIR-a
+ Poruka string // messages (npr. "Success")
+ Storniran bool // true ako je račun naknadno storniran
+ VremeKreiranja string
+}
diff --git a/migrations/081_fiskalni_racuni.sql b/migrations/081_fiskalni_racuni.sql
new file mode 100644
index 0000000..222a44f
--- /dev/null
+++ b/migrations/081_fiskalni_racuni.sql
@@ -0,0 +1,33 @@
+-- Fiskalni računi: veza prodaje sa PFR odgovorom (Teron L-PFR).
+-- Svaki prodajni nalog može imati najviše jedan fiskalni račun (UNIQUE prodaja_id).
+-- QR kod se čuva kao base64 PNG (dolazi gotov iz Fisk servera).
+-- sirovi_odgovor čuva ceo JSON odgovor za audit (sadrži journal, taxItems, itd.).
+CREATE TABLE IF NOT EXISTS fiskalni_racuni (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ prodaja_id INTEGER NOT NULL UNIQUE,
+ tip_racuna TEXT NOT NULL DEFAULT 'Normal',
+ tip_transakcije TEXT NOT NULL DEFAULT 'Sale',
+ pfr_broj TEXT NOT NULL, -- invoiceNumber iz PFR odgovora
+ pfr_vreme TEXT NOT NULL, -- sdcDateTime
+ brojac TEXT, -- invoiceCounter (npr. "1/1ПП")
+ ekstenzija_brojaca TEXT, -- invoiceCounterExtension (npr. "ПП")
+ url_verifikacija TEXT, -- verificationUrl
+ qr_kod TEXT, -- verificationQRCode (base64 PNG)
+ poreske_stavke TEXT, -- JSON niz taxItems[]
+ ukupno_za_naplatu REAL, -- totalAmount
+ ukupan_porez REAL, -- totalTax
+ sirovi_odgovor TEXT, -- ceo JSON odgovor (audit trail)
+ potpisao TEXT, -- signedBy
+ zatrazio TEXT, -- requestedBy
+ poruka TEXT, -- messages
+ storniran INTEGER NOT NULL DEFAULT 0, -- 1 ako je fiskalni račun storniran
+ vreme_kreiranja TEXT NOT NULL DEFAULT (datetime('now','localtime')),
+ FOREIGN KEY (prodaja_id) REFERENCES prodajni_nalozi(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_fiskalni_racuni_prodaja ON fiskalni_racuni(prodaja_id);
+CREATE INDEX IF NOT EXISTS idx_fiskalni_racuni_pfr_broj ON fiskalni_racuni(pfr_broj);
+
+-- Podešavanja za fiskalizaciju koja nedostaju u 080
+INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('pfr_api_key', '');
+INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('pfr_kasir', 'NTech');
diff --git a/web/static/css/main.css b/web/static/css/main.css
index a1545cb..e351995 100644
--- a/web/static/css/main.css
+++ b/web/static/css/main.css
@@ -1274,7 +1274,8 @@ select {
align-items: flex-start;
}
.kanban-kolona {
- flex: 0 0 250px;
+ flex: 1 1 0;
+ min-width: 220px;
display: flex;
flex-direction: column;
gap: 10px;
diff --git a/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html
index edb09f0..df7befd 100644
--- a/web/templates/stranice/podesavanja_opste.html
+++ b/web/templates/stranice/podesavanja_opste.html
@@ -209,11 +209,14 @@
Samo evidencija
-
| Pravni oblik | +PDV evidencija | +Fiskalizacija | +KPO | +Dvojno knjig. | +Limit prihoda | +
|---|---|---|---|---|---|
| Paušalac | +opciono * | +✔ obavezno | +✔ | +— | +6.000.000 RSD | +
| Preduzetnik (knjige) | +preko 8M | +✔ obavezno | +✔ | +opciono ** | +bez limita | +
| DOO | +preko 8M | +✔ obavezno | +— | +✔ obavezno | +bez limita | +