Files
GoNtech/internal/fiskal/mapiranje.go
T
Dasko fcda706edd Servis: avans se fiskalizuje kao Advance/Sale, konačan račun ga zatvara
Avans se do sada nikad nije fiskalizovao — bio je samo interno polje. Zbog
toga je konačni fiskalni račun (pri preuzimanju) uvek prikazivao povraćaj
protiv PUNOG iznosa stavki, bez znanja da je avans već delimično plaćen.

Sad:
- avans se fiskalizuje kao Advance/Sale pri kreiranju/izmeni naloga
  (fiskalizujAvansServisa — fiskalizuje samo deltu ako se avans dopunjava)
- konačan Normal/Sale račun pri preuzimanju šalje advancePaid/advanceTax/
  advanceLastInvoiceNumber/advanceLastInvoiceDateTime — PFR sad zna za avans
  i tačno računa preostali dug i kusur
- ako avans premaši punu cenu, konačan račun se zatvara do pune cene, a
  razlika se automatski vraća posebnim Advance/Refund zahtevom
- servis_detalji modal prikazuje 'Avans premašuje cenu — vratiti klijentu X'
  umesto tihog svođenja duga na 0

Uzgred ispravljena dva mesta koja su koristila DohvatiPoServisu (samo
najnoviji zapis, bilo kog tipa) umesto DohvatiPoServisuITip(Normal,Sale):
- PromeniStatus je avansni račun pogrešno tretirao kao 'već fiskalizovano'
  i preskakao konačan račun kad god je nalog imao avans
- RetryFiskalizacija i FiskalGreska detekcija na servis_detalji stranici
  su imale isti problem — nalog sa avansom je izgledao fiskalizovan i
  dugme 'Pokušaj ponovo' se nije ni prikazivalo

PorezIzBrutoAvansa pretpostavlja opštu PDV stopu (20%) jer stavke u trenutku
uplate avansa još nisu poznate — nema opšteg rešenja bez pretpostavke.
2026-07-02 12:52:04 +02:00

190 lines
6.2 KiB
Go
Raw 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 := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena
if s.PdvStopa > 0 {
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 = "Аванс"
// StopaAvansa je pretpostavljena PDV stopa za avans — u trenutku uplate avansa
// stavke (radovi/delovi) obično još nisu poznate, pa se ne može tačno odrediti
// stvarna stopa. Koristi se opšta stopa (20%) kao razumna pretpostavka za
// srpsko tržište; ako firma posluje isključivo po posebnoj/nultoj stopi, ovo
// treba prilagoditi ručno (nema opšteg rešenja bez poznavanja stavki unapred).
const StopaAvansa = 20.0
// NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka
// "Аванс" u punom (bruto) iznosu.
func NapraviAvansZahtev(iznos 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(StopaAvansa)}, 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 float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest {
zahtev := NapraviAvansZahtev(iznos, nacinPlacanja, kasir)
zahtev.InvoiceRequest.TransactionType = "Refund"
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
return zahtev
}
// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po StopaAvansa
// (npr. 500 din avansa po 20% → porez ≈ 83.33 din).
func PorezIzBrutoAvansa(bruto float64) float64 {
return math.Round(bruto*StopaAvansa/(100+StopaAvansa)*100) / 100
}