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}}
+
PFR broj: {{.FiskalniRacun.PfrBroj}}
+Vreme: {{.FiskalniRacun.PfrVreme}}
+Brojač: {{.FiskalniRacun.Brojac}}
+ {{if .FiskalniRacun.UrlVerifikacija}} + + {{end}} +