From edcea0ddcf2c9f2fbdd10e5e9a0f6ac597b1a3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 22:51:59 +0200 Subject: [PATCH 01/18] =?UTF-8?q?Fiskalizacija:=20inicijalna=20implementac?= =?UTF-8?q?ija=20(8=20koraka)=201.=20migracija=20081=20=E2=80=94=20tabela?= =?UTF-8?q?=20fiskalni=5Fracuni=20+=20pode=C5=A1avanja=20pfr=5Fapi=5Fkey/p?= =?UTF-8?q?fr=5Fkasir=202.=20model=20FiskalniRacun=20(internal/model/fiska?= =?UTF-8?q?l.go)=203.=20FiskalRepository=20interfejs=20(repository.go)=204?= =?UTF-8?q?.=20SQLite=20repozitorijum=20(sqlite/fiskal.go=20=E2=80=94=20Kr?= =?UTF-8?q?eiraj,=20DohvatiPoProdaji)=205.=20Paket=20internal/fiskal/=20?= =?UTF-8?q?=E2=80=94=20HTTP=20klijent=20+=20mapiranje=20prodaja=E2=86=92in?= =?UTF-8?q?voiceRequest=206.=20Handler=20pro=C5=A1irenje=20=E2=80=94=20Fis?= =?UTF-8?q?kalRepo=20+=20fiskalKlijent()=20dinami=C4=8Dki=20helper=207.=20?= =?UTF-8?q?Inicijalizacija=20kroz=20Novi()=20/=20reinicializuj=20=E2=80=94?= =?UTF-8?q?=20klijent=20se=20=C4=8Dita=20dinami=C4=8Dki=20iz=20pode=C5=A1a?= =?UTF-8?q?vanja=208.=20Prodaja=20integracija=20=E2=80=94=20fiskalizacija?= =?UTF-8?q?=20u=20SacuvajProdaju=20+=20QR=20prikaz=20u=20prodaja=5Fdetalji?= =?UTF-8?q?.html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/repository.go | 7 + internal/db/sqlite/fiskal.go | 71 +++++++++ internal/fiskal/fiskal.go | 155 ++++++++++++++++++++ internal/fiskal/mapiranje.go | 117 +++++++++++++++ internal/handler/handler.go | 19 +++ internal/handler/prodaja.go | 72 ++++++++- internal/model/fiskal.go | 25 ++++ migrations/081_fiskalni_racuni.sql | 33 +++++ web/templates/stranice/prodaja_detalji.html | 23 +++ 9 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 internal/db/sqlite/fiskal.go create mode 100644 internal/fiskal/fiskal.go create mode 100644 internal/fiskal/mapiranje.go create mode 100644 internal/model/fiskal.go create mode 100644 migrations/081_fiskalni_racuni.sql 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/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/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/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html index 630b75a..4c8bcdb 100644 --- a/web/templates/stranice/prodaja_detalji.html +++ b/web/templates/stranice/prodaja_detalji.html @@ -246,6 +246,29 @@ {{end}} + {{if .FiskalniRacun}} +
+

Fiskalni račun

+
+
+

PFR broj: {{.FiskalniRacun.PfrBroj}}

+

Vreme: {{.FiskalniRacun.PfrVreme}}

+

Brojač: {{.FiskalniRacun.Brojac}}

+ {{if .FiskalniRacun.UrlVerifikacija}} +

+ Provera na PURS-u → +

