Kartica emulator (BE) + fiskalna verifikacija + ime/prezime korisnika

- internal/be: emulator kartice (TCP listener za L-PFR mock)
- /v/: javna stranica za verifikaciju fiskalnog računa (QR kod)
- /servis/{id}/fiskalni-racun: štampa fiskalnog računa
- Podešavanja fiskalizacije: BE status/reset, proširen UI
- Korisnik: polja ime i prezime (model, DB, handler, admin profil)
- Fisk server: ažuriran receipt.py i server.py
-, : dokumentacija
This commit is contained in:
2026-06-26 23:17:52 +02:00
parent 985a2e7410
commit 97fd4490ea
21 changed files with 1425 additions and 116 deletions
+299
View File
@@ -0,0 +1,299 @@
// Package be implementira emulator bezbednosnog elementa (pametne kartice).
// Sluša na TCP portu i obrađuje 4 JSON komande: status, certificate, verify_pin, sign.
// Podatke o firmi čita iz NTech SQLite baze pri pokretanju.
package be
import (
"database/sql"
"encoding/base64"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strconv"
"strings"
"sync"
"time"
)
// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača)
var tipExt = map[[2]string][2]string{
{"Normal", "Sale"}: {"ПП", "pp"},
{"Normal", "Refund"}: {"ПР", "pr"},
{"Advance", "Sale"}: {"АП", "ap"},
{"Advance", "Refund"}: {"АР", "ar"},
{"Copy", "Sale"}: {"КП", "kp"},
{"Copy", "Refund"}: {"КР", "kr"},
{"Training", "Sale"}: {"ОП", "op"},
{"Training", "Refund"}: {"ОР", "or"},
{"Proforma", "Sale"}: {"РП", "rp"},
{"Proforma", "Refund"}: {"РР", "rr"},
}
// Kartica drži stanje emuliranog bezbednosnog elementa.
type Kartica struct {
mu sync.Mutex
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
JID string
PIN string
TIN string // RS+PIB
TINPlain string // samo PIB
Name string
Address string
City string
District string
BusinessUnitID string
LocationName string
ValidFrom string
ValidTo string
Issuer string
// stanje u toku rada
pinUnesen bool
totalCounter int
counters map[string]int
Limit float64
unreadAmount float64
}
func novaKartica(db *sql.DB) *Kartica {
k := &Kartica{
JID: env("BE_JID", "TRNMOCK1"),
PIN: env("BE_PIN", "1234"),
Limit: envFloat("BE_LIMIT", 500000),
Issuer: "Poreska uprava RS",
counters: map[string]int{
"pp": 0, "pr": 0, "ap": 0, "ar": 0,
"kp": 0, "kr": 0, "op": 0, "or": 0,
"rp": 0, "rr": 0,
},
}
now := time.Now()
k.ValidFrom = now.Format("2006-01-02T00:00:00+01:00")
k.ValidTo = now.AddDate(3, 0, 0).Format("2006-01-02T00:00:00+01:00")
// čitaj podatke firme, PIN i limit iz baze (env var ima prednost)
k.ucitajFirmu(db)
slog.Info("kartica emulator inicijalizovan",
"jid", k.JID,
"tin", k.TINPlain,
"firma", k.Name,
"limit", k.Limit,
)
return k
}
func (k *Kartica) ucitajFirmu(db *sql.DB) {
rows, err := db.Query(
"SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " +
"('naziv_firme','pib','maticni_broj','adresa','telefon'," +
"'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," +
"'be_pin','be_limit')",
)
if err != nil {
slog.Warn("be: ne mogu da čitam podesavanja", "error", err)
k.postaviTestPodatke()
return
}
defer rows.Close()
p := map[string]string{}
for rows.Next() {
var k2, v string
if err := rows.Scan(&k2, &v); err == nil {
p[k2] = v
}
}
naziv := p["naziv_firme"]
if naziv == "" {
naziv = "Test Company DOO"
}
pib := p["pib"]
if pib == "" {
pib = "123456789"
}
k.Name = naziv
k.TINPlain = pib
k.TIN = "RS" + pib
k.Address = p["adresa"]
if k.Address == "" {
k.Address = "Test Adresa 1"
}
k.City = p["grad"]
if k.City == "" {
k.City = "Beograd"
}
k.District = p["opstina"]
if k.District == "" {
k.District = "Savski Venac"
}
k.LocationName = p["poslovna_jedinica_naziv"]
if k.LocationName == "" {
k.LocationName = naziv
}
k.BusinessUnitID = p["poslovna_jedinica_oznaka"]
if k.BusinessUnitID == "" {
k.BusinessUnitID = "BU-001"
}
// PIN i limit iz baze — samo ako env var nije postavljen
if os.Getenv("BE_PIN") == "" {
if pin := p["be_pin"]; pin != "" {
k.PIN = pin
}
}
if os.Getenv("BE_LIMIT") == "" {
if lim := p["be_limit"]; lim != "" {
if f, err := strconv.ParseFloat(lim, 64); err == nil && f > 0 {
k.Limit = f
}
}
}
}
func (k *Kartica) postaviTestPodatke() {
k.Name = "Test Company DOO"
k.TINPlain = "123456789"
k.TIN = "RS123456789"
k.Address = "Test Adresa 1"
k.City = "Beograd"
k.District = "Savski Venac"
k.LocationName = "Test Company DOO"
k.BusinessUnitID = "BU-001"
}
// ── Komande ────────────────────────────────────────────────
func (k *Kartica) cmdStatus() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
counters := map[string]int{}
for key, val := range k.counters {
counters[key] = val
}
return map[string]any{
"status": "ok",
"total_counter": k.totalCounter,
"counters": counters,
"limit": k.Limit,
"unread_amount": k.unreadAmount,
"pin_required": !k.pinUnesen,
}
}
func (k *Kartica) cmdCertificate() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
return map[string]any{
"status": "ok",
"jid": k.JID,
"tin": k.TIN,
"tin_plain": k.TINPlain,
"name": k.Name,
"address": k.Address,
"city": k.City,
"district": k.District,
"business_unit_id": k.BusinessUnitID,
"location_name": k.LocationName,
"valid_from": k.ValidFrom,
"valid_to": k.ValidTo,
"issuer": k.Issuer,
}
}
func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
if pin != k.PIN {
return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"}
}
k.pinUnesen = true
return map[string]any{"status": "ok"}
}
func (k *Kartica) cmdResetAudit() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
k.unreadAmount = 0
return map[string]any{"status": "ok", "unread_amount": 0}
}
func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float64) map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
// blokada limite
if k.unreadAmount >= k.Limit {
return map[string]any{
"status": "blocked",
"message": fmt.Sprintf("Limit iščitavanja dostignut (%.2f / %.2f)", k.unreadAmount, k.Limit),
}
}
// određi tip i ext
ext, tipKey := "", "pp"
if v, ok := tipExt[[2]string{invoiceType, transactionType}]; ok {
ext = v[0]
tipKey = v[1]
} else {
ext = "ПП"
tipKey = "pp"
}
k.totalCounter++
k.counters[tipKey]++
typCounter := k.counters[tipKey]
// za fiskalne račune akumuliraj neisčitani iznos
nonFiscal := strings.HasPrefix(tipKey, "k") || strings.HasPrefix(tipKey, "o") || strings.HasPrefix(tipKey, "r")
if !nonFiscal && transactionType == "Sale" {
k.unreadAmount += totalAmount
} else if !nonFiscal && transactionType == "Refund" {
k.unreadAmount -= totalAmount
if k.unreadAmount < 0 {
k.unreadAmount = 0
}
}
// lažni potpis — random base64 dovoljno dug da izgleda realistično
sig := mockPotpis()
return map[string]any{
"status": "ok",
"counter": k.totalCounter,
"counter_extension": ext,
"type_counter": typCounter,
"signature": sig,
"blocked": false,
}
}
// mockPotpis generiše 64-bajtni nasumični base64 string koji imitira RSA potpis.
func mockPotpis() string {
b := make([]byte, 64)
for i := range b {
b[i] = byte(rand.IntN(256))
}
return base64.StdEncoding.EncodeToString(b)
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
+103
View File
@@ -0,0 +1,103 @@
package be
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
)
// JeUkljucen vraća true ako emulator treba da se pokrene.
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
func JeUkljucen(db *sql.DB) bool {
if v := os.Getenv("BE_ENABLED"); v != "" {
return v != "false" && v != "0"
}
var vrednost string
_ = db.QueryRow("SELECT vrednost FROM podesavanja WHERE kljuc='be_enabled'").Scan(&vrednost)
return vrednost != "false" && vrednost != "0"
}
// Pokreni startuje TCP listener za kartica emulator.
// Blokira dok listener ne padne — pozivati kao goroutine.
func Pokreni(db *sql.DB) {
port := env("BE_PORT", "4567")
addr := fmt.Sprintf("0.0.0.0:%s", port)
ln, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("be: ne mogu da pokrenem listener", "addr", addr, "error", err)
os.Exit(1)
}
slog.Info("kartica emulator sluša", "addr", addr)
k := novaKartica(db)
for {
conn, err := ln.Accept()
if err != nil {
slog.Warn("be: greška pri Accept()", "error", err)
continue
}
go obradiKonekciju(conn, k)
}
}
func obradiKonekciju(conn net.Conn, k *Kartica) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req map[string]any
if err := json.Unmarshal(line, &req); err != nil {
odgovori(conn, map[string]any{"status": "error", "message": "invalid JSON"})
return
}
cmd, _ := req["command"].(string)
var resp map[string]any
switch cmd {
case "status":
resp = k.cmdStatus()
case "certificate":
resp = k.cmdCertificate()
case "verify_pin":
pin, _ := req["pin"].(string)
resp = k.cmdVerifyPin(pin)
case "reset_audit":
resp = k.cmdResetAudit()
case "sign":
invType, _ := req["invoice_type"].(string)
txType, _ := req["transaction_type"].(string)
totalAmount, _ := req["total_amount"].(float64)
resp = k.cmdSign(invType, txType, totalAmount)
default:
resp = map[string]any{"status": "error", "message": fmt.Sprintf("nepoznata komanda: %q", cmd)}
}
odgovori(conn, resp)
}
}
func odgovori(conn net.Conn, resp map[string]any) {
b, err := json.Marshal(resp)
if err != nil {
return
}
b = append(b, '\n')
conn.Write(b)
}