Files
GoNtech/internal/fiskal/fiskal.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

162 lines
5.6 KiB
Go

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. Polja Advance*
// su na vrhu (van invoiceRequest) — Teron ih tako očekuje za konačni račun koji
// zatvara prethodno izdat avansni račun (v. NapraviKonacniZahtevSaAvansom).
type InvoiceRequest struct {
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
AdvancePaid *float64 `json:"advancePaid,omitempty"`
AdvanceTax *float64 `json:"advanceTax,omitempty"`
AdvanceLastInvoiceNumber string `json:"advanceLastInvoiceNumber,omitempty"`
AdvanceLastInvoiceDateTime string `json:"advanceLastInvoiceDateTime,omitempty"`
}
// 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)
}
}