+ {{end}} +
+ {{if .FiskalniRacun.QRKod}} +
+ QR kod za proveru fiskalnog računa +
+ {{end}} +
+
+ {{end}} + {{if index $.Dozvole "prodaja.obrisi"}}
From 7b622173820ce7ca3084f368cd7b0ff5476d0b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 22:59:17 +0200 Subject: [PATCH 02/18] =?UTF-8?q?Bezbednost:=20SSRF=20=E2=80=94=20pro?= =?UTF-8?q?=C5=A1irena=20whitelist=20na=20privatne=20mre=C5=BEe=20(192.168?= =?UTF-8?q?.x,=2010.x,=20172.16-31.x)=20Pre:=20samo=20127.0.0.1=20i=20loca?= =?UTF-8?q?lhost=20=E2=80=94=20nije=20radilo=20sa=20Fisk=20serverom=20na?= =?UTF-8?q?=20drugoj=20ma=C5=A1ini=20u=20LAN-u=20Posle:=20jePrivatnaAdresa?= =?UTF-8?q?()=20dozvoljava=20sve=20privatne=20IPv4=20opsege=20+=20localhos?= =?UTF-8?q?t=20Hostname=20se=20koristi=20direktno=20(iz=20validiranog=20UR?= =?UTF-8?q?L-a),=20port=20kao=20integer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/podesavanja.go | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) 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 { From 4425f7294a4416d45add78e09c0585c912a78383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 23:02:28 +0200 Subject: [PATCH 03/18] =?UTF-8?q?Servis:=20kanban=20kolone=20proporcionaln?= =?UTF-8?q?o=20ra=C5=A1irene=20po=20=C5=A1irini=20ekrana=20Pre:=20flex:=20?= =?UTF-8?q?0=200=20250px=20(fiksna=20=C5=A1irina,=20prazan=20prostor=20des?= =?UTF-8?q?no)=20Posle:=20flex:=201=201=200=20+=20min-width:=20220px=20(ra?= =?UTF-8?q?vnomerno=20popunjavaju=20=C5=A1irinu)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/static/css/main.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From 72ec4cb2a071413cd97a0273d09de6f06732bfcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 23:13:14 +0200 Subject: [PATCH 04/18] =?UTF-8?q?Profil=20firme:=20preduzetnik=5Fknjige=20?= =?UTF-8?q?pokriven=20u=20modulima=20+=20informativna=20tabela=20ModulKpo?= =?UTF-8?q?=20sada=20obuhvata=20i=20preduzetnik=5Fknjige=20(proste=20knjig?= =?UTF-8?q?e)=20Dodata=20tabela=20sa=20pregledom=20svih=20kombinacija=20pr?= =?UTF-8?q?avnog=20oblika,=20PDV-a,=20fiskalizacije,=20KPO=20i=20dvojnog?= =?UTF-8?q?=20Napomene:=20PDV=20prag=208M,=20pau=C5=A1alni=20limit=206M,?= =?UTF-8?q?=20fiskalizacija=20obavezna=20za=20sve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/moduli.go | 32 +++++++---- internal/config/moduli_test.go | 6 +-- web/templates/stranice/podesavanja_opste.html | 54 +++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) 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/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html index edb09f0..5b298c0 100644 --- a/web/templates/stranice/podesavanja_opste.html +++ b/web/templates/stranice/podesavanja_opste.html @@ -246,6 +246,60 @@
+ +
+
+ + Pregled — šta se pali po profilu +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pravni oblikPDV evidencijaFiskalizacijaKPODvojno knjig.Limit prihoda
Paušalacopciono *✔ obavezno6.000.000 RSD
Preduzetnik (knjige)preko 8M✔ obaveznoopciono **bez limita
DOOpreko 8M✔ obavezno✔ obaveznobez limita
+
+
+ * Paušalac može dobrovoljno ući u PDV. Preko 8M — obavezno izlazi iz paušala i ulazi u PDV.
+ ** Preduzetnik koji vodi knjige može birati prosto ili dvojno knjigovodstvo.
+ Fiskalizacija je obavezna za sve koji izdaju račune građanima (maloprodaja) — bez obzira na pravni oblik.
+ PDV prag: 8.000.000 RSD u prethodnih 12 meseci (Zakon o PDV-u, čl. 33). Paušalni limit: 6.000.000 RSD (Zakon o porezu na dohodak građana, čl. 40). +
+
+ {{if .LogoGreska}} From ab43f9018de12ec9dd415c1c52bc29cafebdd0d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 25 Jun 2026 23:23:13 +0200 Subject: [PATCH 05/18] =?UTF-8?q?Pode=C5=A1avanja:=20JS=20logika=20zaklju?= =?UTF-8?q?=C4=8Davanja=20=E2=80=94=20PDV=20se=20gasi=20uz=20Fiskalizaciju?= =?UTF-8?q?=20u=20re=C5=BEimu=20=E2=80=9ESamo=20evidencija"=20Pro=C5=A1ire?= =?UTF-8?q?na=20postoje=C4=87a=20JS=20logika:=20sada=20i=20PDV=20i=20Fiska?= =?UTF-8?q?lizacija=20idu=20na=20=E2=80=9ENe"=20i=20zasive=20se=20kad=20je?= =?UTF-8?q?=20re=C5=BEim=20=E2=80=9Esamo=20evidencija".=20Refaktorisano=20?= =?UTF-8?q?u=20lockGrupa/unlockGrupa=20helper-e.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/templates/stranice/podesavanja_opste.html | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html index 5b298c0..2defccd 100644 --- a/web/templates/stranice/podesavanja_opste.html +++ b/web/templates/stranice/podesavanja_opste.html @@ -213,7 +213,7 @@ -
+