Files
GoNtech/internal/fiskal/mapiranje.go
T
Dasko 524c6de6ba PDV: profil firme blokira nemoguće kombinacije, stope iz šifarnika umesto hardkoda
- Podešavanja: nemoguća kombinacija (samo evidencija + PDV/fiskalizacija=da)
  se sad odbija i na serveru, ne samo u brauzeru (validirajProfilFirme).
- Servis: fiskalni račun/refund više ne iskazuje PDV kad firma nije PDV
  obveznik (stavkeFiskalnogServisa nulira stopu, isto kao Prodaja).
- Avans: uklonjena hardkodovana konstanta 20% PDV — stopa se sad uzima iz
  šifarnika (Podešavanja → Kalkulacija i PDV) ili je 0 kad firma nije
  obveznik.
- fiskal.NapraviZahtev: uklonjen fallback koji je prepisivao namerno
  postavljenu 0% stopu nazad na 20% (poništavao je zaštitu Prodaje za
  ne-PDV firme).
- Forme (usluge, artikli) i validacija PDV stope u prodaji sad koriste
  šifarnik umesto literala 20 / 0,10,20.

Detaljna pravna analiza i status svake stavke u docs/Greške.md (van git-a).
2026-07-03 01:50:57 +02:00

190 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fiskal
import (
"math"
"strings"
"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. Poredi bez obzira
// na velika/mala slova jer Prodaja čuva nazive malim slovima ("gotovina", "kartica",
// "prenos"), a Servis velikim ("Gotovina", "Kartica", "Virman").
func TipPlacanja(nacin string) string {
switch strings.ToLower(nacin) {
case "gotovina":
return "Cash"
case "kartica":
return "Card"
case "virman", "račun", "racun", "prenos":
return "WireTransfer"
case "ček", "cek":
return "Check"
case "vaučer", "vaucer":
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. primljeno je iznos koji je
// kupac stvarno predao (za obračun povraćaja na fiskalnom računu) — ako je 0 ili
// manji od ukupnog iznosa, šalje se tačan iznos duga (bez povraćaja).
func NapraviZahtev(
nalog model.ProdajniNalog,
stavke []model.StavkaProdajeSaArtiklom,
pib, jmbg, kasir string,
primljeno float64,
) InvoiceRequest {
items := make([]InvoiceItem, 0, len(stavke))
ukupanIznos := 0.0
for _, s := range stavke {
// stopa je uvek ono što je snimljeno na stavci — 0 je legitimna vrednost
// (van sistema PDV/neobveznik), ne „nije postavljeno". Nikad je ne prepisivati
// nazad na opštu stopu, jer bi to poništilo nuliranje PDV-a za ne-PDV obveznike
// (v. docs/Greške.md).
stopa := s.PdvStopa
// CenaBezPdv iz baze je već diskontovana (popust primenjen pri kreiranju naloga);
// popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena.
netoCena := s.CenaBezPdv
if netoCena == 0 {
netoCena = s.CenaPoKomadu
if s.PopustProcenat > 0 {
netoCena = math.Round(netoCena*(1-s.PopustProcenat/100)*100) / 100
}
}
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 je uvek suma bruto stavki (neto iz naloga bi bilo pogrešno)
ukupnoPlacanje := ukupanIznos
// ako je kupac dao više od duga, na računu se iskazuje stvarno primljen iznos
// (fiskalni uređaj sam izračunava povraćaj kao razliku)
iznosPlacanja := ukupnoPlacanje
if primljeno > ukupnoPlacanje {
iznosPlacanja = primljeno
}
nacinPlacanja := nalog.NacinPlacanja
if nacinPlacanja == "" {
nacinPlacanja = "Gotovina"
}
zahtev := InvoiceRequest{
InvoiceRequest: InvoiceRequestBody{
InvoiceType: "Normal",
TransactionType: "Sale",
Payment: []PaymentItem{
{
Amount: iznosPlacanja,
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
}
// NapraviRefundZahtev gradi Refund zahtev za storno fiskalnog računa.
// referentBroj je PfrBroj originalnog fiskalnog računa (invoiceNumber iz PFR odgovora).
func NapraviRefundZahtev(
nalog model.ProdajniNalog,
stavke []model.StavkaProdajeSaArtiklom,
pib, jmbg, kasir, referentBroj string,
) InvoiceRequest {
zahtev := NapraviZahtev(nalog, stavke, pib, jmbg, kasir, 0)
zahtev.InvoiceRequest.TransactionType = "Refund"
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
return zahtev
}
// NazivAvansneStavke je naziv stavke na avansnim fiskalnim računima (isti naziv
// mora biti dosledan, jer ga PFR ne povezuje po ID-u već po sadržaju stavke).
const NazivAvansneStavke = "Аванс"
// NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka
// "Аванс" u punom (bruto) iznosu. stopa je PDV stopa avansa — u trenutku uplate
// stavke (radovi/delovi) obično još nisu poznate, pa pozivalac prosleđuje opštu
// stopu iz šifarnika (Podešavanja → Kalkulacija i PDV) kad je firma PDV obveznik,
// ili 0 kad nije — nikad hardkodovanu vrednost (v. docs/Greške.md §4.1).
func NapraviAvansZahtev(iznos, stopa float64, nacinPlacanja, kasir string) InvoiceRequest {
return InvoiceRequest{
InvoiceRequest: InvoiceRequestBody{
InvoiceType: "Advance",
TransactionType: "Sale",
Payment: []PaymentItem{
{Amount: iznos, PaymentType: TipPlacanja(nacinPlacanja)},
},
Items: []InvoiceItem{
{Name: NazivAvansneStavke, Labels: []string{OznakaPDV(stopa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1},
},
Cashier: kasir,
},
}
}
// NapraviAvansRefundZahtev gradi Advance/Refund zahtev za povraćaj dela ili celog
// avansa (npr. kad avans premaši konačnu cenu popravke). referentBroj je PfrBroj
// originalnog avansnog računa.
func NapraviAvansRefundZahtev(iznos, stopa float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest {
zahtev := NapraviAvansZahtev(iznos, stopa, nacinPlacanja, kasir)
zahtev.InvoiceRequest.TransactionType = "Refund"
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
return zahtev
}
// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po datoj stopi
// (npr. 500 din avansa po 20% → porez ≈ 83.33 din). stopa=0 (ne-PDV obveznik) → 0.
func PorezIzBrutoAvansa(bruto, stopa float64) float64 {
if stopa <= 0 {
return 0
}
return math.Round(bruto*stopa/(100+stopa)*100) / 100
}