mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
feat(billing): implement billing and time synchronization module
Added a new billing service and time synchronization functionality to the server. This includes the creation of billing packages, contracts, and sessions, along with necessary database migrations. Updated API endpoints for billing management and integrated billing checks into signal handling. Enhanced configuration options for billing parameters and added localization support for billing-related messages.
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/unitronix/betterdesk-server/billing"
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
)
|
||||
|
||||
// SetBillingService attaches the billing engine.
|
||||
func (s *Server) SetBillingService(b *billing.Service) {
|
||||
s.billing = b
|
||||
}
|
||||
|
||||
// GET /api/billing/packages
|
||||
func (s *Server) handleListBillingPackages(w http.ResponseWriter, r *http.Request) {
|
||||
pkgs, err := s.db.ListBillingPackages()
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingPackages")
|
||||
return
|
||||
}
|
||||
if pkgs == nil {
|
||||
pkgs = []*db.BillingPackage{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"packages": pkgs})
|
||||
}
|
||||
|
||||
// POST /api/billing/packages
|
||||
func (s *Server) handleCreateBillingPackage(w http.ResponseWriter, r *http.Request) {
|
||||
var body db.BillingPackage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
return
|
||||
}
|
||||
if body.ID == "" {
|
||||
body.ID = uuid.New().String()
|
||||
}
|
||||
if body.Currency == "" {
|
||||
body.Currency = "PLN"
|
||||
}
|
||||
if err := s.db.CreateBillingPackage(&body); err != nil {
|
||||
writeInternalError(w, err, "CreateBillingPackage")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, body)
|
||||
}
|
||||
|
||||
// PUT /api/billing/packages/{id}
|
||||
func (s *Server) handleUpdateBillingPackage(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body db.BillingPackage
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
body.ID = id
|
||||
if err := s.db.UpdateBillingPackage(&body); err != nil {
|
||||
writeInternalError(w, err, "UpdateBillingPackage")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// DELETE /api/billing/packages/{id}
|
||||
func (s *Server) handleDeleteBillingPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeleteBillingPackage(r.PathValue("id")); err != nil {
|
||||
writeInternalError(w, err, "DeleteBillingPackage")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
// GET /api/billing/contracts?org_id=
|
||||
func (s *Server) handleListBillingContracts(w http.ResponseWriter, r *http.Request) {
|
||||
filter := db.BillingContractFilter{
|
||||
OrgID: r.URL.Query().Get("org_id"),
|
||||
Status: r.URL.Query().Get("status"),
|
||||
}
|
||||
contracts, err := s.db.ListBillingOrgContracts(filter)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingOrgContracts")
|
||||
return
|
||||
}
|
||||
if contracts == nil {
|
||||
contracts = []*db.BillingOrgContract{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contracts": contracts})
|
||||
}
|
||||
|
||||
// POST /api/billing/contracts
|
||||
func (s *Server) handleCreateBillingContract(w http.ResponseWriter, r *http.Request) {
|
||||
var body db.BillingOrgContract
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if body.OrgID == "" || body.PackageID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "org_id and package_id required"})
|
||||
return
|
||||
}
|
||||
if body.ID == "" {
|
||||
body.ID = uuid.New().String()
|
||||
}
|
||||
if body.Status == "" {
|
||||
body.Status = billing.ContractActive
|
||||
}
|
||||
if body.Currency == "" {
|
||||
body.Currency = "PLN"
|
||||
}
|
||||
if body.RemainingMinutes == 0 {
|
||||
if pkg, err := s.db.GetBillingPackage(body.PackageID); err == nil && pkg != nil {
|
||||
body.RemainingMinutes = pkg.IncludedMinutes
|
||||
}
|
||||
}
|
||||
if err := s.db.CreateBillingOrgContract(&body); err != nil {
|
||||
writeInternalError(w, err, "CreateBillingOrgContract")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, body)
|
||||
}
|
||||
|
||||
// PUT /api/billing/contracts/{id}
|
||||
func (s *Server) handleUpdateBillingContract(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
existing, err := s.db.GetBillingOrgContract(id)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "GetBillingOrgContract")
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "contract not found"})
|
||||
return
|
||||
}
|
||||
var patch map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if raw, ok := patch["status"]; ok {
|
||||
var status string
|
||||
if err := json.Unmarshal(raw, &status); err == nil && status != "" {
|
||||
existing.Status = status
|
||||
}
|
||||
}
|
||||
if raw, ok := patch["remaining_minutes"]; ok {
|
||||
var mins int
|
||||
if err := json.Unmarshal(raw, &mins); err == nil {
|
||||
existing.RemainingMinutes = mins
|
||||
}
|
||||
}
|
||||
if raw, ok := patch["overage_rate"]; ok {
|
||||
var rate *float64
|
||||
if err := json.Unmarshal(raw, &rate); err == nil {
|
||||
existing.OverageRate = rate
|
||||
}
|
||||
}
|
||||
if raw, ok := patch["hourly_rate"]; ok {
|
||||
var rate float64
|
||||
if err := json.Unmarshal(raw, &rate); err == nil {
|
||||
existing.HourlyRate = rate
|
||||
}
|
||||
}
|
||||
if raw, ok := patch["currency"]; ok {
|
||||
var cur string
|
||||
if err := json.Unmarshal(raw, &cur); err == nil && cur != "" {
|
||||
existing.Currency = cur
|
||||
}
|
||||
}
|
||||
if err := s.db.UpdateBillingOrgContract(existing); err != nil {
|
||||
writeInternalError(w, err, "UpdateBillingOrgContract")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
// GET /api/billing/sessions
|
||||
func (s *Server) handleListBillingSessions(w http.ResponseWriter, r *http.Request) {
|
||||
filter := db.BillingSessionFilter{OrgID: r.URL.Query().Get("org_id"), Status: r.URL.Query().Get("status")}
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
filter.Limit = n
|
||||
}
|
||||
}
|
||||
sessions, err := s.db.ListBillingSessions(filter)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingSessions")
|
||||
return
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []*db.BillingSession{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
|
||||
}
|
||||
|
||||
// GET /api/billing/reports
|
||||
func (s *Server) handleListBillingReports(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
reports, err := s.db.ListBillingWorkReports(r.URL.Query().Get("org_id"), limit)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingWorkReports")
|
||||
return
|
||||
}
|
||||
if reports == nil {
|
||||
reports = []*db.BillingWorkReport{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reports": reports})
|
||||
}
|
||||
|
||||
// POST /api/billing/sessions/{id}/report
|
||||
func (s *Server) handleSubmitBillingReport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.billing == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "billing not configured"})
|
||||
return
|
||||
}
|
||||
sessionID := r.PathValue("id")
|
||||
var body struct {
|
||||
Summary string `json:"summary"`
|
||||
Category string `json:"category"`
|
||||
TicketRef string `json:"ticket_ref"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
operatorID := getUsernameFromCtx(r)
|
||||
if operatorID == "" {
|
||||
operatorID = r.Header.Get("X-Operator-Id")
|
||||
}
|
||||
if operatorID == "" {
|
||||
operatorID = "operator"
|
||||
}
|
||||
if err := s.billing.SubmitWorkReport(sessionID, operatorID, body.Summary, body.Category, body.TicketRef); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
// GET /api/billing/currencies
|
||||
func (s *Server) handleListBillingCurrencies(w http.ResponseWriter, r *http.Request) {
|
||||
cur, err := s.db.ListBillingCurrencies()
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingCurrencies")
|
||||
return
|
||||
}
|
||||
if cur == nil {
|
||||
cur = []*db.BillingCurrency{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"currencies": cur})
|
||||
}
|
||||
|
||||
// PUT /api/billing/currencies/{code}
|
||||
func (s *Server) handleUpsertBillingCurrency(w http.ResponseWriter, r *http.Request) {
|
||||
var body db.BillingCurrency
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
body.Code = strings.ToUpper(r.PathValue("code"))
|
||||
if body.Code == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code required"})
|
||||
return
|
||||
}
|
||||
if err := s.db.UpsertBillingCurrency(&body); err != nil {
|
||||
writeInternalError(w, err, "UpsertBillingCurrency")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// GET /api/billing/check?device_id=
|
||||
func (s *Server) handleBillingConnectionCheck(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.URL.Query().Get("device_id")
|
||||
if deviceID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "device_id required"})
|
||||
return
|
||||
}
|
||||
if s.billing == nil {
|
||||
writeJSON(w, http.StatusOK, billing.ConnectionCheckResult{Allowed: true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.billing.CheckConnection(deviceID))
|
||||
}
|
||||
|
||||
// GET /api/billing/sessions/pending?device_id=
|
||||
func (s *Server) handleGetPendingBillingSession(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
|
||||
if deviceID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "device_id required"})
|
||||
return
|
||||
}
|
||||
sessions, err := s.db.ListBillingSessions(db.BillingSessionFilter{
|
||||
DeviceID: deviceID,
|
||||
Status: billing.StatusPendingReport,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingSessions")
|
||||
return
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"session": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"session": sessions[0]})
|
||||
}
|
||||
|
||||
// GET /api/billing/reports/export?format=csv|pdf&org_id=
|
||||
func (s *Server) handleExportBillingReports(w http.ResponseWriter, r *http.Request) {
|
||||
format := strings.ToLower(r.URL.Query().Get("format"))
|
||||
if format == "" {
|
||||
format = "csv"
|
||||
}
|
||||
orgID := r.URL.Query().Get("org_id")
|
||||
reports, err := s.db.ListBillingWorkReports(orgID, 5000)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingWorkReports")
|
||||
return
|
||||
}
|
||||
sessions, err := s.db.ListBillingSessions(db.BillingSessionFilter{OrgID: orgID, Limit: 5000})
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingSessions")
|
||||
return
|
||||
}
|
||||
sessMap := make(map[string]*db.BillingSession, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
sessMap[sess.ID] = sess
|
||||
}
|
||||
rows := billing.BuildReportExportRows(reports, sessMap)
|
||||
stamp := time.Now().UTC().Format("20060102")
|
||||
|
||||
switch format {
|
||||
case "csv":
|
||||
data, err := billing.WriteReportsCSV(rows)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "WriteReportsCSV")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="billing-reports-%s.csv"`, stamp))
|
||||
w.Write(data)
|
||||
case "pdf":
|
||||
data := billing.WriteReportsPDF(rows, "BetterDesk — Work Reports")
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="billing-reports-%s.pdf"`, stamp))
|
||||
w.Write(data)
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "format must be csv or pdf"})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/billing/sessions/export?format=csv&org_id=
|
||||
func (s *Server) handleExportBillingSessions(w http.ResponseWriter, r *http.Request) {
|
||||
format := strings.ToLower(r.URL.Query().Get("format"))
|
||||
if format != "csv" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only csv supported"})
|
||||
return
|
||||
}
|
||||
orgID := r.URL.Query().Get("org_id")
|
||||
sessions, err := s.db.ListBillingSessions(db.BillingSessionFilter{OrgID: orgID, Limit: 5000})
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "ListBillingSessions")
|
||||
return
|
||||
}
|
||||
exportRows := make([]billing.SessionExportRow, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
exportRows = append(exportRows, billing.SessionExportRow{
|
||||
ID: sess.ID,
|
||||
OrgID: sess.OrgID,
|
||||
DeviceID: sess.DeviceID,
|
||||
OperatorID: sess.OperatorID,
|
||||
Status: sess.Status,
|
||||
BillingPhase: sess.BillingPhase,
|
||||
BilledMinutes: sess.BilledMinutes,
|
||||
AmountOverage: sess.AmountOverage,
|
||||
Currency: sess.Currency,
|
||||
StartedAt: sess.StartedAt,
|
||||
EndedAt: sess.EndedAt,
|
||||
})
|
||||
}
|
||||
data, err := billing.WriteSessionsCSV(exportRows)
|
||||
if err != nil {
|
||||
writeInternalError(w, err, "WriteSessionsCSV")
|
||||
return
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102")
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="billing-sessions-%s.csv"`, stamp))
|
||||
w.Write(data)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/unitronix/betterdesk-server/audit"
|
||||
"github.com/unitronix/betterdesk-server/auth"
|
||||
"github.com/unitronix/betterdesk-server/billing"
|
||||
"github.com/unitronix/betterdesk-server/cdap"
|
||||
"github.com/unitronix/betterdesk-server/config"
|
||||
"github.com/unitronix/betterdesk-server/crypto"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/unitronix/betterdesk-server/ratelimit"
|
||||
"github.com/unitronix/betterdesk-server/relay"
|
||||
"github.com/unitronix/betterdesk-server/security"
|
||||
"github.com/unitronix/betterdesk-server/timesync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -66,6 +68,8 @@ type Server struct {
|
||||
oidcProvider *auth.OIDCProvider // OIDC/OAuth2 auth provider (nil if not configured)
|
||||
clientTFASessions *tfaSessionStore
|
||||
panelStore db.PanelSyncStore // device groups, folders, ACL (PostgreSQL or legacy auth.db)
|
||||
timeSync *timesync.Service
|
||||
billing *billing.Service
|
||||
httpSrv *http.Server
|
||||
wg sync.WaitGroup
|
||||
version string
|
||||
@@ -421,6 +425,26 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/help/requests/{id}/acknowledge", s.requirePermission(auth.PermChatAccess, s.handleAcknowledgeHelpRequest))
|
||||
mux.HandleFunc("POST /api/help/requests/{id}/resolve", s.requirePermission(auth.PermChatAccess, s.handleResolveHelpRequest))
|
||||
|
||||
// Time sync / billing (commercialization)
|
||||
mux.HandleFunc("GET /api/timesync/status", s.requirePermission(auth.PermBillingView, s.handleTimeSyncStatus))
|
||||
mux.HandleFunc("POST /api/timesync/check", s.requirePermission(auth.PermServerConfig, s.handleTimeSyncCheck))
|
||||
mux.HandleFunc("GET /api/billing/check", s.requirePermission(auth.PermDeviceConnect, s.handleBillingConnectionCheck))
|
||||
mux.HandleFunc("GET /api/billing/packages", s.requirePermission(auth.PermBillingView, s.handleListBillingPackages))
|
||||
mux.HandleFunc("POST /api/billing/packages", s.requirePermission(auth.PermBillingManage, s.handleCreateBillingPackage))
|
||||
mux.HandleFunc("PUT /api/billing/packages/{id}", s.requirePermission(auth.PermBillingManage, s.handleUpdateBillingPackage))
|
||||
mux.HandleFunc("DELETE /api/billing/packages/{id}", s.requirePermission(auth.PermBillingManage, s.handleDeleteBillingPackage))
|
||||
mux.HandleFunc("GET /api/billing/contracts", s.requirePermission(auth.PermBillingView, s.handleListBillingContracts))
|
||||
mux.HandleFunc("POST /api/billing/contracts", s.requirePermission(auth.PermBillingManage, s.handleCreateBillingContract))
|
||||
mux.HandleFunc("PUT /api/billing/contracts/{id}", s.requirePermission(auth.PermBillingManage, s.handleUpdateBillingContract))
|
||||
mux.HandleFunc("GET /api/billing/sessions", s.requirePermission(auth.PermBillingView, s.handleListBillingSessions))
|
||||
mux.HandleFunc("GET /api/billing/sessions/pending", s.requirePermission(auth.PermBillingReports, s.handleGetPendingBillingSession))
|
||||
mux.HandleFunc("GET /api/billing/sessions/export", s.requirePermission(auth.PermBillingExport, s.handleExportBillingSessions))
|
||||
mux.HandleFunc("GET /api/billing/reports", s.requirePermission(auth.PermBillingView, s.handleListBillingReports))
|
||||
mux.HandleFunc("GET /api/billing/reports/export", s.requirePermission(auth.PermBillingExport, s.handleExportBillingReports))
|
||||
mux.HandleFunc("POST /api/billing/sessions/{id}/report", s.requirePermission(auth.PermBillingReports, s.handleSubmitBillingReport))
|
||||
mux.HandleFunc("GET /api/billing/currencies", s.requirePermission(auth.PermBillingView, s.handleListBillingCurrencies))
|
||||
mux.HandleFunc("PUT /api/billing/currencies/{code}", s.requirePermission(auth.PermBillingManage, s.handleUpsertBillingCurrency))
|
||||
|
||||
// Enrollment — operator approval (admin/operator)
|
||||
mux.HandleFunc("GET /api/enrollment/pending", s.requireRole(auth.RoleOperator, s.handleListPendingDevices))
|
||||
mux.HandleFunc("POST /api/enrollment/approve/{id}", s.requireRole(auth.RoleOperator, s.handleApproveDevice))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/timesync"
|
||||
)
|
||||
|
||||
// SetTimeSyncService attaches the clock monitor.
|
||||
func (s *Server) SetTimeSyncService(ts *timesync.Service) {
|
||||
s.timeSync = ts
|
||||
}
|
||||
|
||||
// GET /api/timesync/status
|
||||
func (s *Server) handleTimeSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.timeSync == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "timesync not configured"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.timeSync.GetStatus())
|
||||
}
|
||||
|
||||
// POST /api/timesync/check
|
||||
func (s *Server) handleTimeSyncCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if s.timeSync == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "timesync not configured"})
|
||||
return
|
||||
}
|
||||
st := s.timeSync.CheckNow()
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
@@ -55,6 +55,12 @@ const (
|
||||
|
||||
// Branding
|
||||
PermBrandingEdit = "branding.edit"
|
||||
|
||||
// Billing / commercialization
|
||||
PermBillingView = "billing.view"
|
||||
PermBillingManage = "billing.manage"
|
||||
PermBillingReports = "billing.reports"
|
||||
PermBillingExport = "billing.export"
|
||||
)
|
||||
|
||||
// AllPermissions is the complete list of permission strings for validation.
|
||||
@@ -69,6 +75,7 @@ var AllPermissions = []string{
|
||||
PermEnrollmentManage, PermEnrollmentApprove,
|
||||
PermChatAccess,
|
||||
PermBrandingEdit,
|
||||
PermBillingView, PermBillingManage, PermBillingReports, PermBillingExport,
|
||||
}
|
||||
|
||||
// DefaultRolePermissions maps each built-in role to its default set of permissions.
|
||||
@@ -109,6 +116,7 @@ var DefaultRolePermissions = map[string]map[string]bool{
|
||||
PermChatAccess,
|
||||
PermEnrollmentManage, PermEnrollmentApprove,
|
||||
PermBrandingEdit,
|
||||
PermBillingView, PermBillingManage, PermBillingReports, PermBillingExport,
|
||||
}),
|
||||
|
||||
RoleOperator: buildPermMap([]string{
|
||||
@@ -119,6 +127,7 @@ var DefaultRolePermissions = map[string]map[string]bool{
|
||||
PermEnrollmentApprove,
|
||||
PermChatAccess,
|
||||
PermOrgManageDevices,
|
||||
PermBillingView, PermBillingReports,
|
||||
}),
|
||||
RoleViewer: buildPermMap([]string{
|
||||
PermDeviceView,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
)
|
||||
|
||||
// ReportExportRow is a flattened work report for export.
|
||||
type ReportExportRow struct {
|
||||
SessionID string
|
||||
DeviceID string
|
||||
OrgID string
|
||||
OperatorID string
|
||||
Summary string
|
||||
Category string
|
||||
TicketRef string
|
||||
BilledMin int
|
||||
Amount float64
|
||||
Currency string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionExportRow is a flattened billing session for export.
|
||||
type SessionExportRow struct {
|
||||
ID string
|
||||
OrgID string
|
||||
DeviceID string
|
||||
OperatorID string
|
||||
Status string
|
||||
BillingPhase string
|
||||
BilledMinutes int
|
||||
AmountOverage float64
|
||||
Currency string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// WriteReportsCSV writes work reports as CSV bytes.
|
||||
func WriteReportsCSV(rows []ReportExportRow) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"session_id", "device_id", "org_id", "operator_id", "summary", "category", "ticket_ref", "billed_minutes", "amount", "currency", "created_at"})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.SessionID, r.DeviceID, r.OrgID, r.OperatorID, r.Summary, r.Category, r.TicketRef,
|
||||
strconv.Itoa(r.BilledMin), fmt.Sprintf("%.2f", r.Amount), r.Currency, r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
// WriteSessionsCSV writes billing sessions as CSV bytes.
|
||||
func WriteSessionsCSV(rows []SessionExportRow) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"id", "org_id", "device_id", "operator_id", "status", "billing_phase", "billed_minutes", "amount_overage", "currency", "started_at", "ended_at"})
|
||||
for _, r := range rows {
|
||||
ended := ""
|
||||
if r.EndedAt != nil {
|
||||
ended = r.EndedAt.Format(time.RFC3339)
|
||||
}
|
||||
_ = w.Write([]string{
|
||||
r.ID, r.OrgID, r.DeviceID, r.OperatorID, r.Status, r.BillingPhase,
|
||||
strconv.Itoa(r.BilledMinutes), fmt.Sprintf("%.2f", r.AmountOverage), r.Currency,
|
||||
r.StartedAt.Format(time.RFC3339), ended,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
// WriteReportsPDF writes a minimal text PDF with report lines.
|
||||
func WriteReportsPDF(rows []ReportExportRow, title string) []byte {
|
||||
var lines []string
|
||||
lines = append(lines, title)
|
||||
lines = append(lines, fmt.Sprintf("Generated: %s", time.Now().UTC().Format(time.RFC3339)))
|
||||
lines = append(lines, "")
|
||||
for i, r := range rows {
|
||||
lines = append(lines, fmt.Sprintf("#%d Session: %s", i+1, r.SessionID))
|
||||
lines = append(lines, fmt.Sprintf("Device: %s Org: %s Operator: %s", r.DeviceID, r.OrgID, r.OperatorID))
|
||||
lines = append(lines, fmt.Sprintf("Time: %s Billed: %d min Amount: %.2f %s", r.CreatedAt.Format(time.RFC3339), r.BilledMin, r.Amount, r.Currency))
|
||||
if r.Category != "" {
|
||||
lines = append(lines, "Category: "+r.Category)
|
||||
}
|
||||
if r.TicketRef != "" {
|
||||
lines = append(lines, "Ticket: "+r.TicketRef)
|
||||
}
|
||||
lines = append(lines, "Summary: "+sanitizePDFText(r.Summary))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return simpleTextPDF(lines)
|
||||
}
|
||||
|
||||
func sanitizePDFText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// simpleTextPDF builds a minimal valid PDF 1.4 document with Helvetica text.
|
||||
func simpleTextPDF(lines []string) []byte {
|
||||
var content strings.Builder
|
||||
content.WriteString("BT\n/F1 10 Tf\n")
|
||||
y := 770
|
||||
for _, line := range lines {
|
||||
if y < 40 {
|
||||
break
|
||||
}
|
||||
escaped := escapePDFString(line)
|
||||
content.WriteString(fmt.Sprintf("1 0 0 1 40 %d Tm (%s) Tj\n", y, escaped))
|
||||
y -= 14
|
||||
}
|
||||
content.WriteString("ET\n")
|
||||
stream := content.String()
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := func(s string) { buf.WriteString(s) }
|
||||
w("%PDF-1.4\n")
|
||||
offs := []int{0}
|
||||
offs = append(offs, buf.Len())
|
||||
w("1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n")
|
||||
offs = append(offs, buf.Len())
|
||||
w("2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n")
|
||||
offs = append(offs, buf.Len())
|
||||
w("3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>endobj\n")
|
||||
offs = append(offs, buf.Len())
|
||||
w(fmt.Sprintf("4 0 obj<< /Length %d >>stream\n%s\nendstream\nendobj\n", len(stream), stream))
|
||||
offs = append(offs, buf.Len())
|
||||
w("5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n")
|
||||
xref := buf.Len()
|
||||
w("xref\n")
|
||||
w(fmt.Sprintf("0 %d\n", len(offs)))
|
||||
w("0000000000 65535 f \n")
|
||||
for i := 1; i < len(offs); i++ {
|
||||
w(fmt.Sprintf("%010d 00000 n \n", offs[i]))
|
||||
}
|
||||
w("trailer<< /Size 6 /Root 1 0 R >>\n")
|
||||
w(fmt.Sprintf("startxref\n%d\n%%EOF\n", xref))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func escapePDFString(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `(`, `\(`)
|
||||
s = strings.ReplaceAll(s, `)`, `\)`)
|
||||
r := []rune(s)
|
||||
if len(r) > 200 {
|
||||
r = r[:200]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// BuildReportExportRows joins reports with session billing data.
|
||||
func BuildReportExportRows(reports []*db.BillingWorkReport, sessions map[string]*db.BillingSession) []ReportExportRow {
|
||||
out := make([]ReportExportRow, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
row := ReportExportRow{
|
||||
SessionID: r.SessionID,
|
||||
OperatorID: r.OperatorID,
|
||||
Summary: r.Summary,
|
||||
Category: r.Category,
|
||||
TicketRef: r.TicketRef,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
if sess, ok := sessions[r.SessionID]; ok && sess != nil {
|
||||
row.DeviceID = sess.DeviceID
|
||||
row.OrgID = sess.OrgID
|
||||
row.BilledMin = sess.BilledMinutes
|
||||
row.Amount = sess.AmountIncluded + sess.AmountOverage
|
||||
row.Currency = sess.Currency
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteReportsCSV(t *testing.T) {
|
||||
data, err := WriteReportsCSV([]ReportExportRow{{
|
||||
SessionID: "s1", DeviceID: "dev", Summary: "Fixed issue", CreatedAt: time.Now().UTC(),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 || data[0] != 's' {
|
||||
t.Fatalf("unexpected csv: %q", string(data[:20]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleTextPDF(t *testing.T) {
|
||||
pdf := simpleTextPDF([]string{"BetterDesk billing report", "Line 2"})
|
||||
if len(pdf) < 100 || pdf[0] != '%' {
|
||||
t.Fatal("invalid pdf header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapePDFString(t *testing.T) {
|
||||
got := escapePDFString(`test (parens) and \ backslash`)
|
||||
if got == "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
"github.com/unitronix/betterdesk-server/timesync"
|
||||
)
|
||||
|
||||
const (
|
||||
PhaseIncluded = "included"
|
||||
PhaseOverage = "overage"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusPendingReport = "pending_report"
|
||||
StatusClosed = "closed"
|
||||
|
||||
ContractActive = "active"
|
||||
ContractSuspended = "suspended"
|
||||
|
||||
LedgerSessionStart = "session_start"
|
||||
LedgerPhaseChange = "phase_change"
|
||||
LedgerSessionEnd = "session_end"
|
||||
)
|
||||
|
||||
// ConnectionCheckResult is returned before allowing a remote session.
|
||||
type ConnectionCheckResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
HasBilling bool `json:"has_billing"`
|
||||
}
|
||||
|
||||
// Service manages billable remote sessions.
|
||||
type Service struct {
|
||||
db db.Database
|
||||
clock *timesync.Service
|
||||
roundMin int
|
||||
requireReport bool
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingRelay // relayUUID -> meta
|
||||
active map[string]*activeSession
|
||||
}
|
||||
|
||||
type pendingRelay struct {
|
||||
OrgID string
|
||||
DeviceID string
|
||||
OperatorID string
|
||||
ContractID string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type activeSession struct {
|
||||
ID string
|
||||
OrgID string
|
||||
ContractID string
|
||||
RelayUUID string
|
||||
StartedAt time.Time
|
||||
RemainingAtStart int
|
||||
OverageRate float64
|
||||
HourlyRate float64
|
||||
Currency string
|
||||
Phase string
|
||||
LastBilledMin int
|
||||
}
|
||||
|
||||
// NewService creates a billing service.
|
||||
func NewService(database db.Database, clock *timesync.Service, roundingMinutes int, requireWorkReport bool) *Service {
|
||||
if roundingMinutes <= 0 {
|
||||
roundingMinutes = 1
|
||||
}
|
||||
return &Service{
|
||||
db: database,
|
||||
clock: clock,
|
||||
roundMin: roundingMinutes,
|
||||
requireReport: requireWorkReport,
|
||||
pending: make(map[string]*pendingRelay),
|
||||
active: make(map[string]*activeSession),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the session ticker.
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
go s.ticker(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ticker(ctx context.Context) {
|
||||
tick := time.NewTicker(10 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s.tickActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckConnection evaluates whether a connection to deviceID may proceed.
|
||||
func (s *Service) CheckConnection(deviceID string) ConnectionCheckResult {
|
||||
orgID, err := s.db.GetDeviceOrgID(deviceID)
|
||||
if err != nil || orgID == "" {
|
||||
return ConnectionCheckResult{Allowed: true}
|
||||
}
|
||||
|
||||
if s.clock != nil && !s.clock.IsSynced() {
|
||||
return ConnectionCheckResult{Allowed: false, Reason: "clock_unsynced", OrgID: orgID}
|
||||
}
|
||||
|
||||
contract, err := s.db.GetActiveBillingOrgContract(orgID)
|
||||
if err != nil || contract == nil {
|
||||
return ConnectionCheckResult{Allowed: true, OrgID: orgID}
|
||||
}
|
||||
if contract.Status == ContractSuspended {
|
||||
return ConnectionCheckResult{Allowed: false, Reason: "billing_suspended", OrgID: orgID, HasBilling: true}
|
||||
}
|
||||
return ConnectionCheckResult{Allowed: true, OrgID: orgID, HasBilling: true}
|
||||
}
|
||||
|
||||
// PrepareRelay registers billing metadata when signal assigns a relay UUID.
|
||||
func (s *Service) PrepareRelay(relayUUID, deviceID, operatorID string) error {
|
||||
if relayUUID == "" {
|
||||
return nil
|
||||
}
|
||||
check := s.CheckConnection(deviceID)
|
||||
if !check.Allowed {
|
||||
return fmt.Errorf("billing: %s", check.Reason)
|
||||
}
|
||||
if !check.HasBilling {
|
||||
return nil
|
||||
}
|
||||
contract, err := s.db.GetActiveBillingOrgContract(check.OrgID)
|
||||
if err != nil || contract == nil {
|
||||
return nil
|
||||
}
|
||||
overage := contract.OverageRate
|
||||
if overage == nil {
|
||||
pkg, _ := s.db.GetBillingPackage(contract.PackageID)
|
||||
if pkg != nil {
|
||||
v := pkg.OverageRate
|
||||
overage = &v
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending[relayUUID] = &pendingRelay{
|
||||
OrgID: check.OrgID,
|
||||
DeviceID: deviceID,
|
||||
OperatorID: operatorID,
|
||||
ContractID: contract.ID,
|
||||
Currency: contract.Currency,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateRelay starts the authoritative billing session when relay pairs.
|
||||
func (s *Service) ActivateRelay(relayUUID string) {
|
||||
if relayUUID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
meta, ok := s.pending[relayUUID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(s.pending, relayUUID)
|
||||
s.mu.Unlock()
|
||||
|
||||
contract, err := s.db.GetBillingOrgContract(meta.ContractID)
|
||||
if err != nil || contract == nil {
|
||||
return
|
||||
}
|
||||
overageRate := contract.HourlyRate
|
||||
if contract.OverageRate != nil {
|
||||
overageRate = *contract.OverageRate
|
||||
} else if pkg, err := s.db.GetBillingPackage(contract.PackageID); err == nil && pkg != nil {
|
||||
overageRate = pkg.OverageRate
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if s.clock != nil {
|
||||
now = s.clock.NowUTC()
|
||||
}
|
||||
sessionID := uuid.New().String()
|
||||
synced := true
|
||||
var offset int64
|
||||
if s.clock != nil {
|
||||
st := s.clock.GetStatus()
|
||||
synced = st.Synced
|
||||
offset = st.OffsetMS
|
||||
}
|
||||
|
||||
sess := &db.BillingSession{
|
||||
ID: sessionID,
|
||||
OrgID: meta.OrgID,
|
||||
ContractID: meta.ContractID,
|
||||
OperatorID: meta.OperatorID,
|
||||
DeviceID: meta.DeviceID,
|
||||
RelayUUID: relayUUID,
|
||||
Transport: "rustdesk",
|
||||
Status: StatusActive,
|
||||
BillingPhase: PhaseIncluded,
|
||||
StartedAt: now,
|
||||
Currency: meta.Currency,
|
||||
ClockOffsetMSAtStart: offset,
|
||||
ClockSyncedAtStart: synced,
|
||||
}
|
||||
if err := s.db.CreateBillingSession(sess); err != nil {
|
||||
log.Printf("[billing] CreateBillingSession: %v", err)
|
||||
return
|
||||
}
|
||||
_ = s.db.InsertBillingLedgerEntry(&db.BillingSessionLedger{
|
||||
SessionID: sessionID,
|
||||
EventType: LedgerSessionStart,
|
||||
Details: relayUUID,
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
s.active[relayUUID] = &activeSession{
|
||||
ID: sessionID,
|
||||
OrgID: meta.OrgID,
|
||||
ContractID: meta.ContractID,
|
||||
RelayUUID: relayUUID,
|
||||
StartedAt: now,
|
||||
RemainingAtStart: contract.RemainingMinutes,
|
||||
OverageRate: overageRate,
|
||||
HourlyRate: contract.HourlyRate,
|
||||
Currency: meta.Currency,
|
||||
Phase: PhaseIncluded,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// EndRelay finalizes a billing session when relay ends.
|
||||
func (s *Service) EndRelay(relayUUID string) {
|
||||
if relayUUID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
active, ok := s.active[relayUUID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(s.active, relayUUID)
|
||||
s.mu.Unlock()
|
||||
s.finalizeSession(active)
|
||||
}
|
||||
|
||||
func (s *Service) tickActive() {
|
||||
s.mu.Lock()
|
||||
snap := make([]*activeSession, 0, len(s.active))
|
||||
for _, a := range s.active {
|
||||
cp := *a
|
||||
snap = append(snap, &cp)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, a := range snap {
|
||||
s.updateActiveProgress(a)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateActiveProgress(a *activeSession) {
|
||||
now := time.Now().UTC()
|
||||
if s.clock != nil {
|
||||
now = s.clock.NowUTC()
|
||||
}
|
||||
rawSecs := int(now.Sub(a.StartedAt).Seconds())
|
||||
if rawSecs < 0 {
|
||||
rawSecs = 0
|
||||
}
|
||||
billedMin := roundUpMinutes(rawSecs, s.roundMin)
|
||||
|
||||
s.mu.Lock()
|
||||
cur, ok := s.active[a.RelayUUID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
cur.LastBilledMin = billedMin
|
||||
|
||||
overageMin := 0
|
||||
if billedMin > cur.RemainingAtStart {
|
||||
overageMin = billedMin - cur.RemainingAtStart
|
||||
}
|
||||
newPhase := PhaseIncluded
|
||||
if overageMin > 0 {
|
||||
newPhase = PhaseOverage
|
||||
}
|
||||
if newPhase != cur.Phase {
|
||||
cur.Phase = newPhase
|
||||
s.mu.Unlock()
|
||||
_ = s.db.InsertBillingLedgerEntry(&db.BillingSessionLedger{
|
||||
SessionID: cur.ID,
|
||||
EventType: LedgerPhaseChange,
|
||||
Details: newPhase,
|
||||
})
|
||||
s.mu.Lock()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) finalizeSession(a *activeSession) {
|
||||
now := time.Now().UTC()
|
||||
if s.clock != nil {
|
||||
now = s.clock.NowUTC()
|
||||
}
|
||||
rawSecs := int(now.Sub(a.StartedAt).Seconds())
|
||||
if rawSecs < 0 {
|
||||
rawSecs = 0
|
||||
}
|
||||
billedMin := roundUpMinutes(rawSecs, s.roundMin)
|
||||
includedUsed := min(billedMin, a.RemainingAtStart)
|
||||
overageMin := 0
|
||||
if billedMin > a.RemainingAtStart {
|
||||
overageMin = billedMin - a.RemainingAtStart
|
||||
}
|
||||
phase := PhaseIncluded
|
||||
if overageMin > 0 {
|
||||
phase = PhaseOverage
|
||||
}
|
||||
|
||||
amountIncluded := (float64(includedUsed) / 60.0) * a.HourlyRate
|
||||
amountOverage := (float64(overageMin) / 60.0) * a.OverageRate
|
||||
status := StatusPendingReport
|
||||
if !s.requireReport {
|
||||
status = StatusClosed
|
||||
}
|
||||
|
||||
sess, err := s.db.GetBillingSession(a.ID)
|
||||
if err != nil || sess == nil {
|
||||
return
|
||||
}
|
||||
sess.EndedAt = &now
|
||||
sess.RawSeconds = rawSecs
|
||||
sess.BilledMinutes = billedMin
|
||||
sess.IncludedMinutesUsed = includedUsed
|
||||
sess.OverageMinutes = overageMin
|
||||
sess.BillingPhase = phase
|
||||
sess.AmountIncluded = amountIncluded
|
||||
sess.AmountOverage = amountOverage
|
||||
sess.Status = status
|
||||
_ = s.db.UpdateBillingSession(sess)
|
||||
_ = s.db.InsertBillingLedgerEntry(&db.BillingSessionLedger{
|
||||
SessionID: a.ID,
|
||||
EventType: LedgerSessionEnd,
|
||||
})
|
||||
|
||||
if contract, err := s.db.GetBillingOrgContract(a.ContractID); err == nil && contract != nil {
|
||||
newRemaining := contract.RemainingMinutes - includedUsed
|
||||
if newRemaining < 0 {
|
||||
newRemaining = 0
|
||||
}
|
||||
contract.RemainingMinutes = newRemaining
|
||||
_ = s.db.UpdateBillingOrgContract(contract)
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitWorkReport attaches a technician report and closes the session.
|
||||
func (s *Service) SubmitWorkReport(sessionID, operatorID, summary, category, ticketRef string) error {
|
||||
summary = trim(summary, 8000)
|
||||
if summary == "" {
|
||||
return errors.New("summary required")
|
||||
}
|
||||
sess, err := s.db.GetBillingSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.Status == StatusClosed {
|
||||
return errors.New("session already closed")
|
||||
}
|
||||
if _, err := s.db.GetBillingWorkReportBySession(sessionID); err == nil {
|
||||
return errors.New("report already submitted")
|
||||
}
|
||||
if err := s.db.CreateBillingWorkReport(&db.BillingWorkReport{
|
||||
SessionID: sessionID,
|
||||
OperatorID: operatorID,
|
||||
Summary: summary,
|
||||
Category: trim(category, 128),
|
||||
TicketRef: trim(ticketRef, 128),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
sess.Status = StatusClosed
|
||||
return s.db.UpdateBillingSession(sess)
|
||||
}
|
||||
|
||||
func roundUpMinutes(seconds, roundMin int) int {
|
||||
if roundMin <= 0 {
|
||||
roundMin = 1
|
||||
}
|
||||
mins := float64(seconds) / 60.0
|
||||
return int(math.Ceil(mins/float64(roundMin))) * roundMin
|
||||
}
|
||||
|
||||
func trim(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRoundUpMinutes(t *testing.T) {
|
||||
if got := roundUpMinutes(61, 1); got != 2 {
|
||||
t.Fatalf("got %d want 2", got)
|
||||
}
|
||||
if got := roundUpMinutes(601, 10); got != 20 {
|
||||
t.Fatalf("got %d want 20", got)
|
||||
}
|
||||
if got := roundUpMinutes(600, 10); got != 10 {
|
||||
t.Fatalf("got %d want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverageSplit(t *testing.T) {
|
||||
remaining := 5
|
||||
billed := 12
|
||||
included := min(billed, remaining)
|
||||
overage := 0
|
||||
if billed > remaining {
|
||||
overage = billed - remaining
|
||||
}
|
||||
if included != 5 || overage != 7 {
|
||||
t.Fatalf("included=%d overage=%d", included, overage)
|
||||
}
|
||||
_ = time.Now()
|
||||
}
|
||||
|
||||
func TestSessionAmountCalculation(t *testing.T) {
|
||||
includedUsed := 5
|
||||
overageMin := 7
|
||||
hourlyRate := 120.0
|
||||
overageRate := 180.0
|
||||
|
||||
amountIncluded := (float64(includedUsed) / 60.0) * hourlyRate
|
||||
amountOverage := (float64(overageMin) / 60.0) * overageRate
|
||||
total := amountIncluded + amountOverage
|
||||
|
||||
if amountIncluded != 10.0 {
|
||||
t.Fatalf("amountIncluded=%v want 10", amountIncluded)
|
||||
}
|
||||
if amountOverage != 21.0 {
|
||||
t.Fatalf("amountOverage=%v want 21", amountOverage)
|
||||
}
|
||||
if total != 31.0 {
|
||||
t.Fatalf("total=%v want 31", total)
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,13 @@ type Config struct {
|
||||
CDAPEnabled bool // Enable CDAP gateway (default false)
|
||||
CDAPTLS bool // Enable TLS on CDAP port
|
||||
CDAPRateLimit int // Max requests per minute per IP (default 30)
|
||||
|
||||
// Time sync / billing (commercialization module)
|
||||
NTPServers string // Comma-separated NTP servers
|
||||
BillingMaxClockSkewMS int // Max allowed clock offset vs NTP (default 2000)
|
||||
BillingRequireSyncedClock bool // Block billable sessions when clock unsynced
|
||||
BillingRoundingMinutes int // Billable minute rounding (1, 10, 15)
|
||||
BillingRequireWorkReport bool // Require technician report before session close
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
@@ -146,7 +153,10 @@ func DefaultConfig() *Config {
|
||||
SignalRateLimitPerIP: IPRateLimitRegistrations,
|
||||
SameNATRelay: true, // issue #121: auto-fallback to relay on shared public IP
|
||||
P2PFirst: true, // issue #157: give direct P2P a real chance before relay
|
||||
P2PFallbackMs: 2000, // grace period for target hole punch before relay fallback
|
||||
P2PFallbackMs: 2000, // grace period for target hole punch before relay fallback
|
||||
BillingMaxClockSkewMS: 2000,
|
||||
BillingRequireSyncedClock: true,
|
||||
BillingRoundingMinutes: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +343,51 @@ func (c *Config) LoadEnv() {
|
||||
c.CDAPRateLimit = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("NTP_SERVERS"); v != "" {
|
||||
c.NTPServers = v
|
||||
}
|
||||
if v := os.Getenv("BILLING_MAX_CLOCK_SKEW_MS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.BillingMaxClockSkewMS = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("BILLING_REQUIRE_SYNCED_CLOCK"); v != "" {
|
||||
switch strings.ToUpper(v) {
|
||||
case "Y", "YES", "1", "TRUE", "ON":
|
||||
c.BillingRequireSyncedClock = true
|
||||
case "N", "NO", "0", "FALSE", "OFF":
|
||||
c.BillingRequireSyncedClock = false
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("BILLING_ROUNDING_MINUTES"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.BillingRoundingMinutes = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("BILLING_REQUIRE_WORK_REPORT"); v != "" {
|
||||
switch strings.ToUpper(v) {
|
||||
case "Y", "YES", "1", "TRUE", "ON":
|
||||
c.BillingRequireWorkReport = true
|
||||
case "N", "NO", "0", "FALSE", "OFF":
|
||||
c.BillingRequireWorkReport = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetNTPServers returns configured NTP server hostnames.
|
||||
func (c *Config) GetNTPServers() []string {
|
||||
if c.NTPServers == "" {
|
||||
return []string{"pool.ntp.org", "time.google.com", "time.cloudflare.com"}
|
||||
}
|
||||
parts := strings.Split(c.NTPServers, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NATTestPort returns the NAT test port (signal port - 1).
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// BillingPackage is a reusable support-hours template.
|
||||
type BillingPackage struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
IncludedMinutes int `json:"included_minutes"`
|
||||
OverageRate float64 `json:"overage_rate"` // per hour when included pool exhausted
|
||||
Currency string `json:"currency"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BillingOrgContract links an organization to a billing package.
|
||||
type BillingOrgContract struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
PackageID string `json:"package_id"`
|
||||
Status string `json:"status"` // active, suspended, expired
|
||||
RemainingMinutes int `json:"remaining_minutes"`
|
||||
OverageRate *float64 `json:"overage_rate,omitempty"`
|
||||
HourlyRate float64 `json:"hourly_rate"` // base hourly rate for org
|
||||
Currency string `json:"currency"`
|
||||
ValidFrom *time.Time `json:"valid_from,omitempty"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PackageName string `json:"package_name,omitempty"`
|
||||
OrgName string `json:"org_name,omitempty"`
|
||||
}
|
||||
|
||||
// BillingSession is the authoritative billable remote session record.
|
||||
type BillingSession struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
ContractID string `json:"contract_id,omitempty"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name,omitempty"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
RelayUUID string `json:"relay_uuid,omitempty"`
|
||||
Transport string `json:"transport"`
|
||||
Status string `json:"status"` // active, pending_report, closed
|
||||
BillingPhase string `json:"billing_phase"` // included, overage
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
RawSeconds int `json:"raw_seconds"`
|
||||
BilledMinutes int `json:"billed_minutes"`
|
||||
IncludedMinutesUsed int `json:"included_minutes_used"`
|
||||
OverageMinutes int `json:"overage_minutes"`
|
||||
AmountIncluded float64 `json:"amount_included"`
|
||||
AmountOverage float64 `json:"amount_overage"`
|
||||
Currency string `json:"currency"`
|
||||
ClockOffsetMSAtStart int64 `json:"clock_offset_ms_at_start"`
|
||||
ClockSyncedAtStart bool `json:"clock_synced_at_start"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BillingSessionLedger records in-session billing events.
|
||||
type BillingSessionLedger struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Details string `json:"details,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// BillingWorkReport is submitted by a technician after a session.
|
||||
type BillingWorkReport struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
OperatorID string `json:"operator_id"`
|
||||
Summary string `json:"summary"`
|
||||
Category string `json:"category,omitempty"`
|
||||
TicketRef string `json:"ticket_ref,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// BillingCurrency stores manual FX rates relative to server base currency.
|
||||
type BillingCurrency struct {
|
||||
Code string `json:"code"`
|
||||
Symbol string `json:"symbol"`
|
||||
ExchangeRateToBase float64 `json:"exchange_rate_to_base"`
|
||||
}
|
||||
|
||||
// BillingSessionFilter filters session listings.
|
||||
type BillingSessionFilter struct {
|
||||
OrgID string
|
||||
DeviceID string
|
||||
Status string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// BillingContractFilter filters org contracts.
|
||||
type BillingContractFilter struct {
|
||||
OrgID string
|
||||
Status string
|
||||
}
|
||||
@@ -611,4 +611,34 @@ type Database interface {
|
||||
CreateStrategy(s *Strategy) error
|
||||
UpdateStrategy(guid string, s *Strategy) error
|
||||
DeleteStrategy(guid string) error
|
||||
|
||||
// Billing / commercialization
|
||||
CreateBillingPackage(p *BillingPackage) error
|
||||
GetBillingPackage(id string) (*BillingPackage, error)
|
||||
ListBillingPackages() ([]*BillingPackage, error)
|
||||
UpdateBillingPackage(p *BillingPackage) error
|
||||
DeleteBillingPackage(id string) error
|
||||
|
||||
CreateBillingOrgContract(c *BillingOrgContract) error
|
||||
GetBillingOrgContract(id string) (*BillingOrgContract, error)
|
||||
GetActiveBillingOrgContract(orgID string) (*BillingOrgContract, error)
|
||||
ListBillingOrgContracts(filter BillingContractFilter) ([]*BillingOrgContract, error)
|
||||
UpdateBillingOrgContract(c *BillingOrgContract) error
|
||||
|
||||
CreateBillingSession(s *BillingSession) error
|
||||
GetBillingSession(id string) (*BillingSession, error)
|
||||
GetBillingSessionByRelayUUID(relayUUID string) (*BillingSession, error)
|
||||
UpdateBillingSession(s *BillingSession) error
|
||||
ListBillingSessions(filter BillingSessionFilter) ([]*BillingSession, error)
|
||||
|
||||
InsertBillingLedgerEntry(e *BillingSessionLedger) error
|
||||
ListBillingLedger(sessionID string) ([]*BillingSessionLedger, error)
|
||||
|
||||
CreateBillingWorkReport(r *BillingWorkReport) error
|
||||
GetBillingWorkReportBySession(sessionID string) (*BillingWorkReport, error)
|
||||
ListBillingWorkReports(orgID string, limit int) ([]*BillingWorkReport, error)
|
||||
|
||||
ListBillingCurrencies() ([]*BillingCurrency, error)
|
||||
UpsertBillingCurrency(c *BillingCurrency) error
|
||||
DeleteBillingCurrency(code string) error
|
||||
}
|
||||
|
||||
@@ -347,6 +347,83 @@ func (pg *PostgresDB) Migrate() error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms(alarm_type, created_at)`,
|
||||
|
||||
// Billing / commercialization module
|
||||
`CREATE TABLE IF NOT EXISTS billing_packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
included_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overage_rate DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_org_contracts (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
package_id TEXT NOT NULL REFERENCES billing_packages(id),
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
remaining_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overage_rate DOUBLE PRECISION,
|
||||
hourly_rate DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_contracts_org ON billing_org_contracts(org_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
contract_id TEXT NOT NULL DEFAULT '',
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
operator_name TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
relay_uuid TEXT NOT NULL DEFAULT '',
|
||||
transport TEXT NOT NULL DEFAULT 'rustdesk',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
billing_phase TEXT NOT NULL DEFAULT 'included',
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
raw_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
billed_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
included_minutes_used INTEGER NOT NULL DEFAULT 0,
|
||||
overage_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
amount_included DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
amount_overage DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
clock_offset_ms_at_start BIGINT NOT NULL DEFAULT 0,
|
||||
clock_synced_at_start BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_org ON billing_sessions(org_id, started_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_relay ON billing_sessions(relay_uuid)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_session_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_ledger_session ON billing_session_ledger(session_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_work_reports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
ticket_ref TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
symbol TEXT NOT NULL DEFAULT '',
|
||||
exchange_rate_to_base DOUBLE PRECISION NOT NULL DEFAULT 1
|
||||
)`,
|
||||
|
||||
// User/device groups + strategies (API-port consolidation Phase A)
|
||||
`CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pgScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanBillingSessionPG(row pgScanner) (*BillingSession, error) {
|
||||
var sess BillingSession
|
||||
var endedAt *time.Time
|
||||
err := row.Scan(
|
||||
&sess.ID, &sess.OrgID, &sess.ContractID, &sess.OperatorID, &sess.OperatorName,
|
||||
&sess.DeviceID, &sess.DeviceName, &sess.RelayUUID, &sess.Transport, &sess.Status,
|
||||
&sess.BillingPhase, &sess.StartedAt, &endedAt, &sess.RawSeconds, &sess.BilledMinutes,
|
||||
&sess.IncludedMinutesUsed, &sess.OverageMinutes, &sess.AmountIncluded, &sess.AmountOverage,
|
||||
&sess.Currency, &sess.ClockOffsetMSAtStart, &sess.ClockSyncedAtStart, &sess.CreatedAt, &sess.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.EndedAt = endedAt
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) CreateBillingPackage(p *BillingPackage) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO billing_packages (id, name, description, included_minutes, overage_rate, currency, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
|
||||
p.ID, p.Name, p.Description, p.IncludedMinutes, p.OverageRate, p.Currency,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetBillingPackage(id string) (*BillingPackage, error) {
|
||||
var p BillingPackage
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, name, description, included_minutes, overage_rate, currency, created_at, updated_at
|
||||
FROM billing_packages WHERE id = $1`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Description, &p.IncludedMinutes, &p.OverageRate, &p.Currency, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingPackages() ([]*BillingPackage, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT id, name, description, included_minutes, overage_rate, currency, created_at, updated_at
|
||||
FROM billing_packages ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingPackage
|
||||
for rows.Next() {
|
||||
var p BillingPackage
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.IncludedMinutes, &p.OverageRate, &p.Currency, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateBillingPackage(p *BillingPackage) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE billing_packages SET name = $1, description = $2, included_minutes = $3, overage_rate = $4, currency = $5, updated_at = NOW()
|
||||
WHERE id = $6`,
|
||||
p.Name, p.Description, p.IncludedMinutes, p.OverageRate, p.Currency, p.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteBillingPackage(id string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx, `DELETE FROM billing_packages WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) CreateBillingOrgContract(c *BillingOrgContract) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO billing_org_contracts (id, org_id, package_id, status, remaining_minutes, overage_rate, hourly_rate, currency, valid_from, valid_until, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())`,
|
||||
c.ID, c.OrgID, c.PackageID, c.Status, c.RemainingMinutes, c.OverageRate, c.HourlyRate, c.Currency,
|
||||
c.ValidFrom, c.ValidUntil,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetBillingOrgContract(id string) (*BillingOrgContract, error) {
|
||||
return pg.queryBillingOrgContract(`WHERE c.id = $1`, id)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetActiveBillingOrgContract(orgID string) (*BillingOrgContract, error) {
|
||||
return pg.queryBillingOrgContract(
|
||||
`WHERE c.org_id = $1 AND c.status = 'active' ORDER BY c.updated_at DESC LIMIT 1`, orgID,
|
||||
)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) queryBillingOrgContract(where string, args ...any) (*BillingOrgContract, error) {
|
||||
query := `SELECT c.id, c.org_id, c.package_id, c.status, c.remaining_minutes, c.overage_rate,
|
||||
c.hourly_rate, c.currency, c.valid_from, c.valid_until, c.created_at, c.updated_at,
|
||||
COALESCE(p.name, ''), COALESCE(o.name, '')
|
||||
FROM billing_org_contracts c
|
||||
LEFT JOIN billing_packages p ON p.id = c.package_id
|
||||
LEFT JOIN organizations o ON o.id = c.org_id ` + where
|
||||
|
||||
var c BillingOrgContract
|
||||
var overage *float64
|
||||
var validFrom, validUntil *time.Time
|
||||
err := pg.pool.QueryRow(pg.ctx, query, args...).Scan(
|
||||
&c.ID, &c.OrgID, &c.PackageID, &c.Status, &c.RemainingMinutes, &overage,
|
||||
&c.HourlyRate, &c.Currency, &validFrom, &validUntil, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.PackageName, &c.OrgName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.OverageRate = overage
|
||||
c.ValidFrom = validFrom
|
||||
c.ValidUntil = validUntil
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingOrgContracts(filter BillingContractFilter) ([]*BillingOrgContract, error) {
|
||||
var (
|
||||
conds []string
|
||||
args []any
|
||||
)
|
||||
idx := 1
|
||||
if filter.OrgID != "" {
|
||||
conds = append(conds, "c.org_id = $"+itoa(idx))
|
||||
args = append(args, filter.OrgID)
|
||||
idx++
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conds = append(conds, "c.status = $"+itoa(idx))
|
||||
args = append(args, filter.Status)
|
||||
idx++
|
||||
}
|
||||
|
||||
query := `SELECT c.id, c.org_id, c.package_id, c.status, c.remaining_minutes, c.overage_rate,
|
||||
c.hourly_rate, c.currency, c.valid_from, c.valid_until, c.created_at, c.updated_at,
|
||||
COALESCE(p.name, ''), COALESCE(o.name, '')
|
||||
FROM billing_org_contracts c
|
||||
LEFT JOIN billing_packages p ON p.id = c.package_id
|
||||
LEFT JOIN organizations o ON o.id = c.org_id`
|
||||
if len(conds) > 0 {
|
||||
query += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query += " ORDER BY c.updated_at DESC"
|
||||
|
||||
rows, err := pg.pool.Query(pg.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingOrgContract
|
||||
for rows.Next() {
|
||||
var c BillingOrgContract
|
||||
var overage *float64
|
||||
var validFrom, validUntil *time.Time
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.OrgID, &c.PackageID, &c.Status, &c.RemainingMinutes, &overage,
|
||||
&c.HourlyRate, &c.Currency, &validFrom, &validUntil, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.PackageName, &c.OrgName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.OverageRate = overage
|
||||
c.ValidFrom = validFrom
|
||||
c.ValidUntil = validUntil
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateBillingOrgContract(c *BillingOrgContract) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE billing_org_contracts SET status = $1, remaining_minutes = $2, overage_rate = $3, hourly_rate = $4, currency = $5,
|
||||
valid_from = $6, valid_until = $7, updated_at = NOW() WHERE id = $8`,
|
||||
c.Status, c.RemainingMinutes, c.OverageRate, c.HourlyRate, c.Currency,
|
||||
c.ValidFrom, c.ValidUntil, c.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) CreateBillingSession(sess *BillingSession) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO billing_sessions (`+billingSessionCols+`)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, NOW(), NOW())`,
|
||||
sess.ID, sess.OrgID, sess.ContractID, sess.OperatorID, sess.OperatorName,
|
||||
sess.DeviceID, sess.DeviceName, sess.RelayUUID, sess.Transport, sess.Status,
|
||||
sess.BillingPhase, sess.StartedAt, sess.EndedAt,
|
||||
sess.RawSeconds, sess.BilledMinutes, sess.IncludedMinutesUsed, sess.OverageMinutes,
|
||||
sess.AmountIncluded, sess.AmountOverage, sess.Currency, sess.ClockOffsetMSAtStart, sess.ClockSyncedAtStart,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetBillingSession(id string) (*BillingSession, error) {
|
||||
row := pg.pool.QueryRow(pg.ctx, `SELECT `+billingSessionCols+` FROM billing_sessions WHERE id = $1`, id)
|
||||
return scanBillingSessionPG(row)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetBillingSessionByRelayUUID(relayUUID string) (*BillingSession, error) {
|
||||
row := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT `+billingSessionCols+` FROM billing_sessions WHERE relay_uuid = $1 ORDER BY started_at DESC LIMIT 1`,
|
||||
relayUUID,
|
||||
)
|
||||
return scanBillingSessionPG(row)
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateBillingSession(sess *BillingSession) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE billing_sessions SET status = $1, billing_phase = $2, ended_at = $3, raw_seconds = $4, billed_minutes = $5,
|
||||
included_minutes_used = $6, overage_minutes = $7, amount_included = $8, amount_overage = $9, updated_at = NOW()
|
||||
WHERE id = $10`,
|
||||
sess.Status, sess.BillingPhase, sess.EndedAt, sess.RawSeconds, sess.BilledMinutes,
|
||||
sess.IncludedMinutesUsed, sess.OverageMinutes, sess.AmountIncluded, sess.AmountOverage, sess.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingSessions(filter BillingSessionFilter) ([]*BillingSession, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var (
|
||||
conds []string
|
||||
args []any
|
||||
)
|
||||
idx := 1
|
||||
if filter.OrgID != "" {
|
||||
conds = append(conds, "org_id = $"+itoa(idx))
|
||||
args = append(args, filter.OrgID)
|
||||
idx++
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conds = append(conds, "status = $"+itoa(idx))
|
||||
args = append(args, filter.Status)
|
||||
idx++
|
||||
}
|
||||
if filter.DeviceID != "" {
|
||||
conds = append(conds, "device_id = $"+itoa(idx))
|
||||
args = append(args, filter.DeviceID)
|
||||
idx++
|
||||
}
|
||||
|
||||
query := `SELECT ` + billingSessionCols + ` FROM billing_sessions`
|
||||
if len(conds) > 0 {
|
||||
query += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query += " ORDER BY started_at DESC LIMIT $" + itoa(idx) + " OFFSET $" + itoa(idx+1)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := pg.pool.Query(pg.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingSession
|
||||
for rows.Next() {
|
||||
sess, err := scanBillingSessionPG(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sess)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) InsertBillingLedgerEntry(e *BillingSessionLedger) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO billing_session_ledger (session_id, event_type, details, created_at) VALUES ($1, $2, $3, NOW())`,
|
||||
e.SessionID, e.EventType, e.Details,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingLedger(sessionID string) ([]*BillingSessionLedger, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT id, session_id, event_type, details, created_at FROM billing_session_ledger WHERE session_id = $1 ORDER BY id`,
|
||||
sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingSessionLedger
|
||||
for rows.Next() {
|
||||
var e BillingSessionLedger
|
||||
if err := rows.Scan(&e.ID, &e.SessionID, &e.EventType, &e.Details, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) CreateBillingWorkReport(r *BillingWorkReport) error {
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`INSERT INTO billing_work_reports (session_id, operator_id, summary, category, ticket_ref, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING id`,
|
||||
r.SessionID, r.OperatorID, r.Summary, r.Category, r.TicketRef,
|
||||
).Scan(&r.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetBillingWorkReportBySession(sessionID string) (*BillingWorkReport, error) {
|
||||
var r BillingWorkReport
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, session_id, operator_id, summary, category, ticket_ref, created_at FROM billing_work_reports WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&r.ID, &r.SessionID, &r.OperatorID, &r.Summary, &r.Category, &r.TicketRef, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingWorkReports(orgID string, limit int) ([]*BillingWorkReport, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
query := `SELECT w.id, w.session_id, w.operator_id, w.summary, w.category, w.ticket_ref, w.created_at
|
||||
FROM billing_work_reports w`
|
||||
var args []any
|
||||
if orgID != "" {
|
||||
query += ` INNER JOIN billing_sessions s ON s.id = w.session_id WHERE s.org_id = $1`
|
||||
args = append(args, orgID)
|
||||
query += ` ORDER BY w.id DESC LIMIT $2`
|
||||
args = append(args, limit)
|
||||
} else {
|
||||
query += ` ORDER BY w.id DESC LIMIT $1`
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := pg.pool.Query(pg.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingWorkReport
|
||||
for rows.Next() {
|
||||
var r BillingWorkReport
|
||||
if err := rows.Scan(&r.ID, &r.SessionID, &r.OperatorID, &r.Summary, &r.Category, &r.TicketRef, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListBillingCurrencies() ([]*BillingCurrency, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT code, symbol, exchange_rate_to_base FROM billing_currencies ORDER BY code`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*BillingCurrency
|
||||
for rows.Next() {
|
||||
var c BillingCurrency
|
||||
if err := rows.Scan(&c.Code, &c.Symbol, &c.ExchangeRateToBase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpsertBillingCurrency(c *BillingCurrency) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO billing_currencies (code, symbol, exchange_rate_to_base) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (code) DO UPDATE SET symbol = EXCLUDED.symbol, exchange_rate_to_base = EXCLUDED.exchange_rate_to_base`,
|
||||
c.Code, c.Symbol, c.ExchangeRateToBase,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteBillingCurrency(code string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx, `DELETE FROM billing_currencies WHERE code = $1`, strings.ToUpper(code))
|
||||
return err
|
||||
}
|
||||
@@ -327,6 +327,85 @@ func (s *SQLiteDB) Migrate() error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_alarms_type ON audit_alarms(alarm_type, created_at)`,
|
||||
|
||||
// Billing / commercialization module
|
||||
`CREATE TABLE IF NOT EXISTS billing_packages (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
included_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overage_rate REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_org_contracts (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
package_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
remaining_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
overage_rate REAL,
|
||||
hourly_rate REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
valid_from TEXT,
|
||||
valid_until TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (package_id) REFERENCES billing_packages(id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_contracts_org ON billing_org_contracts(org_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
contract_id TEXT NOT NULL DEFAULT '',
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
operator_name TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
relay_uuid TEXT NOT NULL DEFAULT '',
|
||||
transport TEXT NOT NULL DEFAULT 'rustdesk',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
billing_phase TEXT NOT NULL DEFAULT 'included',
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
raw_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
billed_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
included_minutes_used INTEGER NOT NULL DEFAULT 0,
|
||||
overage_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
amount_included REAL NOT NULL DEFAULT 0,
|
||||
amount_overage REAL NOT NULL DEFAULT 0,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
clock_offset_ms_at_start INTEGER NOT NULL DEFAULT 0,
|
||||
clock_synced_at_start INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_org ON billing_sessions(org_id, started_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_sessions_relay ON billing_sessions(relay_uuid)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_session_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_ledger_session ON billing_session_ledger(session_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_work_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
operator_id TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL,
|
||||
category TEXT DEFAULT '',
|
||||
ticket_ref TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS billing_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
symbol TEXT NOT NULL DEFAULT '',
|
||||
exchange_rate_to_base REAL NOT NULL DEFAULT 1
|
||||
)`,
|
||||
|
||||
// User/device groups + strategies (API-port consolidation Phase A)
|
||||
`CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -421,6 +500,19 @@ func (s *SQLiteDB) Migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Billing: backfill NULL optional text columns (SQLite cannot ALTER to add NOT NULL).
|
||||
billingNullBackfills := []string{
|
||||
`UPDATE billing_sessions SET contract_id = '' WHERE contract_id IS NULL`,
|
||||
`UPDATE billing_sessions SET operator_name = '' WHERE operator_name IS NULL`,
|
||||
`UPDATE billing_sessions SET device_name = '' WHERE device_name IS NULL`,
|
||||
`UPDATE billing_sessions SET relay_uuid = '' WHERE relay_uuid IS NULL`,
|
||||
}
|
||||
for _, stmt := range billingNullBackfills {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("db: billing null backfill failed: %w\nStatement: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseSQLiteTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func scanBillingSession(row scanner) (*BillingSession, error) {
|
||||
var sess BillingSession
|
||||
var startedAt, endedAt, createdAt, updatedAt string
|
||||
var clockSynced int
|
||||
err := row.Scan(
|
||||
&sess.ID, &sess.OrgID, &sess.ContractID, &sess.OperatorID, &sess.OperatorName,
|
||||
&sess.DeviceID, &sess.DeviceName, &sess.RelayUUID, &sess.Transport, &sess.Status,
|
||||
&sess.BillingPhase, &startedAt, &endedAt, &sess.RawSeconds, &sess.BilledMinutes,
|
||||
&sess.IncludedMinutesUsed, &sess.OverageMinutes, &sess.AmountIncluded, &sess.AmountOverage,
|
||||
&sess.Currency, &sess.ClockOffsetMSAtStart, &clockSynced, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.StartedAt = parseSQLiteTime(startedAt)
|
||||
if endedAt != "" {
|
||||
t := parseSQLiteTime(endedAt)
|
||||
sess.EndedAt = &t
|
||||
}
|
||||
sess.ClockSyncedAtStart = clockSynced != 0
|
||||
sess.CreatedAt = parseSQLiteTime(createdAt)
|
||||
sess.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
const billingSessionCols = `id, org_id, contract_id, operator_id, operator_name, device_id, device_name,
|
||||
relay_uuid, transport, status, billing_phase, started_at, ended_at, raw_seconds, billed_minutes,
|
||||
included_minutes_used, overage_minutes, amount_included, amount_overage, currency,
|
||||
clock_offset_ms_at_start, clock_synced_at_start, created_at, updated_at`
|
||||
|
||||
func (s *SQLiteDB) CreateBillingPackage(p *BillingPackage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO billing_packages (id, name, description, included_minutes, overage_rate, currency, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
||||
p.ID, p.Name, p.Description, p.IncludedMinutes, p.OverageRate, p.Currency,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetBillingPackage(id string) (*BillingPackage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var p BillingPackage
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, description, included_minutes, overage_rate, currency, created_at, updated_at
|
||||
FROM billing_packages WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Description, &p.IncludedMinutes, &p.OverageRate, &p.Currency, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt = parseSQLiteTime(createdAt)
|
||||
p.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingPackages() ([]*BillingPackage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, description, included_minutes, overage_rate, currency, created_at, updated_at
|
||||
FROM billing_packages ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingPackage
|
||||
for rows.Next() {
|
||||
var p BillingPackage
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.IncludedMinutes, &p.OverageRate, &p.Currency, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.CreatedAt = parseSQLiteTime(createdAt)
|
||||
p.UpdatedAt = parseSQLiteTime(updatedAt)
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateBillingPackage(p *BillingPackage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE billing_packages SET name=?, description=?, included_minutes=?, overage_rate=?, currency=?, updated_at=datetime('now') WHERE id=?`,
|
||||
p.Name, p.Description, p.IncludedMinutes, p.OverageRate, p.Currency, p.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteBillingPackage(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`DELETE FROM billing_packages WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func formatSQLiteTimePtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) CreateBillingOrgContract(c *BillingOrgContract) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var overage sql.NullFloat64
|
||||
if c.OverageRate != nil {
|
||||
overage = sql.NullFloat64{Float64: *c.OverageRate, Valid: true}
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO billing_org_contracts (id, org_id, package_id, status, remaining_minutes, overage_rate, hourly_rate, currency, valid_from, valid_until, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
||||
c.ID, c.OrgID, c.PackageID, c.Status, c.RemainingMinutes, overage, c.HourlyRate, c.Currency,
|
||||
formatSQLiteTimePtr(c.ValidFrom), formatSQLiteTimePtr(c.ValidUntil),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetBillingOrgContract(id string) (*BillingOrgContract, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.queryBillingOrgContract(`WHERE c.id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetActiveBillingOrgContract(orgID string) (*BillingOrgContract, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.queryBillingOrgContract(
|
||||
`WHERE c.org_id = ? AND c.status = 'active' ORDER BY c.updated_at DESC LIMIT 1`, orgID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) queryBillingOrgContract(where string, args ...any) (*BillingOrgContract, error) {
|
||||
query := `SELECT c.id, c.org_id, c.package_id, c.status, c.remaining_minutes, c.overage_rate,
|
||||
c.hourly_rate, c.currency, c.valid_from, c.valid_until, c.created_at, c.updated_at,
|
||||
COALESCE(p.name, ''), COALESCE(o.name, '')
|
||||
FROM billing_org_contracts c
|
||||
LEFT JOIN billing_packages p ON p.id = c.package_id
|
||||
LEFT JOIN organizations o ON o.id = c.org_id ` + where
|
||||
|
||||
var c BillingOrgContract
|
||||
var overage sql.NullFloat64
|
||||
var validFrom, validUntil, createdAt, updatedAt sql.NullString
|
||||
err := s.db.QueryRow(query, args...).Scan(
|
||||
&c.ID, &c.OrgID, &c.PackageID, &c.Status, &c.RemainingMinutes, &overage,
|
||||
&c.HourlyRate, &c.Currency, &validFrom, &validUntil, &createdAt, &updatedAt,
|
||||
&c.PackageName, &c.OrgName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if overage.Valid {
|
||||
v := overage.Float64
|
||||
c.OverageRate = &v
|
||||
}
|
||||
if validFrom.Valid {
|
||||
t := parseSQLiteTime(validFrom.String)
|
||||
c.ValidFrom = &t
|
||||
}
|
||||
if validUntil.Valid {
|
||||
t := parseSQLiteTime(validUntil.String)
|
||||
c.ValidUntil = &t
|
||||
}
|
||||
c.CreatedAt = parseSQLiteTime(createdAt.String)
|
||||
c.UpdatedAt = parseSQLiteTime(updatedAt.String)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingOrgContracts(filter BillingContractFilter) ([]*BillingOrgContract, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var conds []string
|
||||
var args []any
|
||||
if filter.OrgID != "" {
|
||||
conds = append(conds, "c.org_id = ?")
|
||||
args = append(args, filter.OrgID)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conds = append(conds, "c.status = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
query := `SELECT c.id, c.org_id, c.package_id, c.status, c.remaining_minutes, c.overage_rate,
|
||||
c.hourly_rate, c.currency, c.valid_from, c.valid_until, c.created_at, c.updated_at,
|
||||
COALESCE(p.name, ''), COALESCE(o.name, '')
|
||||
FROM billing_org_contracts c
|
||||
LEFT JOIN billing_packages p ON p.id = c.package_id
|
||||
LEFT JOIN organizations o ON o.id = c.org_id`
|
||||
if len(conds) > 0 {
|
||||
query += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query += " ORDER BY c.updated_at DESC"
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingOrgContract
|
||||
for rows.Next() {
|
||||
var c BillingOrgContract
|
||||
var overage sql.NullFloat64
|
||||
var validFrom, validUntil, createdAt, updatedAt sql.NullString
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.OrgID, &c.PackageID, &c.Status, &c.RemainingMinutes, &overage,
|
||||
&c.HourlyRate, &c.Currency, &validFrom, &validUntil, &createdAt, &updatedAt,
|
||||
&c.PackageName, &c.OrgName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if overage.Valid {
|
||||
v := overage.Float64
|
||||
c.OverageRate = &v
|
||||
}
|
||||
if validFrom.Valid {
|
||||
t := parseSQLiteTime(validFrom.String)
|
||||
c.ValidFrom = &t
|
||||
}
|
||||
if validUntil.Valid {
|
||||
t := parseSQLiteTime(validUntil.String)
|
||||
c.ValidUntil = &t
|
||||
}
|
||||
c.CreatedAt = parseSQLiteTime(createdAt.String)
|
||||
c.UpdatedAt = parseSQLiteTime(updatedAt.String)
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateBillingOrgContract(c *BillingOrgContract) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var overage sql.NullFloat64
|
||||
if c.OverageRate != nil {
|
||||
overage = sql.NullFloat64{Float64: *c.OverageRate, Valid: true}
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE billing_org_contracts SET status=?, remaining_minutes=?, overage_rate=?, hourly_rate=?, currency=?, valid_from=?, valid_until=?, updated_at=datetime('now') WHERE id=?`,
|
||||
c.Status, c.RemainingMinutes, overage, c.HourlyRate, c.Currency,
|
||||
formatSQLiteTimePtr(c.ValidFrom), formatSQLiteTimePtr(c.ValidUntil), c.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) CreateBillingSession(sess *BillingSession) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
synced := 0
|
||||
if sess.ClockSyncedAtStart {
|
||||
synced = 1
|
||||
}
|
||||
var ended any
|
||||
if sess.EndedAt != nil {
|
||||
ended = sess.EndedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO billing_sessions (`+billingSessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
sess.ID, sess.OrgID, sess.ContractID, sess.OperatorID, sess.OperatorName,
|
||||
sess.DeviceID, sess.DeviceName, sess.RelayUUID, sess.Transport, sess.Status,
|
||||
sess.BillingPhase, sess.StartedAt.UTC().Format("2006-01-02 15:04:05"), ended,
|
||||
sess.RawSeconds, sess.BilledMinutes, sess.IncludedMinutesUsed, sess.OverageMinutes,
|
||||
sess.AmountIncluded, sess.AmountOverage, sess.Currency, sess.ClockOffsetMSAtStart, synced,
|
||||
now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetBillingSession(id string) (*BillingSession, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
row := s.db.QueryRow(`SELECT `+billingSessionCols+` FROM billing_sessions WHERE id = ?`, id)
|
||||
return scanBillingSession(row)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetBillingSessionByRelayUUID(relayUUID string) (*BillingSession, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
row := s.db.QueryRow(`SELECT `+billingSessionCols+` FROM billing_sessions WHERE relay_uuid = ? ORDER BY started_at DESC LIMIT 1`, relayUUID)
|
||||
return scanBillingSession(row)
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateBillingSession(sess *BillingSession) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var ended any
|
||||
if sess.EndedAt != nil {
|
||||
ended = sess.EndedAt.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE billing_sessions SET status=?, billing_phase=?, ended_at=?, raw_seconds=?, billed_minutes=?,
|
||||
included_minutes_used=?, overage_minutes=?, amount_included=?, amount_overage=?, updated_at=datetime('now')
|
||||
WHERE id=?`,
|
||||
sess.Status, sess.BillingPhase, ended, sess.RawSeconds, sess.BilledMinutes,
|
||||
sess.IncludedMinutesUsed, sess.OverageMinutes, sess.AmountIncluded, sess.AmountOverage, sess.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingSessions(filter BillingSessionFilter) ([]*BillingSession, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
var conds []string
|
||||
var args []any
|
||||
if filter.OrgID != "" {
|
||||
conds = append(conds, "org_id = ?")
|
||||
args = append(args, filter.OrgID)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conds = append(conds, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.DeviceID != "" {
|
||||
conds = append(conds, "device_id = ?")
|
||||
args = append(args, filter.DeviceID)
|
||||
}
|
||||
query := `SELECT ` + billingSessionCols + ` FROM billing_sessions`
|
||||
if len(conds) > 0 {
|
||||
query += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingSession
|
||||
for rows.Next() {
|
||||
sess, err := scanBillingSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sess)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) InsertBillingLedgerEntry(e *BillingSessionLedger) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO billing_session_ledger (session_id, event_type, details, created_at) VALUES (?, ?, ?, datetime('now'))`,
|
||||
e.SessionID, e.EventType, e.Details,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingLedger(sessionID string) ([]*BillingSessionLedger, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, session_id, event_type, details, created_at FROM billing_session_ledger WHERE session_id = ? ORDER BY id`,
|
||||
sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingSessionLedger
|
||||
for rows.Next() {
|
||||
var e BillingSessionLedger
|
||||
var createdAt string
|
||||
if err := rows.Scan(&e.ID, &e.SessionID, &e.EventType, &e.Details, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = parseSQLiteTime(createdAt)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) CreateBillingWorkReport(r *BillingWorkReport) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO billing_work_reports (session_id, operator_id, summary, category, ticket_ref, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||
r.SessionID, r.OperatorID, r.Summary, r.Category, r.TicketRef,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetBillingWorkReportBySession(sessionID string) (*BillingWorkReport, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var r BillingWorkReport
|
||||
var createdAt string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, session_id, operator_id, summary, category, ticket_ref, created_at FROM billing_work_reports WHERE session_id = ?`,
|
||||
sessionID,
|
||||
).Scan(&r.ID, &r.SessionID, &r.OperatorID, &r.Summary, &r.Category, &r.TicketRef, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt = parseSQLiteTime(createdAt)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingWorkReports(orgID string, limit int) ([]*BillingWorkReport, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
query := `SELECT w.id, w.session_id, w.operator_id, w.summary, w.category, w.ticket_ref, w.created_at
|
||||
FROM billing_work_reports w`
|
||||
var args []any
|
||||
if orgID != "" {
|
||||
query += ` INNER JOIN billing_sessions s ON s.id = w.session_id WHERE s.org_id = ?`
|
||||
args = append(args, orgID)
|
||||
}
|
||||
query += ` ORDER BY w.id DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingWorkReport
|
||||
for rows.Next() {
|
||||
var r BillingWorkReport
|
||||
var createdAt string
|
||||
if err := rows.Scan(&r.ID, &r.SessionID, &r.OperatorID, &r.Summary, &r.Category, &r.TicketRef, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CreatedAt = parseSQLiteTime(createdAt)
|
||||
out = append(out, &r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListBillingCurrencies() ([]*BillingCurrency, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`SELECT code, symbol, exchange_rate_to_base FROM billing_currencies ORDER BY code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BillingCurrency
|
||||
for rows.Next() {
|
||||
var c BillingCurrency
|
||||
if err := rows.Scan(&c.Code, &c.Symbol, &c.ExchangeRateToBase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpsertBillingCurrency(c *BillingCurrency) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO billing_currencies (code, symbol, exchange_rate_to_base) VALUES (?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET symbol=excluded.symbol, exchange_rate_to_base=excluded.exchange_rate_to_base`,
|
||||
c.Code, c.Symbol, c.ExchangeRateToBase,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteBillingCurrency(code string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`DELETE FROM billing_currencies WHERE code = ?`, strings.ToUpper(code))
|
||||
return err
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/unitronix/betterdesk-server/api"
|
||||
"github.com/unitronix/betterdesk-server/audit"
|
||||
"github.com/unitronix/betterdesk-server/auth"
|
||||
"github.com/unitronix/betterdesk-server/billing"
|
||||
"github.com/unitronix/betterdesk-server/cdap"
|
||||
"github.com/unitronix/betterdesk-server/config"
|
||||
"github.com/unitronix/betterdesk-server/crypto"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/unitronix/betterdesk-server/reload"
|
||||
"github.com/unitronix/betterdesk-server/security"
|
||||
sigServer "github.com/unitronix/betterdesk-server/signal"
|
||||
"github.com/unitronix/betterdesk-server/timesync"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -274,6 +276,20 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Time sync + billing (commercialization module)
|
||||
timeSyncSvc := timesync.NewService(database, timesync.Config{
|
||||
Servers: cfg.GetNTPServers(),
|
||||
Interval: 60 * time.Second,
|
||||
QueryTimeout: 5 * time.Second,
|
||||
MaxSkew: time.Duration(cfg.BillingMaxClockSkewMS) * time.Millisecond,
|
||||
RequireSync: cfg.BillingRequireSyncedClock,
|
||||
})
|
||||
timeSyncSvc.Start(ctx)
|
||||
defer timeSyncSvc.Stop()
|
||||
|
||||
billingSvc := billing.NewService(database, timeSyncSvc, cfg.BillingRoundingMinutes, cfg.BillingRequireWorkReport)
|
||||
billingSvc.Start(ctx)
|
||||
|
||||
// Start SIGHUP listener in background
|
||||
reloadDone := make(chan struct{})
|
||||
go reloadHandler.ListenSIGHUP(reloadDone)
|
||||
@@ -295,6 +311,7 @@ func main() {
|
||||
sig.SetBlocklist(blocklist)
|
||||
sig.SetRateLimiter(ipLimiter)
|
||||
sig.SetAuditLogger(auditLogger)
|
||||
sig.SetBillingService(billingSvc)
|
||||
if err := sig.Start(ctx); err != nil {
|
||||
log.Fatalf("Failed to start signal server: %v", err)
|
||||
}
|
||||
@@ -305,6 +322,7 @@ func main() {
|
||||
if connLimiter != nil {
|
||||
relaySrv.SetConnLimiter(connLimiter)
|
||||
}
|
||||
relaySrv.SetBillingCallbacks(billingSvc.ActivateRelay, billingSvc.EndRelay)
|
||||
if err := relaySrv.Start(ctx); err != nil {
|
||||
log.Fatalf("Failed to start relay server: %v", err)
|
||||
}
|
||||
@@ -319,6 +337,8 @@ func main() {
|
||||
apiSrv.SetMetrics(mc)
|
||||
apiSrv.SetJWTManager(jwtManager)
|
||||
apiSrv.SetKeyPair(kp)
|
||||
apiSrv.SetTimeSyncService(timeSyncSvc)
|
||||
apiSrv.SetBillingService(billingSvc)
|
||||
|
||||
// LDAP provider (loads config from DB, hot-reloadable via API)
|
||||
apiSrv.InitLDAP()
|
||||
|
||||
@@ -39,6 +39,9 @@ type Server struct {
|
||||
// Stats
|
||||
ActiveSessions atomic.Int64
|
||||
TotalRelayed atomic.Int64
|
||||
|
||||
onRelayStart func(uuid string)
|
||||
onRelayEnd func(uuid string)
|
||||
}
|
||||
|
||||
// Indirection for testing.
|
||||
@@ -69,6 +72,12 @@ func (s *Server) SetConnLimiter(cl *ratelimit.ConnLimiter) {
|
||||
s.connLimiter = cl
|
||||
}
|
||||
|
||||
// SetBillingCallbacks registers hooks when relay sessions start/end (commercialization).
|
||||
func (s *Server) SetBillingCallbacks(onStart, onEnd func(uuid string)) {
|
||||
s.onRelayStart = onStart
|
||||
s.onRelayEnd = onEnd
|
||||
}
|
||||
|
||||
// Start launches the relay TCP listener.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
@@ -230,6 +239,10 @@ func (s *Server) startRelay(conn1, conn2 net.Conn, uuid string) {
|
||||
log.Printf("[relay] Pair established: %s <-> %s (UUID: %s)",
|
||||
conn1.RemoteAddr(), conn2.RemoteAddr(), uuid)
|
||||
|
||||
if s.onRelayStart != nil {
|
||||
s.onRelayStart(uuid)
|
||||
}
|
||||
|
||||
// NOTE: Do NOT send RelayResponse confirmation to clients here.
|
||||
// The RustDesk client's create_relay() does not read any response from
|
||||
// the relay server after sending RequestRelay. The client's
|
||||
@@ -280,6 +293,10 @@ func (s *Server) startRelay(conn1, conn2 net.Conn, uuid string) {
|
||||
// Wait for one direction to finish, then clean up both
|
||||
<-done
|
||||
|
||||
if s.onRelayEnd != nil {
|
||||
s.onRelayEnd(uuid)
|
||||
}
|
||||
|
||||
conn1.Close()
|
||||
conn2.Close()
|
||||
|
||||
|
||||
@@ -553,6 +553,21 @@ func (s *Server) handlePunchHoleRequest(msg *pb.PunchHoleRequest, raddr *net.UDP
|
||||
return
|
||||
}
|
||||
|
||||
if s.billing != nil {
|
||||
if check := s.billing.CheckConnection(targetID); !check.Allowed {
|
||||
log.Printf("[signal] PunchHole: billing denied for target %s: %s", targetID, check.Reason)
|
||||
resp := &pb.RendezvousMessage{
|
||||
Union: &pb.RendezvousMessage_PunchHoleResponse{
|
||||
PunchHoleResponse: &pb.PunchHoleResponse{
|
||||
Failure: pb.PunchHoleResponse_OFFLINE,
|
||||
},
|
||||
},
|
||||
}
|
||||
s.sendUDP(resp, raddr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
relayServer, sameNetwork, hairpin := s.selectPeerRelayServer(s.getRelayServer(), raddr, target.UDPAddr)
|
||||
initiatorID := s.peerIDForAddr(raddr)
|
||||
relayServer = s.applyNetworkRelayPolicy(relayServer, initiatorID, targetID)
|
||||
@@ -1025,10 +1040,39 @@ func (s *Server) handleRequestRelay(msg *pb.RequestRelay, raddr *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
|
||||
initiatorID := s.peerIDForAddr(raddr)
|
||||
if s.billing != nil {
|
||||
if check := s.billing.CheckConnection(targetID); !check.Allowed {
|
||||
log.Printf("[signal] RequestRelay: billing denied for target %s: %s", targetID, check.Reason)
|
||||
resp := &pb.RendezvousMessage{
|
||||
Union: &pb.RendezvousMessage_RelayResponse{
|
||||
RelayResponse: &pb.RelayResponse{
|
||||
RefuseReason: "Billing suspended",
|
||||
RelayServer: relayServer,
|
||||
},
|
||||
},
|
||||
}
|
||||
s.sendUDP(resp, raddr)
|
||||
return
|
||||
}
|
||||
if err := s.billing.PrepareRelay(relayUUID, targetID, initiatorID); err != nil {
|
||||
log.Printf("[signal] RequestRelay: billing prepare failed: %v", err)
|
||||
resp := &pb.RendezvousMessage{
|
||||
Union: &pb.RendezvousMessage_RelayResponse{
|
||||
RelayResponse: &pb.RelayResponse{
|
||||
RefuseReason: "Billing blocked",
|
||||
RelayServer: relayServer,
|
||||
},
|
||||
},
|
||||
}
|
||||
s.sendUDP(resp, raddr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// LAN detection: use server's LAN IP only for genuine LAN cases. Shared
|
||||
// public IP peers keep the public relay to avoid NAT hairpin failures (#121).
|
||||
relayServer, sameNetwork, hairpin := s.selectPeerRelayServer(relayServer, raddr, target.UDPAddr)
|
||||
initiatorID := s.peerIDForAddr(raddr)
|
||||
relayServer = s.applyNetworkRelayPolicy(relayServer, initiatorID, targetID)
|
||||
if sameNetwork {
|
||||
log.Printf("[signal] RequestRelay LAN detected: %s and %s on same network, relay=%s", raddr.IP, target.UDPAddr.IP, relayServer)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/audit"
|
||||
"github.com/unitronix/betterdesk-server/billing"
|
||||
"github.com/unitronix/betterdesk-server/codec"
|
||||
"github.com/unitronix/betterdesk-server/config"
|
||||
"github.com/unitronix/betterdesk-server/crypto"
|
||||
@@ -117,6 +118,8 @@ type Server struct {
|
||||
|
||||
// networkPolicy resolves org-level block_direct_p2p / allowed relay servers.
|
||||
networkPolicy *policy.NetworkResolver
|
||||
|
||||
billing *billing.Service
|
||||
}
|
||||
|
||||
// New creates a new signal server instance.
|
||||
@@ -148,6 +151,11 @@ func (s *Server) SetAuditLogger(l *audit.Logger) {
|
||||
s.auditLog = l
|
||||
}
|
||||
|
||||
// SetBillingService attaches commercialization billing gates to signal handling.
|
||||
func (s *Server) SetBillingService(b *billing.Service) {
|
||||
s.billing = b
|
||||
}
|
||||
|
||||
// PeerMap returns the server's in-memory peer map for external access (e.g., API).
|
||||
func (s *Server) PeerMap() *peer.Map {
|
||||
return s.peers
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package timesync monitors server clock accuracy against NTP sources.
|
||||
package timesync
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ntpEpochOffset = 2208988800 // seconds between 1900-01-01 and 1970-01-01
|
||||
|
||||
// QueryNTP performs a single NTP v4 client query and returns the estimated
|
||||
// offset between the local clock and the remote server (local - remote).
|
||||
func QueryNTP(server string, timeout time.Duration) (offset time.Duration, stratum uint8, err error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(server, "123"))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("timesync: resolve %s: %w", server, err)
|
||||
}
|
||||
|
||||
conn, err := net.DialUDP("udp", nil, addr)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("timesync: dial %s: %w", server, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
// NTP request: LI=0, VN=4, Mode=3 (client)
|
||||
req := make([]byte, 48)
|
||||
req[0] = 0x23
|
||||
|
||||
t1 := time.Now()
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return 0, 0, fmt.Errorf("timesync: write: %w", err)
|
||||
}
|
||||
|
||||
resp := make([]byte, 48)
|
||||
n, err := conn.Read(resp)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("timesync: read: %w", err)
|
||||
}
|
||||
if n < 48 {
|
||||
return 0, 0, errors.New("timesync: short NTP response")
|
||||
}
|
||||
|
||||
t4 := time.Now()
|
||||
stratum = resp[1]
|
||||
|
||||
t2 := ntpTimestampToTime(resp[32:40])
|
||||
t3 := ntpTimestampToTime(resp[40:48])
|
||||
if t2.IsZero() || t3.IsZero() {
|
||||
return 0, stratum, errors.New("timesync: invalid NTP timestamps")
|
||||
}
|
||||
|
||||
// Standard NTP offset: ((t2 - t1) + (t3 - t4)) / 2
|
||||
offset = (t2.Sub(t1) + t3.Sub(t4)) / 2
|
||||
return offset, stratum, nil
|
||||
}
|
||||
|
||||
func ntpTimestampToTime(b []byte) time.Time {
|
||||
if len(b) < 8 {
|
||||
return time.Time{}
|
||||
}
|
||||
secs := binary.BigEndian.Uint32(b[0:4])
|
||||
frac := binary.BigEndian.Uint32(b[4:8])
|
||||
if secs == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
unix := int64(secs) - ntpEpochOffset
|
||||
nano := (int64(frac) * 1e9) >> 32
|
||||
return time.Unix(unix, nano).UTC()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package timesync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNtpTimestampToTime(t *testing.T) {
|
||||
// 2024-01-01 00:00:00 UTC ≈ NTP seconds since 1900
|
||||
b := make([]byte, 8)
|
||||
secs := uint32(1704067200 + ntpEpochOffset)
|
||||
b[0] = byte(secs >> 24)
|
||||
b[1] = byte(secs >> 16)
|
||||
b[2] = byte(secs >> 8)
|
||||
b[3] = byte(secs)
|
||||
got := ntpTimestampToTime(b)
|
||||
if got.IsZero() {
|
||||
t.Fatal("expected non-zero time")
|
||||
}
|
||||
if got.UTC().Year() != 2024 {
|
||||
t.Fatalf("year = %d", got.UTC().Year())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryNTPInvalidServer(t *testing.T) {
|
||||
_, _, err := QueryNTP("127.0.0.1:1", 200*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable NTP")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build linux
|
||||
|
||||
package timesync
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readOSClockSynced() *bool {
|
||||
out, err := exec.Command("timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
if v == "yes" {
|
||||
t := true
|
||||
return &t
|
||||
}
|
||||
if v == "no" {
|
||||
f := false
|
||||
return &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package timesync
|
||||
|
||||
func readOSClockSynced() *bool {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package timesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
)
|
||||
|
||||
// Status is the last known clock synchronization state.
|
||||
type Status struct {
|
||||
Synced bool `json:"synced"`
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
LastCheckAt time.Time `json:"last_check_at"`
|
||||
NTPServer string `json:"ntp_server,omitempty"`
|
||||
Stratum uint8 `json:"stratum,omitempty"`
|
||||
MaxSkewMS int64 `json:"max_skew_ms"`
|
||||
RequireSync bool `json:"require_sync_for_billing"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
OSClockSynced *bool `json:"os_clock_synced,omitempty"`
|
||||
}
|
||||
|
||||
// Config controls periodic NTP checks.
|
||||
type Config struct {
|
||||
Servers []string
|
||||
Interval time.Duration
|
||||
QueryTimeout time.Duration
|
||||
MaxSkew time.Duration
|
||||
RequireSync bool
|
||||
}
|
||||
|
||||
// Service periodically queries NTP and exposes clock health for billing.
|
||||
type Service struct {
|
||||
cfg Config
|
||||
db db.Database
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a time sync monitor.
|
||||
func NewService(database db.Database, cfg Config) *Service {
|
||||
if len(cfg.Servers) == 0 {
|
||||
cfg.Servers = []string{"pool.ntp.org", "time.google.com", "time.cloudflare.com"}
|
||||
}
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = 60 * time.Second
|
||||
}
|
||||
if cfg.QueryTimeout <= 0 {
|
||||
cfg.QueryTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.MaxSkew <= 0 {
|
||||
cfg.MaxSkew = 2 * time.Second
|
||||
}
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
db: database,
|
||||
status: Status{
|
||||
MaxSkewMS: cfg.MaxSkew.Milliseconds(),
|
||||
RequireSync: cfg.RequireSync,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic NTP checks.
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
if s.cancel != nil {
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.wg.Add(1)
|
||||
go s.loop(runCtx)
|
||||
s.CheckNow()
|
||||
}
|
||||
|
||||
// Stop stops background checks.
|
||||
func (s *Service) Stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
s.cancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) loop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.CheckNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckNow runs an immediate NTP check against configured servers.
|
||||
func (s *Service) CheckNow() Status {
|
||||
var (
|
||||
bestOffset time.Duration
|
||||
bestServer string
|
||||
bestStratum uint8
|
||||
lastErr error
|
||||
found bool
|
||||
)
|
||||
|
||||
for _, server := range s.cfg.Servers {
|
||||
offset, stratum, err := QueryNTP(server, s.cfg.QueryTimeout)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[timesync] NTP query %s failed: %v", server, err)
|
||||
continue
|
||||
}
|
||||
if !found || absDuration(offset) < absDuration(bestOffset) {
|
||||
bestOffset = offset
|
||||
bestServer = server
|
||||
bestStratum = stratum
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
st := Status{
|
||||
LastCheckAt: time.Now().UTC(),
|
||||
MaxSkewMS: s.cfg.MaxSkew.Milliseconds(),
|
||||
RequireSync: s.cfg.RequireSync,
|
||||
}
|
||||
if osSync := readOSClockSynced(); osSync != nil {
|
||||
st.OSClockSynced = osSync
|
||||
}
|
||||
|
||||
if !found {
|
||||
st.Synced = false
|
||||
if lastErr != nil {
|
||||
st.LastError = lastErr.Error()
|
||||
} else {
|
||||
st.LastError = "no NTP servers responded"
|
||||
}
|
||||
s.setStatus(st)
|
||||
s.persistStatus(st)
|
||||
return st
|
||||
}
|
||||
|
||||
offsetMS := bestOffset.Milliseconds()
|
||||
st.OffsetMS = offsetMS
|
||||
st.NTPServer = bestServer
|
||||
st.Stratum = bestStratum
|
||||
st.Synced = absDuration(bestOffset) <= s.cfg.MaxSkew
|
||||
if !st.Synced {
|
||||
st.LastError = "clock skew exceeds threshold"
|
||||
}
|
||||
|
||||
s.setStatus(st)
|
||||
s.persistStatus(st)
|
||||
if !st.Synced {
|
||||
log.Printf("[timesync] WARNING: clock skew %dms (max %dms) via %s",
|
||||
offsetMS, s.cfg.MaxSkew.Milliseconds(), bestServer)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (s *Service) setStatus(st Status) {
|
||||
s.mu.Lock()
|
||||
s.status = st
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) persistStatus(st Status) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.db.SetConfig("timesync_last_status", string(b))
|
||||
}
|
||||
|
||||
// GetStatus returns the cached synchronization state.
|
||||
func (s *Service) GetStatus() Status {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
// IsSynced reports whether the server clock is within configured skew.
|
||||
func (s *Service) IsSynced() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.cfg.RequireSync && !s.status.Synced {
|
||||
return false
|
||||
}
|
||||
return s.status.Synced
|
||||
}
|
||||
|
||||
// OffsetAtCheck returns the last measured offset in milliseconds.
|
||||
func (s *Service) OffsetMS() int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status.OffsetMS
|
||||
}
|
||||
|
||||
// NowUTC returns the current UTC time used for billing timestamps.
|
||||
func (s *Service) NowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -58,3 +58,10 @@ TRUST_PROXY=false
|
||||
|
||||
# Console→Go API: keep on 127.0.0.1 (native install) or http://betterdesk-server:21114/api (Docker).
|
||||
# Do not expose the Go API port directly to WAN — use the console proxy on :21121 for RustDesk clients.
|
||||
|
||||
# Billing / NTP (commercialization module — merged on update, missing keys only)
|
||||
NTP_SERVERS=pool.ntp.org,time.google.com,time.cloudflare.com
|
||||
BILLING_MAX_CLOCK_SKEW_MS=2000
|
||||
BILLING_REQUIRE_SYNCED_CLOCK=1
|
||||
BILLING_ROUNDING_MINUTES=1
|
||||
BILLING_REQUIRE_WORK_REPORT=1
|
||||
|
||||
+3650
-3557
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+94
-1
@@ -77,7 +77,8 @@
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"login": "Login",
|
||||
"server_management": "Server Management",
|
||||
"server_attestation": "Server Attestation"
|
||||
"server_attestation": "Server Attestation",
|
||||
"commercialization": "Commercialization"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -598,6 +599,8 @@
|
||||
"generator": {
|
||||
"title": "Agent Generator",
|
||||
"subtitle": "Build branded BetterDesk Agent installers for your end users",
|
||||
"alpha_badge": "ALPHA",
|
||||
"alpha_tooltip": "Early feature — agent bundle generator is under active development.",
|
||||
"bundles_title": "Agent bundles",
|
||||
"new_bundle": "New bundle",
|
||||
"editor_title": "Bundle editor",
|
||||
@@ -3573,5 +3576,95 @@
|
||||
"page_error": "Failed to load the Server Attestation page",
|
||||
"mode_estimated": "Estimated (WebSocket unreachable)",
|
||||
"incomplete": "Benchmark finished without a valid tier"
|
||||
},
|
||||
"commercialization": {
|
||||
"title": "Commercialization",
|
||||
"subtitle": "Support packages, billable sessions, and work reports",
|
||||
"alpha_badge": "ALPHA",
|
||||
"alpha_tooltip": "Early feature — billing and packages are under active development.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"packages": "Packages & contracts",
|
||||
"sessions": "Sessions",
|
||||
"reports": "Reports",
|
||||
"settings": "Advanced settings"
|
||||
},
|
||||
"clock": {
|
||||
"title": "Server time (NTP)",
|
||||
"check_now": "Check now",
|
||||
"unsynced": "Server clock is not synchronized — billable sessions may be blocked"
|
||||
},
|
||||
"stats": {
|
||||
"active_sessions": "Active billable sessions"
|
||||
},
|
||||
"packages": {
|
||||
"new": "New package",
|
||||
"name": "Name",
|
||||
"included_minutes": "Included minutes",
|
||||
"overage_rate": "Overage rate / h",
|
||||
"currency": "Currency",
|
||||
"heading": "Support packages",
|
||||
"prompt_name": "Package name",
|
||||
"create_title": "New package for organization",
|
||||
"create_submit": "Create package",
|
||||
"select_org": "Organization",
|
||||
"select_org_placeholder": "Select organization…"
|
||||
},
|
||||
"sessions": {
|
||||
"device": "Device",
|
||||
"operator": "Operator",
|
||||
"duration": "Duration",
|
||||
"phase": "Phase",
|
||||
"amount": "Amount"
|
||||
},
|
||||
"reports": {
|
||||
"session": "Session",
|
||||
"summary": "Summary",
|
||||
"date": "Date"
|
||||
},
|
||||
"contracts": {
|
||||
"title": "Organization contracts",
|
||||
"new": "Assign package",
|
||||
"org": "Organization",
|
||||
"package": "Package",
|
||||
"remaining": "Remaining minutes",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"suspend": "Suspend",
|
||||
"activate": "Activate",
|
||||
"select_org": "Select organization",
|
||||
"select_package": "Select package",
|
||||
"no_orgs": "No organizations found",
|
||||
"no_packages": "Create a package first"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign existing package",
|
||||
"submit": "Assign"
|
||||
},
|
||||
"form": {
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"export": {
|
||||
"csv": "Export CSV",
|
||||
"pdf": "Export PDF",
|
||||
"sessions_csv": "Export sessions CSV"
|
||||
},
|
||||
"empty": {
|
||||
"packages": "No packages yet. Create one with the button above.",
|
||||
"contracts": "No organization contracts yet. Assign a package to an organization.",
|
||||
"sessions": "No billable sessions recorded yet.",
|
||||
"reports": "No work reports submitted yet."
|
||||
},
|
||||
"report": {
|
||||
"title": "Work report",
|
||||
"subtitle": "Describe the work performed during this remote session.",
|
||||
"category": "Category",
|
||||
"ticket_ref": "Ticket reference",
|
||||
"summary": "Work performed",
|
||||
"skip": "Skip for now",
|
||||
"submit": "Submit report",
|
||||
"summary_required": "Summary is required",
|
||||
"saved": "Work report saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3649
-3556
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
+3643
-3550
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ const DEFAULT_ROLE_PERMISSIONS = {
|
||||
'chat.access',
|
||||
'enrollment.manage', 'enrollment.approve',
|
||||
'branding.edit',
|
||||
'billing.view', 'billing.manage', 'billing.reports', 'billing.export',
|
||||
]),
|
||||
|
||||
operator: new Set([
|
||||
@@ -46,6 +47,7 @@ const DEFAULT_ROLE_PERMISSIONS = {
|
||||
'enrollment.approve',
|
||||
'chat.access',
|
||||
'org.manage_devices',
|
||||
'billing.view', 'billing.reports',
|
||||
]),
|
||||
viewer: new Set([
|
||||
'device.view',
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"test:ci": "jest --detectOpenHandles --forceExit --ci",
|
||||
"i18n:check": "node scripts/i18n-check.js --system web-nodejs",
|
||||
"i18n:check:all": "node scripts/i18n-check.js",
|
||||
"i18n:commercialization": "node scripts/patch-commercialization-i18n.js",
|
||||
"prei18n:check": "npm run i18n:commercialization",
|
||||
"i18n:apply": "node scripts/apply-i18n-audit.js"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
.commercialization-page .page-header { margin-bottom: 1.25rem; }
|
||||
.commercialization-page .page-header h1 { margin: 0 0 0.35rem; font-size: 1.5rem; color: var(--text-primary); display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.commercialization-page .page-header h1 .beta-badge { margin-left: 0; }
|
||||
.commercialization-page .beta-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
background: linear-gradient(135deg, #a371f7, #58a6ff);
|
||||
color: #fff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.commercialization-page .page-subtitle { margin: 0; color: var(--text-secondary); font-size: 0.9375rem; }
|
||||
/* Override global .tab-panel { display:none } — show the active section for this page */
|
||||
.commercialization-page .tab-panel { display: block; }
|
||||
.commercialization-page .tab-panel.hidden { display: none !important; }
|
||||
.commercialization-page .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.commercialization-page .stat-card,
|
||||
.commercialization-page .card {
|
||||
padding: 1rem 1.125rem;
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.commercialization-page .stat-card h3,
|
||||
.commercialization-page .card h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.commercialization-page .stat-value { font-size: 1.5rem; font-weight: 600; margin: 0.25rem 0; color: var(--text-primary); }
|
||||
.commercialization-page .stat-meta { color: var(--text-secondary); font-size: 0.875rem; }
|
||||
.timesync-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
border: 1px solid rgba(210, 153, 34, 0.35);
|
||||
color: #e3b341;
|
||||
}
|
||||
.timesync-banner.hidden { display: none; }
|
||||
.commercialization-page .table-wrap { overflow-x: auto; }
|
||||
.commercialization-page .panel-toolbar { margin-bottom: 0.75rem; display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.commercialization-page .section-heading {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.commercialization-page .section-heading:first-of-type { margin-top: 0; }
|
||||
.commercialization-page .btn-link { background: none; border: none; color: var(--accent-primary, #58a6ff); cursor: pointer; padding: 0; font: inherit; }
|
||||
.commercialization-page .btn-link.danger { color: #f85149; }
|
||||
.commercialization-page .btn-link:hover { text-decoration: underline; }
|
||||
.commercialization-page .empty-state {
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.commercialization-page .empty-state.hidden { display: none; }
|
||||
.commercialization-page .form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.commercialization-page .form-label .form-control { margin-top: 0.35rem; }
|
||||
.commercialization-page .form-error {
|
||||
color: #f85149;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.commercialization-page .form-error.hidden { display: none; }
|
||||
@@ -390,6 +390,22 @@ table {
|
||||
outline-color: var(--sidebar-cat-server-mgmt-accent);
|
||||
}
|
||||
|
||||
/* Category: commercialization */
|
||||
.sidebar-rail-btn[data-category="commercialization"].active,
|
||||
.sidebar-rail-btn[data-category="commercialization"].selected {
|
||||
background: var(--sidebar-cat-commercialization-muted);
|
||||
color: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
.sidebar-rail-btn[data-category="commercialization"].active::before,
|
||||
.sidebar-rail-btn[data-category="commercialization"].selected::before {
|
||||
background: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
.sidebar-rail-btn[data-category="commercialization"]:focus-visible {
|
||||
outline-color: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
.sidebar-rail-btn .material-icons {
|
||||
font-size: 22px;
|
||||
}
|
||||
@@ -466,19 +482,8 @@ table {
|
||||
color: var(--sidebar-cat-server-mgmt-accent);
|
||||
}
|
||||
|
||||
.sidebar-logo-text {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-flyout-header .sidebar-flyout-title + .sidebar-logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-flyout-header .sidebar-flyout-title:empty + .sidebar-logo-text {
|
||||
display: inline;
|
||||
.sidebar-flyout[data-active-category="commercialization"] .sidebar-flyout-title {
|
||||
color: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
/* Flyout panels (one per category) */
|
||||
@@ -574,6 +579,16 @@ table {
|
||||
outline-color: var(--sidebar-cat-server-mgmt-accent);
|
||||
}
|
||||
|
||||
.sidebar-flyout[data-active-category="commercialization"] .sidebar-link.active {
|
||||
background: var(--sidebar-cat-commercialization-muted);
|
||||
color: var(--sidebar-cat-commercialization-accent);
|
||||
border-left-color: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
.sidebar-flyout[data-active-category="commercialization"] .sidebar-link:focus-visible {
|
||||
outline-color: var(--sidebar-cat-commercialization-accent);
|
||||
}
|
||||
|
||||
.sidebar-link .material-icons {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
@@ -677,6 +692,40 @@ table {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-flyout-stage.badge-sidebar {
|
||||
margin-left: 0;
|
||||
background: linear-gradient(135deg, #a371f7, #58a6ff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-flyout-stage.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.badge-sidebar--alpha {
|
||||
background: linear-gradient(135deg, #a371f7, #58a6ff);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-rail-btn--alpha {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-rail-btn .rail-stage-badge {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.1;
|
||||
background: linear-gradient(135deg, #a371f7, #58a6ff);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
NAVBAR
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
--sidebar-cat-system-muted: rgba(163, 113, 247, 0.20);
|
||||
--sidebar-cat-server-mgmt-accent: #ff9b7a;
|
||||
--sidebar-cat-server-mgmt-muted: rgba(255, 155, 122, 0.18);
|
||||
--sidebar-cat-commercialization-accent: #f0b429;
|
||||
--sidebar-cat-commercialization-muted: rgba(240, 180, 41, 0.18);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
SHAPES & EFFECTS
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Billing work report modal — shown after remote session ends when a
|
||||
* billable session requires a technician report.
|
||||
*/
|
||||
(function (global) {
|
||||
function t(key, fallback) {
|
||||
if (typeof global.t === 'function') {
|
||||
const val = global.t(key);
|
||||
if (val && val !== key) return val;
|
||||
}
|
||||
return fallback !== undefined ? fallback : key;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function ensureModal() {
|
||||
let el = document.getElementById('billing-report-modal');
|
||||
if (el) return el;
|
||||
|
||||
el = document.createElement('div');
|
||||
el.id = 'billing-report-modal';
|
||||
el.className = 'billing-report-modal hidden';
|
||||
el.innerHTML = `
|
||||
<div class="billing-report-backdrop"></div>
|
||||
<div class="billing-report-dialog" role="dialog" aria-modal="true">
|
||||
<h2 class="billing-report-title"></h2>
|
||||
<p class="billing-report-subtitle"></p>
|
||||
<div class="billing-report-meta"></div>
|
||||
<label class="billing-report-label">${escapeHtml(t('commercialization.report.category', 'Category'))}
|
||||
<input type="text" class="billing-report-category" maxlength="128">
|
||||
</label>
|
||||
<label class="billing-report-label">${escapeHtml(t('commercialization.report.ticket_ref', 'Ticket reference'))}
|
||||
<input type="text" class="billing-report-ticket" maxlength="128">
|
||||
</label>
|
||||
<label class="billing-report-label">${escapeHtml(t('commercialization.report.summary', 'Work performed'))} *
|
||||
<textarea class="billing-report-summary" rows="6" maxlength="8000" required></textarea>
|
||||
</label>
|
||||
<div class="billing-report-actions">
|
||||
<button type="button" class="btn btn-secondary billing-report-skip">${escapeHtml(t('commercialization.report.skip', 'Skip for now'))}</button>
|
||||
<button type="button" class="btn btn-primary billing-report-submit">${escapeHtml(t('commercialization.report.submit', 'Submit report'))}</button>
|
||||
</div>
|
||||
<p class="billing-report-error hidden"></p>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
|
||||
if (!document.getElementById('billing-report-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'billing-report-styles';
|
||||
style.textContent = `
|
||||
.billing-report-modal{position:fixed;inset:0;z-index:10050;display:flex;align-items:center;justify-content:center}
|
||||
.billing-report-modal.hidden{display:none}
|
||||
.billing-report-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.55)}
|
||||
.billing-report-dialog{position:relative;z-index:1;width:min(520px,92vw);background:var(--surface-1,#fff);border-radius:10px;padding:1.25rem;box-shadow:0 12px 40px rgba(0,0,0,.25)}
|
||||
.billing-report-title{margin:0 0 .25rem;font-size:1.15rem}
|
||||
.billing-report-subtitle{margin:0 0 .75rem;color:var(--text-muted,#666);font-size:.9rem}
|
||||
.billing-report-meta{font-size:.85rem;margin-bottom:.75rem;color:var(--text-muted,#666)}
|
||||
.billing-report-label{display:block;margin-bottom:.65rem;font-size:.875rem}
|
||||
.billing-report-label input,.billing-report-label textarea{width:100%;margin-top:.25rem;padding:.5rem;border:1px solid var(--border,#ccc);border-radius:6px;background:var(--surface-2,#fafafa);color:inherit}
|
||||
.billing-report-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.75rem}
|
||||
.billing-report-error{color:#b91c1c;font-size:.85rem;margin-top:.5rem}`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
async function fetchPendingSession(deviceId) {
|
||||
const url = `/api/panel/billing/sessions/pending?device_id=${encodeURIComponent(deviceId)}`;
|
||||
const resp = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data.session || null;
|
||||
}
|
||||
|
||||
async function submitReport(sessionId, payload) {
|
||||
const resp = await fetch(`/api/panel/billing/sessions/${encodeURIComponent(sessionId)}/report`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText);
|
||||
}
|
||||
|
||||
function showModal(session, deviceName) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = ensureModal();
|
||||
modal.querySelector('.billing-report-title').textContent = t('commercialization.report.title', 'Work report');
|
||||
modal.querySelector('.billing-report-subtitle').textContent = t('commercialization.report.subtitle', 'Describe the work performed during this remote session.');
|
||||
const mins = session.billed_minutes || 0;
|
||||
const amount = (session.amount_included || 0) + (session.amount_overage || 0);
|
||||
modal.querySelector('.billing-report-meta').textContent =
|
||||
`${deviceName || session.device_id} · ${mins} min · ${amount.toFixed(2)} ${session.currency || ''}`;
|
||||
|
||||
const summaryEl = modal.querySelector('.billing-report-summary');
|
||||
const catEl = modal.querySelector('.billing-report-category');
|
||||
const ticketEl = modal.querySelector('.billing-report-ticket');
|
||||
const errEl = modal.querySelector('.billing-report-error');
|
||||
summaryEl.value = '';
|
||||
catEl.value = '';
|
||||
ticketEl.value = '';
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const close = (result) => {
|
||||
modal.classList.add('hidden');
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
modal.querySelector('.billing-report-skip').onclick = () => close(false);
|
||||
modal.querySelector('.billing-report-backdrop').onclick = () => close(false);
|
||||
modal.querySelector('.billing-report-submit').onclick = async () => {
|
||||
const summary = summaryEl.value.trim();
|
||||
if (!summary) {
|
||||
errEl.textContent = t('commercialization.report.summary_required', 'Summary is required');
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await submitReport(session.id, {
|
||||
summary,
|
||||
category: catEl.value.trim(),
|
||||
ticket_ref: ticketEl.value.trim()
|
||||
});
|
||||
close(true);
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Error';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
summaryEl.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function promptAfterSession(deviceId, deviceName) {
|
||||
if (!deviceId) return false;
|
||||
await sleep(1200);
|
||||
let session = null;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
session = await fetchPendingSession(deviceId);
|
||||
if (session) break;
|
||||
await sleep(800);
|
||||
}
|
||||
if (!session) return false;
|
||||
return showModal(session, deviceName);
|
||||
}
|
||||
|
||||
global.BillingReport = { promptAfterSession };
|
||||
})(window);
|
||||
@@ -0,0 +1,386 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
const page = document.querySelector('.commercialization-page');
|
||||
if (!page) return;
|
||||
|
||||
function t(key, fallback) {
|
||||
if (typeof window.__ === 'function') {
|
||||
const val = window.__(key);
|
||||
if (val && val !== key) return val;
|
||||
}
|
||||
return fallback !== undefined ? fallback : key;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const resp = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
...opts
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatMinutes(m) {
|
||||
if (!m) return '0 min';
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
if (h > 0) return `${h}h ${min}m`;
|
||||
return `${min} min`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function triggerDownload(url) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function setEmptyState(tableId, emptyId, isEmpty, message) {
|
||||
const table = document.getElementById(tableId);
|
||||
let emptyEl = document.getElementById(emptyId);
|
||||
if (!table) return;
|
||||
if (isEmpty) {
|
||||
if (!emptyEl) {
|
||||
emptyEl = document.createElement('p');
|
||||
emptyEl.id = emptyId;
|
||||
emptyEl.className = 'empty-state';
|
||||
table.parentElement?.appendChild(emptyEl);
|
||||
}
|
||||
emptyEl.textContent = message;
|
||||
emptyEl.classList.remove('hidden');
|
||||
table.classList.add('hidden');
|
||||
} else {
|
||||
emptyEl?.classList.add('hidden');
|
||||
table.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
document.getElementById(id)?.classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id)?.classList.remove('open');
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-close-modal]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => closeModal(btn.getAttribute('data-close-modal')));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.modal-overlay').forEach((overlay) => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeModal(overlay.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadOrgs() {
|
||||
const data = await api('/api/panel/org');
|
||||
return data.organizations || data.orgs || [];
|
||||
}
|
||||
|
||||
async function populateOrgSelect(selectEl, placeholderKey) {
|
||||
const orgs = await loadOrgs();
|
||||
selectEl.innerHTML = '';
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = t(placeholderKey, 'Select organization…');
|
||||
selectEl.appendChild(placeholder);
|
||||
orgs.forEach((org) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = org.id;
|
||||
opt.textContent = org.name || org.id;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
return orgs;
|
||||
}
|
||||
|
||||
async function loadTimesync() {
|
||||
try {
|
||||
const st = await api('/api/panel/billing/timesync/status');
|
||||
const banner = document.getElementById('timesync-banner');
|
||||
const statusEl = document.getElementById('clock-status');
|
||||
const offsetEl = document.getElementById('clock-offset');
|
||||
const detail = document.getElementById('settings-clock-detail');
|
||||
const synced = !!st.synced;
|
||||
if (statusEl) statusEl.textContent = synced ? 'OK' : 'WARN';
|
||||
const offset = `${st.offset_ms || 0} ms`;
|
||||
if (offsetEl) offsetEl.textContent = offset;
|
||||
if (detail) detail.textContent = `${synced ? 'Synced' : 'Not synced'} · offset ${offset}`;
|
||||
if (banner) {
|
||||
banner.classList.toggle('hidden', synced);
|
||||
banner.textContent = synced ? '' : t('commercialization.clock.unsynced', 'Clock not synchronized');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[commercialization] timesync', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
const tbody = document.querySelector('#packages-table tbody');
|
||||
if (!tbody) return [];
|
||||
const data = await api('/api/panel/billing/packages');
|
||||
tbody.innerHTML = '';
|
||||
const pkgs = data.packages || [];
|
||||
pkgs.forEach((p) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${escapeHtml(p.name)}</td><td>${p.included_minutes}</td><td>${p.overage_rate}</td><td>${escapeHtml(p.currency)}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
setEmptyState('packages-table', 'packages-empty', pkgs.length === 0,
|
||||
t('commercialization.empty.packages', 'No packages yet. Create one with the button above.'));
|
||||
return pkgs;
|
||||
}
|
||||
|
||||
async function loadContracts() {
|
||||
const tbody = document.querySelector('#contracts-table tbody');
|
||||
if (!tbody) return;
|
||||
const data = await api('/api/panel/billing/contracts');
|
||||
tbody.innerHTML = '';
|
||||
(data.contracts || []).forEach((c) => {
|
||||
const tr = document.createElement('tr');
|
||||
const orgLabel = escapeHtml(c.org_name || c.org_id);
|
||||
const pkgLabel = escapeHtml(c.package_name || c.package_id);
|
||||
const isSuspended = c.status === 'suspended';
|
||||
const toggleLabel = isSuspended
|
||||
? t('commercialization.contracts.activate', 'Activate')
|
||||
: t('commercialization.contracts.suspend', 'Suspend');
|
||||
tr.innerHTML = `
|
||||
<td>${orgLabel}</td>
|
||||
<td>${pkgLabel}</td>
|
||||
<td>${formatMinutes(c.remaining_minutes)}</td>
|
||||
<td>${escapeHtml(c.status)}</td>
|
||||
<td><button type="button" class="btn-link ${isSuspended ? '' : 'danger'}" data-contract-id="${escapeHtml(c.id)}" data-status="${isSuspended ? 'active' : 'suspended'}">${toggleLabel}</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll('[data-contract-id]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.getAttribute('data-contract-id');
|
||||
const status = btn.getAttribute('data-status');
|
||||
await api(`/api/panel/billing/contracts/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
await loadContracts();
|
||||
});
|
||||
});
|
||||
setEmptyState('contracts-table', 'contracts-empty', (data.contracts || []).length === 0,
|
||||
t('commercialization.empty.contracts', 'No organization contracts yet. Assign a package to an organization.'));
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const tbody = document.querySelector('#sessions-table tbody');
|
||||
if (!tbody) return;
|
||||
const data = await api('/api/panel/billing/sessions');
|
||||
tbody.innerHTML = '';
|
||||
let active = 0;
|
||||
(data.sessions || []).forEach((s) => {
|
||||
if (s.status === 'active') active++;
|
||||
const amount = (s.amount_included || 0) + (s.amount_overage || 0);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${escapeHtml(s.device_id)}</td><td>${escapeHtml(s.operator_id)}</td><td>${formatMinutes(s.billed_minutes)}</td><td>${escapeHtml(s.billing_phase)}</td><td>${amount.toFixed(2)} ${escapeHtml(s.currency)}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
const stat = document.getElementById('stat-active-sessions');
|
||||
if (stat) stat.textContent = String(active);
|
||||
setEmptyState('sessions-table', 'sessions-empty', (data.sessions || []).length === 0,
|
||||
t('commercialization.empty.sessions', 'No billable sessions recorded yet.'));
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const tbody = document.querySelector('#reports-table tbody');
|
||||
if (!tbody) return;
|
||||
const data = await api('/api/panel/billing/reports');
|
||||
tbody.innerHTML = '';
|
||||
(data.reports || []).forEach((r) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${escapeHtml(r.session_id)}</td><td>${escapeHtml(r.summary)}</td><td>${escapeHtml(r.created_at || '')}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
setEmptyState('reports-table', 'reports-empty', (data.reports || []).length === 0,
|
||||
t('commercialization.empty.reports', 'No work reports submitted yet.'));
|
||||
}
|
||||
|
||||
function showModalError(el, message) {
|
||||
if (!el) return;
|
||||
if (message) {
|
||||
el.textContent = message;
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.textContent = '';
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function openPackageModal() {
|
||||
const orgSelect = document.getElementById('package-org-select');
|
||||
const errEl = document.getElementById('package-modal-error');
|
||||
showModalError(errEl, '');
|
||||
const orgs = await populateOrgSelect(orgSelect, 'commercialization.packages.select_org_placeholder');
|
||||
if (!orgs.length) {
|
||||
alert(t('commercialization.contracts.no_orgs', 'No organizations found'));
|
||||
return;
|
||||
}
|
||||
document.getElementById('package-name').value = '';
|
||||
document.getElementById('package-minutes').value = '600';
|
||||
document.getElementById('package-overage').value = '100';
|
||||
document.getElementById('package-currency').value = 'PLN';
|
||||
openModal('package-modal');
|
||||
}
|
||||
|
||||
async function submitPackageModal() {
|
||||
const errEl = document.getElementById('package-modal-error');
|
||||
const orgId = document.getElementById('package-org-select')?.value;
|
||||
const name = document.getElementById('package-name')?.value.trim();
|
||||
const includedMinutes = parseInt(document.getElementById('package-minutes')?.value, 10);
|
||||
const overageRate = parseFloat(document.getElementById('package-overage')?.value);
|
||||
const currency = document.getElementById('package-currency')?.value.trim().toUpperCase();
|
||||
|
||||
if (!orgId) {
|
||||
showModalError(errEl, t('commercialization.packages.select_org_placeholder', 'Select organization…'));
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showModalError(errEl, t('commercialization.packages.prompt_name', 'Package name'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = await api('/api/panel/billing/packages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
included_minutes: includedMinutes,
|
||||
overage_rate: overageRate,
|
||||
currency: currency || 'PLN'
|
||||
})
|
||||
});
|
||||
await api('/api/panel/billing/contracts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
org_id: orgId,
|
||||
package_id: pkg.id,
|
||||
currency: pkg.currency || currency || 'PLN'
|
||||
})
|
||||
});
|
||||
closeModal('package-modal');
|
||||
await loadPackages();
|
||||
await loadContracts();
|
||||
} catch (e) {
|
||||
showModalError(errEl, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignModal() {
|
||||
const orgSelect = document.getElementById('assign-org-select');
|
||||
const pkgSelect = document.getElementById('assign-package-select');
|
||||
const errEl = document.getElementById('assign-modal-error');
|
||||
showModalError(errEl, '');
|
||||
|
||||
const [orgs, pkgs] = await Promise.all([
|
||||
populateOrgSelect(orgSelect, 'commercialization.packages.select_org_placeholder'),
|
||||
api('/api/panel/billing/packages')
|
||||
]);
|
||||
if (!orgs.length) {
|
||||
alert(t('commercialization.contracts.no_orgs', 'No organizations found'));
|
||||
return;
|
||||
}
|
||||
const packages = pkgs.packages || [];
|
||||
if (!packages.length) {
|
||||
alert(t('commercialization.contracts.no_packages', 'Create a package first'));
|
||||
return;
|
||||
}
|
||||
pkgSelect.innerHTML = '';
|
||||
packages.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.name} (${p.included_minutes} min)`;
|
||||
pkgSelect.appendChild(opt);
|
||||
});
|
||||
openModal('assign-modal');
|
||||
}
|
||||
|
||||
async function submitAssignModal() {
|
||||
const errEl = document.getElementById('assign-modal-error');
|
||||
const orgId = document.getElementById('assign-org-select')?.value;
|
||||
const packageId = document.getElementById('assign-package-select')?.value;
|
||||
if (!orgId || !packageId) {
|
||||
showModalError(errEl, t('commercialization.contracts.select_org', 'Select organization'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pkgs = await api('/api/panel/billing/packages');
|
||||
const pkg = (pkgs.packages || []).find((p) => p.id === packageId);
|
||||
await api('/api/panel/billing/contracts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
org_id: orgId,
|
||||
package_id: packageId,
|
||||
currency: pkg?.currency || 'PLN'
|
||||
})
|
||||
});
|
||||
closeModal('assign-modal');
|
||||
await loadContracts();
|
||||
} catch (e) {
|
||||
showModalError(errEl, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-timesync-check')?.addEventListener('click', async () => {
|
||||
await api('/api/panel/billing/timesync/check', { method: 'POST' });
|
||||
await loadTimesync();
|
||||
});
|
||||
|
||||
document.getElementById('btn-new-package')?.addEventListener('click', () => {
|
||||
openPackageModal().catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
document.getElementById('package-modal-submit')?.addEventListener('click', () => {
|
||||
submitPackageModal();
|
||||
});
|
||||
|
||||
document.getElementById('btn-new-contract')?.addEventListener('click', () => {
|
||||
openAssignModal().catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
document.getElementById('assign-modal-submit')?.addEventListener('click', () => {
|
||||
submitAssignModal();
|
||||
});
|
||||
|
||||
document.getElementById('btn-export-sessions')?.addEventListener('click', () => {
|
||||
triggerDownload('/api/panel/billing/sessions/export?format=csv');
|
||||
});
|
||||
|
||||
document.getElementById('btn-export-reports-csv')?.addEventListener('click', () => {
|
||||
triggerDownload('/api/panel/billing/reports/export?format=csv');
|
||||
});
|
||||
|
||||
document.getElementById('btn-export-reports-pdf')?.addEventListener('click', () => {
|
||||
triggerDownload('/api/panel/billing/reports/export?format=pdf');
|
||||
});
|
||||
|
||||
const tab = page.dataset.activeTab || 'overview';
|
||||
if (tab === 'overview' || tab === 'settings') {
|
||||
loadTimesync();
|
||||
}
|
||||
if (tab === 'overview' || tab === 'sessions') {
|
||||
loadSessions().catch(console.warn);
|
||||
}
|
||||
if (tab === 'packages') {
|
||||
loadPackages().catch(console.warn);
|
||||
loadContracts().catch(console.warn);
|
||||
}
|
||||
if (tab === 'reports') {
|
||||
loadReports().catch(console.warn);
|
||||
}
|
||||
})();
|
||||
@@ -448,6 +448,13 @@
|
||||
setSessionStatus(session, 'info', reason || _('remote.disconnected'));
|
||||
showSessionActions(session);
|
||||
if (isActive(session)) setToolbarAutoHide(false);
|
||||
if (typeof window.BillingReport !== 'undefined') {
|
||||
window.BillingReport.promptAfterSession(session.deviceId, session.deviceName)
|
||||
.then((submitted) => {
|
||||
if (submitted) showToast(t('commercialization.report.saved', 'Work report saved'), 'success');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
c.on('password_required', () => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAuth, requirePermission } = require('../middleware/auth');
|
||||
const { apiClient } = require('../services/betterdeskApi');
|
||||
|
||||
async function goApiProxy(req, res, method, path, body) {
|
||||
try {
|
||||
const opts = { method, url: path };
|
||||
if (body) opts.data = body;
|
||||
const resp = await apiClient(opts);
|
||||
res.status(resp.status).json(resp.data);
|
||||
} catch (err) {
|
||||
const status = err.response?.status || 500;
|
||||
const data = err.response?.data || { error: 'Go server unreachable' };
|
||||
res.status(status).json(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function goApiBinaryProxy(req, res, method, path) {
|
||||
try {
|
||||
const resp = await apiClient({
|
||||
method,
|
||||
url: path,
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
const ct = resp.headers['content-type'];
|
||||
const cd = resp.headers['content-disposition'];
|
||||
if (ct) res.set('Content-Type', ct);
|
||||
if (cd) res.set('Content-Disposition', cd);
|
||||
res.status(resp.status).send(Buffer.from(resp.data));
|
||||
} catch (err) {
|
||||
const status = err.response?.status || 500;
|
||||
if (err.response?.data) {
|
||||
try {
|
||||
const text = Buffer.from(err.response.data).toString('utf8');
|
||||
const json = JSON.parse(text);
|
||||
return res.status(status).json(json);
|
||||
} catch (_) { /* fall through */ }
|
||||
}
|
||||
res.status(status).json({ error: 'Go server unreachable' });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/commercialization', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
const validTabs = ['overview', 'packages', 'sessions', 'reports', 'settings'];
|
||||
const tab = validTabs.includes(req.query.tab) ? req.query.tab : 'overview';
|
||||
const tabKey = `commercialization.tabs.${tab}`;
|
||||
res.render('commercialization', {
|
||||
title: req.t(tabKey),
|
||||
pageStyles: ['commercialization'],
|
||||
pageScripts: ['commercialization'],
|
||||
currentPage: 'commercialization',
|
||||
currentTab: tab,
|
||||
activeTab: tab,
|
||||
breadcrumb: [
|
||||
{ label: req.t('commercialization.title') },
|
||||
{ label: req.t(tabKey) }
|
||||
],
|
||||
req
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/timesync/status', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
goApiProxy(req, res, 'GET', '/timesync/status');
|
||||
});
|
||||
|
||||
router.post('/api/panel/billing/timesync/check', requireAuth, requirePermission('server.config'), (req, res) => {
|
||||
goApiProxy(req, res, 'POST', '/timesync/check');
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/packages', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
goApiProxy(req, res, 'GET', '/billing/packages');
|
||||
});
|
||||
|
||||
router.post('/api/panel/billing/packages', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'POST', '/billing/packages', req.body);
|
||||
});
|
||||
|
||||
router.put('/api/panel/billing/packages/:id', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'PUT', `/billing/packages/${encodeURIComponent(req.params.id)}`, req.body);
|
||||
});
|
||||
|
||||
router.delete('/api/panel/billing/packages/:id', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'DELETE', `/billing/packages/${encodeURIComponent(req.params.id)}`);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/contracts', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiProxy(req, res, 'GET', `/billing/contracts${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.post('/api/panel/billing/contracts', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'POST', '/billing/contracts', req.body);
|
||||
});
|
||||
|
||||
router.put('/api/panel/billing/contracts/:id', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'PUT', `/billing/contracts/${encodeURIComponent(req.params.id)}`, req.body);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/sessions', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiProxy(req, res, 'GET', `/billing/sessions${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/sessions/pending', requireAuth, requirePermission('billing.reports'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiProxy(req, res, 'GET', `/billing/sessions/pending${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/sessions/export', requireAuth, requirePermission('billing.export'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiBinaryProxy(req, res, 'GET', `/billing/sessions/export${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/reports', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiProxy(req, res, 'GET', `/billing/reports${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/reports/export', requireAuth, requirePermission('billing.export'), (req, res) => {
|
||||
const q = new URLSearchParams(req.query).toString();
|
||||
goApiBinaryProxy(req, res, 'GET', `/billing/reports/export${q ? '?' + q : ''}`);
|
||||
});
|
||||
|
||||
router.post('/api/panel/billing/sessions/:id/report', requireAuth, requirePermission('billing.reports'), (req, res) => {
|
||||
goApiProxy(req, res, 'POST', `/billing/sessions/${encodeURIComponent(req.params.id)}/report`, req.body);
|
||||
});
|
||||
|
||||
router.get('/api/panel/billing/currencies', requireAuth, requirePermission('billing.view'), (req, res) => {
|
||||
goApiProxy(req, res, 'GET', '/billing/currencies');
|
||||
});
|
||||
|
||||
router.put('/api/panel/billing/currencies/:code', requireAuth, requirePermission('billing.manage'), (req, res) => {
|
||||
goApiProxy(req, res, 'PUT', `/billing/currencies/${encodeURIComponent(req.params.code)}`, req.body);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -49,6 +49,7 @@ const tokensRoutes = lazyRoute('./tokens.routes');
|
||||
const organizationsRoutes = lazyRoute('./organizations.routes');
|
||||
const policiesRoutes = lazyRoute('./policies.routes');
|
||||
const fleetRoutes = lazyRoute('./fleet.routes');
|
||||
const commercializationRoutes = lazyRoute('./commercialization.routes');
|
||||
const scalingRoutes = lazyRoute('./scaling.routes');
|
||||
const crossPlatformRoutes = lazyRoute('./cross-platform.routes');
|
||||
const toolkitRoutes = lazyRoute('./toolkit.routes');
|
||||
@@ -135,6 +136,7 @@ router.use('/', organizationsRoutes); // admin-facing: /organ
|
||||
router.use('/', policiesRoutes); // admin-facing: /policies, /api/panel/policies/*, /api/bd/device-policy, /api/bd/attestation
|
||||
router.use('/api/bd', policiesRoutes); // device-facing: /api/bd/device-policy, /api/bd/attestation
|
||||
router.use('/', fleetRoutes); // admin-facing: /fleet, /api/panel/fleet/*
|
||||
router.use('/', commercializationRoutes); // admin-facing: /commercialization, /api/panel/billing/*
|
||||
router.use('/api/bd', fleetRoutes); // device-facing: /api/bd/fleet/task-result, /api/bd/fleet/software
|
||||
router.use('/', scalingRoutes); // admin-facing: /scaling, /api/panel/scaling/*
|
||||
router.use('/api/bd', scalingRoutes); // device-facing: /api/bd/scaling/relay-heartbeat
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
'use strict';
|
||||
|
||||
/** Shared UI sections for commercialization — merged into every non-en locale by patch-commercialization-i18n.js */
|
||||
module.exports = {
|
||||
ar: {
|
||||
tabs: { overview: 'نظرة عامة', packages: 'الحزم والعقود', sessions: 'الجلسات', reports: 'التقارير', settings: 'إعدادات متقدمة' },
|
||||
clock: { title: 'وقت الخادم (NTP)', check_now: 'تحقق الآن', unsynced: 'ساعة الخادم غير متزامنة — قد تُحظر الجلسات القابلة للفوترة' },
|
||||
stats: { active_sessions: 'جلسات قابلة للفوترة نشطة' },
|
||||
packages: { new: 'حزمة جديدة', name: 'الاسم', included_minutes: 'الدقائق المضمنة', overage_rate: 'سعر التجاوز / س', currency: 'العملة', create_title: 'حزمة جديدة للمنظمة', create_submit: 'إنشاء حزمة', select_org: 'المنظمة', select_org_placeholder: 'اختر المنظمة…' },
|
||||
sessions: { device: 'الجهاز', operator: 'المشغّل', duration: 'المدة', phase: 'المرحلة', amount: 'المبلغ' },
|
||||
reports: { session: 'الجلسة', summary: 'الملخص', date: 'التاريخ' },
|
||||
empty: { packages: 'لا توجد حزم بعد. أنشئ واحدة بالزر أعلاه.', contracts: 'لا توجد عقود منظمات بعد. عيّن حزمة لمنظمة.', sessions: 'لم تُسجَّل جلسات قابلة للفوترة بعد.', reports: 'لم تُرسَل تقارير عمل بعد.' },
|
||||
assign: { title: 'تعيين حزمة موجودة', submit: 'تعيين' },
|
||||
form: { cancel: 'إلغاء' }
|
||||
},
|
||||
cs: {
|
||||
tabs: { overview: 'Přehled', packages: 'Balíčky a smlouvy', sessions: 'Relace', reports: 'Reporty', settings: 'Pokročilá nastavení' },
|
||||
clock: { title: 'Čas serveru (NTP)', check_now: 'Zkontrolovat', unsynced: 'Hodiny serveru nejsou synchronizovány — fakturovatelné relace mohou být blokovány' },
|
||||
stats: { active_sessions: 'Aktivní fakturovatelné relace' },
|
||||
packages: { new: 'Nový balíček', name: 'Název', included_minutes: 'Zahrnuté minuty', overage_rate: 'Sazba překročení / h', currency: 'Měna', create_title: 'Nový balíček pro organizaci', create_submit: 'Vytvořit balíček', select_org: 'Organizace', select_org_placeholder: 'Vyberte organizaci…' },
|
||||
sessions: { device: 'Zařízení', operator: 'Operátor', duration: 'Trvání', phase: 'Fáze', amount: 'Částka' },
|
||||
reports: { session: 'Relace', summary: 'Shrnutí', date: 'Datum' },
|
||||
empty: { packages: 'Zatím žádné balíčky. Vytvořte první tlačítkem výše.', contracts: 'Zatím žádné smlouvy organizací. Přiřaďte balíček organizaci.', sessions: 'Zatím nejsou zaznamenány fakturovatelné relace.', reports: 'Zatím nebyly odeslány pracovní zprávy.' },
|
||||
assign: { title: 'Přiřadit existující balíček', submit: 'Přiřadit' },
|
||||
form: { cancel: 'Zrušit' }
|
||||
},
|
||||
da: {
|
||||
tabs: { overview: 'Overblik', packages: 'Pakker og kontrakter', sessions: 'Sessioner', reports: 'Rapporter', settings: 'Avancerede indstillinger' },
|
||||
clock: { title: 'Servertid (NTP)', check_now: 'Tjek nu', unsynced: 'Serveruret er ikke synkroniseret — fakturerbare sessioner kan blive blokeret' },
|
||||
stats: { active_sessions: 'Aktive fakturerbare sessioner' },
|
||||
packages: { new: 'Ny pakke', name: 'Navn', included_minutes: 'Inkluderede minutter', overage_rate: 'Overforbrugssats / t', currency: 'Valuta', create_title: 'Ny pakke til organisation', create_submit: 'Opret pakke', select_org: 'Organisation', select_org_placeholder: 'Vælg organisation…' },
|
||||
sessions: { device: 'Enhed', operator: 'Operatør', duration: 'Varighed', phase: 'Fase', amount: 'Beløb' },
|
||||
reports: { session: 'Session', summary: 'Resumé', date: 'Dato' },
|
||||
empty: { packages: 'Ingen pakker endnu. Opret en med knappen ovenfor.', contracts: 'Ingen organisationskontrakter endnu. Tildel en pakke til en organisation.', sessions: 'Ingen fakturerbare sessioner registreret endnu.', reports: 'Ingen arbejdsrapporter indsendt endnu.' },
|
||||
assign: { title: 'Tildel eksisterende pakke', submit: 'Tildel' },
|
||||
form: { cancel: 'Annuller' }
|
||||
},
|
||||
de: {
|
||||
tabs: { overview: 'Übersicht', packages: 'Pakete & Verträge', sessions: 'Sitzungen', reports: 'Berichte', settings: 'Erweiterte Einstellungen' },
|
||||
clock: { title: 'Serverzeit (NTP)', check_now: 'Jetzt prüfen', unsynced: 'Serveruhr nicht synchronisiert — abrechenbare Sitzungen können blockiert werden' },
|
||||
stats: { active_sessions: 'Aktive abrechenbare Sitzungen' },
|
||||
packages: { new: 'Neues Paket', name: 'Name', included_minutes: 'Enthaltene Minuten', overage_rate: 'Überziehungssatz / h', currency: 'Währung', create_title: 'Neues Paket für Organisation', create_submit: 'Paket erstellen', select_org: 'Organisation', select_org_placeholder: 'Organisation wählen…' },
|
||||
sessions: { device: 'Gerät', operator: 'Operator', duration: 'Dauer', phase: 'Phase', amount: 'Betrag' },
|
||||
reports: { session: 'Sitzung', summary: 'Zusammenfassung', date: 'Datum' },
|
||||
empty: { packages: 'Noch keine Pakete. Erstellen Sie eines mit der Schaltfläche oben.', contracts: 'Noch keine Organisationsverträge. Weisen Sie einer Organisation ein Paket zu.', sessions: 'Noch keine abrechenbaren Sitzungen erfasst.', reports: 'Noch keine Arbeitsberichte eingereicht.' },
|
||||
assign: { title: 'Vorhandenes Paket zuweisen', submit: 'Zuweisen' },
|
||||
form: { cancel: 'Abbrechen' }
|
||||
},
|
||||
es: {
|
||||
tabs: { overview: 'Resumen', packages: 'Paquetes y contratos', sessions: 'Sesiones', reports: 'Informes', settings: 'Configuración avanzada' },
|
||||
clock: { title: 'Hora del servidor (NTP)', check_now: 'Comprobar ahora', unsynced: 'El reloj del servidor no está sincronizado — las sesiones facturables pueden bloquearse' },
|
||||
stats: { active_sessions: 'Sesiones facturables activas' },
|
||||
packages: { new: 'Nuevo paquete', name: 'Nombre', included_minutes: 'Minutos incluidos', overage_rate: 'Tarifa de exceso / h', currency: 'Moneda', create_title: 'Nuevo paquete para organización', create_submit: 'Crear paquete', select_org: 'Organización', select_org_placeholder: 'Seleccionar organización…' },
|
||||
sessions: { device: 'Dispositivo', operator: 'Operador', duration: 'Duración', phase: 'Fase', amount: 'Importe' },
|
||||
reports: { session: 'Sesión', summary: 'Resumen', date: 'Fecha' },
|
||||
empty: { packages: 'Aún no hay paquetes. Cree uno con el botón de arriba.', contracts: 'Aún no hay contratos de organización. Asigne un paquete a una organización.', sessions: 'Aún no hay sesiones facturables registradas.', reports: 'Aún no se han enviado informes de trabajo.' },
|
||||
assign: { title: 'Asignar paquete existente', submit: 'Asignar' },
|
||||
form: { cancel: 'Cancelar' }
|
||||
},
|
||||
fi: {
|
||||
tabs: { overview: 'Yleiskatsaus', packages: 'Paketit ja sopimukset', sessions: 'Istunnot', reports: 'Raportit', settings: 'Lisäasetukset' },
|
||||
clock: { title: 'Palvelinaika (NTP)', check_now: 'Tarkista nyt', unsynced: 'Palvelimen kello ei ole synkronoitu — laskutettavat istunnot voidaan estää' },
|
||||
stats: { active_sessions: 'Aktiiviset laskutettavat istunnot' },
|
||||
packages: { new: 'Uusi paketti', name: 'Nimi', included_minutes: 'Sisältyvät minuutit', overage_rate: 'Ylityshinta / h', currency: 'Valuutta', create_title: 'Uusi paketti organisaatiolle', create_submit: 'Luo paketti', select_org: 'Organisaatio', select_org_placeholder: 'Valitse organisaatio…' },
|
||||
sessions: { device: 'Laite', operator: 'Operaattori', duration: 'Kesto', phase: 'Vaihe', amount: 'Summa' },
|
||||
reports: { session: 'Istunto', summary: 'Yhteenveto', date: 'Päivämäärä' },
|
||||
empty: { packages: 'Ei paketteja vielä. Luo ensimmäinen yllä olevalla painikkeella.', contracts: 'Ei organisaatiosopimuksia vielä. Määritä paketti organisaatiolle.', sessions: 'Ei laskutettavia istuntoja vielä.', reports: 'Ei työraportteja vielä.' },
|
||||
assign: { title: 'Määritä olemassa oleva paketti', submit: 'Määritä' },
|
||||
form: { cancel: 'Peruuta' }
|
||||
},
|
||||
fr: {
|
||||
tabs: { overview: 'Aperçu', packages: 'Forfaits et contrats', sessions: 'Sessions', reports: 'Rapports', settings: 'Paramètres avancés' },
|
||||
clock: { title: 'Heure serveur (NTP)', check_now: 'Vérifier', unsynced: 'L\'horloge du serveur n\'est pas synchronisée — les sessions facturables peuvent être bloquées' },
|
||||
stats: { active_sessions: 'Sessions facturables actives' },
|
||||
packages: { new: 'Nouveau forfait', name: 'Nom', included_minutes: 'Minutes incluses', overage_rate: 'Tarif dépassement / h', currency: 'Devise', create_title: 'Nouveau forfait pour l\'organisation', create_submit: 'Créer le forfait', select_org: 'Organisation', select_org_placeholder: 'Sélectionner une organisation…' },
|
||||
sessions: { device: 'Appareil', operator: 'Opérateur', duration: 'Durée', phase: 'Phase', amount: 'Montant' },
|
||||
reports: { session: 'Session', summary: 'Résumé', date: 'Date' },
|
||||
empty: { packages: 'Aucun forfait pour l\'instant. Créez-en un avec le bouton ci-dessus.', contracts: 'Aucun contrat d\'organisation pour l\'instant. Assignez un forfait à une organisation.', sessions: 'Aucune session facturable enregistrée.', reports: 'Aucun rapport de travail soumis.' },
|
||||
assign: { title: 'Assigner un forfait existant', submit: 'Assigner' },
|
||||
form: { cancel: 'Annuler' }
|
||||
},
|
||||
hi: {
|
||||
tabs: { overview: 'अवलोकन', packages: 'पैकेज और अनुबंध', sessions: 'सत्र', reports: 'रिपोर्ट', settings: 'उन्नत सेटिंग्स' },
|
||||
clock: { title: 'सर्वर समय (NTP)', check_now: 'अभी जाँचें', unsynced: 'सर्वर घड़ी सिंक नहीं है — बिल योग्य सत्र अवरुद्ध हो सकते हैं' },
|
||||
stats: { active_sessions: 'सक्रिय बिल योग्य सत्र' },
|
||||
packages: { new: 'नया पैकेज', name: 'नाम', included_minutes: 'शामिल मिनट', overage_rate: 'अतिरिक्त दर / घं', currency: 'मुद्रा', create_title: 'संगठन के लिए नया पैकेज', create_submit: 'पैकेज बनाएं', select_org: 'संगठन', select_org_placeholder: 'संगठन चुनें…' },
|
||||
sessions: { device: 'डिवाइस', operator: 'ऑपरेटर', duration: 'अवधि', phase: 'चरण', amount: 'राशि' },
|
||||
reports: { session: 'सत्र', summary: 'सारांश', date: 'तारीख' },
|
||||
empty: { packages: 'अभी कोई पैकेज नहीं। ऊपर के बटन से एक बनाएं।', contracts: 'अभी कोई संगठन अनुबंध नहीं। किसी संगठन को पैकेज असाइन करें।', sessions: 'अभी कोई बिल योग्य सत्र दर्ज नहीं।', reports: 'अभी कोई कार्य रिपोर्ट नहीं।' },
|
||||
assign: { title: 'मौजूदा पैकेज असाइन करें', submit: 'असाइन करें' },
|
||||
form: { cancel: 'रद्द करें' }
|
||||
},
|
||||
hu: {
|
||||
tabs: { overview: 'Áttekintés', packages: 'Csomagok és szerződések', sessions: 'Munkamenetek', reports: 'Jelentések', settings: 'Speciális beállítások' },
|
||||
clock: { title: 'Szerveridő (NTP)', check_now: 'Ellenőrzés', unsynced: 'A szerver órája nincs szinkronban — a számlázható munkamenetek blokkolva lehetnek' },
|
||||
stats: { active_sessions: 'Aktív számlázható munkamenetek' },
|
||||
packages: { new: 'Új csomag', name: 'Név', included_minutes: 'Tartalmazott percek', overage_rate: 'Túllépési díj / ó', currency: 'Pénznem', create_title: 'Új csomag szervezetnek', create_submit: 'Csomag létrehozása', select_org: 'Szervezet', select_org_placeholder: 'Válasszon szervezetet…' },
|
||||
sessions: { device: 'Eszköz', operator: 'Operátor', duration: 'Időtartam', phase: 'Fázis', amount: 'Összeg' },
|
||||
reports: { session: 'Munkamenet', summary: 'Összefoglaló', date: 'Dátum' },
|
||||
empty: { packages: 'Még nincsenek csomagok. Hozzon létre egyet a fenti gombbal.', contracts: 'Még nincsenek szervezeti szerződések. Rendeljen csomagot szervezethez.', sessions: 'Még nincsenek rögzített számlázható munkamenetek.', reports: 'Még nincsenek beküldött munkajelentések.' },
|
||||
assign: { title: 'Meglévő csomag hozzárendelése', submit: 'Hozzárendelés' },
|
||||
form: { cancel: 'Mégse' }
|
||||
},
|
||||
id: {
|
||||
tabs: { overview: 'Ikhtisar', packages: 'Paket & kontrak', sessions: 'Sesi', reports: 'Laporan', settings: 'Pengaturan lanjutan' },
|
||||
clock: { title: 'Waktu server (NTP)', check_now: 'Periksa sekarang', unsynced: 'Jam server tidak tersinkron — sesi berbayar dapat diblokir' },
|
||||
stats: { active_sessions: 'Sesi berbayar aktif' },
|
||||
packages: { new: 'Paket baru', name: 'Nama', included_minutes: 'Menit termasuk', overage_rate: 'Tarif kelebihan / j', currency: 'Mata uang', create_title: 'Paket baru untuk organisasi', create_submit: 'Buat paket', select_org: 'Organisasi', select_org_placeholder: 'Pilih organisasi…' },
|
||||
sessions: { device: 'Perangkat', operator: 'Operator', duration: 'Durasi', phase: 'Fase', amount: 'Jumlah' },
|
||||
reports: { session: 'Sesi', summary: 'Ringkasan', date: 'Tanggal' },
|
||||
empty: { packages: 'Belum ada paket. Buat dengan tombol di atas.', contracts: 'Belum ada kontrak organisasi. Tetapkan paket ke organisasi.', sessions: 'Belum ada sesi berbayar tercatat.', reports: 'Belum ada laporan kerja.' },
|
||||
assign: { title: 'Tetapkan paket yang ada', submit: 'Tetapkan' },
|
||||
form: { cancel: 'Batal' }
|
||||
},
|
||||
it: {
|
||||
tabs: { overview: 'Panoramica', packages: 'Pacchetti e contratti', sessions: 'Sessioni', reports: 'Report', settings: 'Impostazioni avanzate' },
|
||||
clock: { title: 'Ora server (NTP)', check_now: 'Controlla ora', unsynced: 'L\'orologio del server non è sincronizzato — le sessioni fatturabili possono essere bloccate' },
|
||||
stats: { active_sessions: 'Sessioni fatturabili attive' },
|
||||
packages: { new: 'Nuovo pacchetto', name: 'Nome', included_minutes: 'Minuti inclusi', overage_rate: 'Tariffa extra / h', currency: 'Valuta', create_title: 'Nuovo pacchetto per organizzazione', create_submit: 'Crea pacchetto', select_org: 'Organizzazione', select_org_placeholder: 'Seleziona organizzazione…' },
|
||||
sessions: { device: 'Dispositivo', operator: 'Operatore', duration: 'Durata', phase: 'Fase', amount: 'Importo' },
|
||||
reports: { session: 'Sessione', summary: 'Riepilogo', date: 'Data' },
|
||||
empty: { packages: 'Nessun pacchetto ancora. Creane uno con il pulsante sopra.', contracts: 'Nessun contratto organizzazione ancora. Assegna un pacchetto a un\'organizzazione.', sessions: 'Nessuna sessione fatturabile registrata.', reports: 'Nessun report di lavoro inviato.' },
|
||||
assign: { title: 'Assegna pacchetto esistente', submit: 'Assegna' },
|
||||
form: { cancel: 'Annulla' }
|
||||
},
|
||||
ja: {
|
||||
tabs: { overview: '概要', packages: 'パッケージと契約', sessions: 'セッション', reports: 'レポート', settings: '詳細設定' },
|
||||
clock: { title: 'サーバー時刻 (NTP)', check_now: '今すぐ確認', unsynced: 'サーバー時計が同期されていません — 課金セッションがブロックされる場合があります' },
|
||||
stats: { active_sessions: 'アクティブな課金セッション' },
|
||||
packages: { new: '新規パッケージ', name: '名前', included_minutes: '含まれる分数', overage_rate: '超過料金 / 時間', currency: '通貨', create_title: '組織向け新規パッケージ', create_submit: 'パッケージを作成', select_org: '組織', select_org_placeholder: '組織を選択…' },
|
||||
sessions: { device: 'デバイス', operator: 'オペレーター', duration: '時間', phase: 'フェーズ', amount: '金額' },
|
||||
reports: { session: 'セッション', summary: '概要', date: '日付' },
|
||||
empty: { packages: 'パッケージがありません。上のボタンで作成してください。', contracts: '組織契約がありません。組織にパッケージを割り当ててください。', sessions: '課金セッションはまだ記録されていません。', reports: '作業レポートはまだ送信されていません。' },
|
||||
assign: { title: '既存パッケージを割り当て', submit: '割り当て' },
|
||||
form: { cancel: 'キャンセル' }
|
||||
},
|
||||
ko: {
|
||||
tabs: { overview: '개요', packages: '패키지 및 계약', sessions: '세션', reports: '보고서', settings: '고급 설정' },
|
||||
clock: { title: '서버 시간 (NTP)', check_now: '지금 확인', unsynced: '서버 시계가 동기화되지 않음 — 과금 세션이 차단될 수 있습니다' },
|
||||
stats: { active_sessions: '활성 과금 세션' },
|
||||
packages: { new: '새 패키지', name: '이름', included_minutes: '포함 분', overage_rate: '초과 요금 / 시간', currency: '통화', create_title: '조직용 새 패키지', create_submit: '패키지 만들기', select_org: '조직', select_org_placeholder: '조직 선택…' },
|
||||
sessions: { device: '장치', operator: '운영자', duration: '시간', phase: '단계', amount: '금액' },
|
||||
reports: { session: '세션', summary: '요약', date: '날짜' },
|
||||
empty: { packages: '아직 패키지가 없습니다. 위 버튼으로 만드세요.', contracts: '아직 조직 계약이 없습니다. 조직에 패키지를 할당하세요.', sessions: '아직 기록된 과금 세션이 없습니다.', reports: '아직 제출된 작업 보고서가 없습니다.' },
|
||||
assign: { title: '기존 패키지 할당', submit: '할당' },
|
||||
form: { cancel: '취소' }
|
||||
},
|
||||
nb: {
|
||||
tabs: { overview: 'Oversikt', packages: 'Pakker og kontrakter', sessions: 'Økter', reports: 'Rapporter', settings: 'Avanserte innstillinger' },
|
||||
clock: { title: 'Servertid (NTP)', check_now: 'Sjekk nå', unsynced: 'Serverklokken er ikke synkronisert — fakturerbare økter kan bli blokkert' },
|
||||
stats: { active_sessions: 'Aktive fakturerbare økter' },
|
||||
packages: { new: 'Ny pakke', name: 'Navn', included_minutes: 'Inkluderte minutter', overage_rate: 'Overforbrukssats / t', currency: 'Valuta', create_title: 'Ny pakke for organisasjon', create_submit: 'Opprett pakke', select_org: 'Organisasjon', select_org_placeholder: 'Velg organisasjon…' },
|
||||
sessions: { device: 'Enhet', operator: 'Operatør', duration: 'Varighet', phase: 'Fase', amount: 'Beløp' },
|
||||
reports: { session: 'Økt', summary: 'Sammendrag', date: 'Dato' },
|
||||
empty: { packages: 'Ingen pakker ennå. Opprett en med knappen over.', contracts: 'Ingen organisasjonskontrakter ennå. Tildel en pakke til en organisasjon.', sessions: 'Ingen fakturerbare økter registrert ennå.', reports: 'Ingen arbeidsrapporter sendt inn ennå.' },
|
||||
assign: { title: 'Tildel eksisterende pakke', submit: 'Tildel' },
|
||||
form: { cancel: 'Avbryt' }
|
||||
},
|
||||
nl: {
|
||||
tabs: { overview: 'Overzicht', packages: 'Pakketten & contracten', sessions: 'Sessies', reports: 'Rapporten', settings: 'Geavanceerde instellingen' },
|
||||
clock: { title: 'Servertijd (NTP)', check_now: 'Nu controleren', unsynced: 'Serverklok niet gesynchroniseerd — factureerbare sessies kunnen worden geblokkeerd' },
|
||||
stats: { active_sessions: 'Actieve factureerbare sessies' },
|
||||
packages: { new: 'Nieuw pakket', name: 'Naam', included_minutes: 'Inbegrepen minuten', overage_rate: 'Overage tarief / u', currency: 'Valuta', create_title: 'Nieuw pakket voor organisatie', create_submit: 'Pakket aanmaken', select_org: 'Organisatie', select_org_placeholder: 'Selecteer organisatie…' },
|
||||
sessions: { device: 'Apparaat', operator: 'Operator', duration: 'Duur', phase: 'Fase', amount: 'Bedrag' },
|
||||
reports: { session: 'Sessie', summary: 'Samenvatting', date: 'Datum' },
|
||||
empty: { packages: 'Nog geen pakketten. Maak er een met de knop hierboven.', contracts: 'Nog geen organisatiecontracten. Wijs een pakket toe aan een organisatie.', sessions: 'Nog geen factureerbare sessies geregistreerd.', reports: 'Nog geen werkrapporten ingediend.' },
|
||||
assign: { title: 'Bestaand pakket toewijzen', submit: 'Toewijzen' },
|
||||
form: { cancel: 'Annuleren' }
|
||||
},
|
||||
pt: {
|
||||
tabs: { overview: 'Visão geral', packages: 'Pacotes e contratos', sessions: 'Sessões', reports: 'Relatórios', settings: 'Definições avançadas' },
|
||||
clock: { title: 'Hora do servidor (NTP)', check_now: 'Verificar agora', unsynced: 'Relógio do servidor não sincronizado — sessões faturáveis podem ser bloqueadas' },
|
||||
stats: { active_sessions: 'Sessões faturáveis ativas' },
|
||||
packages: { new: 'Novo pacote', name: 'Nome', included_minutes: 'Minutos incluídos', overage_rate: 'Taxa de excedente / h', currency: 'Moeda', create_title: 'Novo pacote para organização', create_submit: 'Criar pacote', select_org: 'Organização', select_org_placeholder: 'Selecionar organização…' },
|
||||
sessions: { device: 'Dispositivo', operator: 'Operador', duration: 'Duração', phase: 'Fase', amount: 'Valor' },
|
||||
reports: { session: 'Sessão', summary: 'Resumo', date: 'Data' },
|
||||
empty: { packages: 'Ainda não há pacotes. Crie um com o botão acima.', contracts: 'Ainda não há contratos de organização. Atribua um pacote a uma organização.', sessions: 'Ainda não há sessões faturáveis registadas.', reports: 'Ainda não há relatórios de trabalho.' },
|
||||
assign: { title: 'Atribuir pacote existente', submit: 'Atribuir' },
|
||||
form: { cancel: 'Cancelar' }
|
||||
},
|
||||
ro: {
|
||||
tabs: { overview: 'Prezentare', packages: 'Pachete și contracte', sessions: 'Sesiuni', reports: 'Rapoarte', settings: 'Setări avansate' },
|
||||
clock: { title: 'Ora serverului (NTP)', check_now: 'Verifică acum', unsynced: 'Ceasul serverului nu este sincronizat — sesiunile facturabile pot fi blocate' },
|
||||
stats: { active_sessions: 'Sesiuni facturabile active' },
|
||||
packages: { new: 'Pachet nou', name: 'Nume', included_minutes: 'Minute incluse', overage_rate: 'Tarif depășire / h', currency: 'Monedă', create_title: 'Pachet nou pentru organizație', create_submit: 'Creează pachet', select_org: 'Organizație', select_org_placeholder: 'Selectează organizația…' },
|
||||
sessions: { device: 'Dispozitiv', operator: 'Operator', duration: 'Durată', phase: 'Fază', amount: 'Sumă' },
|
||||
reports: { session: 'Sesiune', summary: 'Rezumat', date: 'Dată' },
|
||||
empty: { packages: 'Nu există pachete încă. Creați unul cu butonul de mai sus.', contracts: 'Nu există contracte de organizație încă. Atribuiți un pachet unei organizații.', sessions: 'Nu există sesiuni facturabile înregistrate încă.', reports: 'Nu există rapoarte de lucru trimise încă.' },
|
||||
assign: { title: 'Atribuie pachet existent', submit: 'Atribuie' },
|
||||
form: { cancel: 'Anulează' }
|
||||
},
|
||||
sv: {
|
||||
tabs: { overview: 'Översikt', packages: 'Paket och avtal', sessions: 'Sessioner', reports: 'Rapporter', settings: 'Avancerade inställningar' },
|
||||
clock: { title: 'Servertid (NTP)', check_now: 'Kontrollera nu', unsynced: 'Serverklockan är inte synkroniserad — fakturerbara sessioner kan blockeras' },
|
||||
stats: { active_sessions: 'Aktiva fakturerbara sessioner' },
|
||||
packages: { new: 'Nytt paket', name: 'Namn', included_minutes: 'Inkluderade minuter', overage_rate: 'Överskottsavgift / h', currency: 'Valuta', create_title: 'Nytt paket för organisation', create_submit: 'Skapa paket', select_org: 'Organisation', select_org_placeholder: 'Välj organisation…' },
|
||||
sessions: { device: 'Enhet', operator: 'Operatör', duration: 'Varaktighet', phase: 'Fas', amount: 'Belopp' },
|
||||
reports: { session: 'Supportsession', summary: 'Sammanfattning', date: 'Datum' },
|
||||
empty: { packages: 'Inga paket ännu. Skapa ett med knappen ovan.', contracts: 'Inga organisationsavtal ännu. Tilldela ett paket till en organisation.', sessions: 'Inga fakturerbara sessioner registrerade ännu.', reports: 'Inga arbetsrapporter inskickade ännu.' },
|
||||
assign: { title: 'Tilldela befintligt paket', submit: 'Tilldela' },
|
||||
form: { cancel: 'Avbryt' }
|
||||
},
|
||||
th: {
|
||||
tabs: { overview: 'ภาพรวม', packages: 'แพ็กเกจและสัญญา', sessions: 'เซสชัน', reports: 'รายงาน', settings: 'การตั้งค่าขั้นสูง' },
|
||||
clock: { title: 'เวลาเซิร์ฟเวอร์ (NTP)', check_now: 'ตรวจสอบตอนนี้', unsynced: 'นาฬิกาเซิร์ฟเวอร์ไม่ซิงค์ — เซสชันที่เรียกเก็บเงินอาจถูกบล็อก' },
|
||||
stats: { active_sessions: 'เซสชันที่เรียกเก็บเงินที่ใช้งานอยู่' },
|
||||
packages: { new: 'แพ็กเกจใหม่', name: 'ชื่อ', included_minutes: 'นาทีที่รวม', overage_rate: 'อัตราเกิน / ชม.', currency: 'สกุลเงิน', create_title: 'แพ็กเกจใหม่สำหรับองค์กร', create_submit: 'สร้างแพ็กเกจ', select_org: 'องค์กร', select_org_placeholder: 'เลือกองค์กร…' },
|
||||
sessions: { device: 'อุปกรณ์', operator: 'ผู้ปฏิบัติการ', duration: 'ระยะเวลา', phase: 'ระยะ', amount: 'จำนวนเงิน' },
|
||||
reports: { session: 'เซสชัน', summary: 'สรุป', date: 'วันที่' },
|
||||
empty: { packages: 'ยังไม่มีแพ็กเกจ สร้างด้วยปุ่มด้านบน', contracts: 'ยังไม่มีสัญญาองค์กร กำหนดแพ็กเกจให้องค์กร', sessions: 'ยังไม่มีเซสชันที่เรียกเก็บเงิน', reports: 'ยังไม่มีรายงานการทำงาน' },
|
||||
assign: { title: 'กำหนดแพ็กเกจที่มีอยู่', submit: 'กำหนด' },
|
||||
form: { cancel: 'ยกเลิก' }
|
||||
},
|
||||
tr: {
|
||||
tabs: { overview: 'Genel bakış', packages: 'Paketler ve sözleşmeler', sessions: 'Oturumlar', reports: 'Raporlar', settings: 'Gelişmiş ayarlar' },
|
||||
clock: { title: 'Sunucu saati (NTP)', check_now: 'Şimdi kontrol et', unsynced: 'Sunucu saati senkronize değil — faturalandırılabilir oturumlar engellenebilir' },
|
||||
stats: { active_sessions: 'Aktif faturalandırılabilir oturumlar' },
|
||||
packages: { new: 'Yeni paket', name: 'Ad', included_minutes: 'Dahil edilen dakikalar', overage_rate: 'Aşım ücreti / s', currency: 'Para birimi', create_title: 'Kuruluş için yeni paket', create_submit: 'Paket oluştur', select_org: 'Kuruluş', select_org_placeholder: 'Kuruluş seçin…' },
|
||||
sessions: { device: 'Cihaz', operator: 'Operatör', duration: 'Süre', phase: 'Aşama', amount: 'Tutar' },
|
||||
reports: { session: 'Oturum', summary: 'Özet', date: 'Tarih' },
|
||||
empty: { packages: 'Henüz paket yok. Yukarıdaki düğmeyle oluşturun.', contracts: 'Henüz kuruluş sözleşmesi yok. Bir kuruluşa paket atayın.', sessions: 'Henüz faturalandırılabilir oturum kaydedilmedi.', reports: 'Henüz iş raporu gönderilmedi.' },
|
||||
assign: { title: 'Mevcut paketi ata', submit: 'Ata' },
|
||||
form: { cancel: 'İptal' }
|
||||
},
|
||||
uk: {
|
||||
tabs: { overview: 'Огляд', packages: 'Пакети та контракти', sessions: 'Сесії', reports: 'Звіти', settings: 'Розширені налаштування' },
|
||||
clock: { title: 'Час сервера (NTP)', check_now: 'Перевірити зараз', unsynced: 'Годинник сервера не синхронізовано — платні сесії можуть бути заблоковані' },
|
||||
stats: { active_sessions: 'Активні платні сесії' },
|
||||
packages: { new: 'Новий пакет', name: 'Назва', included_minutes: 'Включені хвилини', overage_rate: 'Ставка понад ліміт / год', currency: 'Валюта', create_title: 'Новий пакет для організації', create_submit: 'Створити пакет', select_org: 'Організація', select_org_placeholder: 'Оберіть організацію…' },
|
||||
sessions: { device: 'Пристрій', operator: 'Оператор', duration: 'Тривалість', phase: 'Фаза', amount: 'Сума' },
|
||||
reports: { session: 'Сесія', summary: 'Підсумок', date: 'Дата' },
|
||||
empty: { packages: 'Пакетів ще немає. Створіть перший кнопкою вище.', contracts: 'Контрактів організацій ще немає. Призначте пакет організації.', sessions: 'Платних сесій ще не зареєстровано.', reports: 'Звітів про роботу ще не надіслано.' },
|
||||
assign: { title: 'Призначити існуючий пакет', submit: 'Призначити' },
|
||||
form: { cancel: 'Скасувати' }
|
||||
},
|
||||
vi: {
|
||||
tabs: { overview: 'Tổng quan', packages: 'Gói & hợp đồng', sessions: 'Phiên', reports: 'Báo cáo', settings: 'Cài đặt nâng cao' },
|
||||
clock: { title: 'Giờ máy chủ (NTP)', check_now: 'Kiểm tra ngay', unsynced: 'Đồng hồ máy chủ chưa đồng bộ — phiên tính phí có thể bị chặn' },
|
||||
stats: { active_sessions: 'Phiên tính phí đang hoạt động' },
|
||||
packages: { new: 'Gói mới', name: 'Tên', included_minutes: 'Phút bao gồm', overage_rate: 'Giá vượt / giờ', currency: 'Tiền tệ', create_title: 'Gói mới cho tổ chức', create_submit: 'Tạo gói', select_org: 'Tổ chức', select_org_placeholder: 'Chọn tổ chức…' },
|
||||
sessions: { device: 'Thiết bị', operator: 'Người vận hành', duration: 'Thời lượng', phase: 'Giai đoạn', amount: 'Số tiền' },
|
||||
reports: { session: 'Phiên', summary: 'Tóm tắt', date: 'Ngày' },
|
||||
empty: { packages: 'Chưa có gói nào. Tạo gói bằng nút phía trên.', contracts: 'Chưa có hợp đồng tổ chức. Gán gói cho tổ chức.', sessions: 'Chưa có phiên tính phí nào.', reports: 'Chưa có báo cáo công việc.' },
|
||||
assign: { title: 'Gán gói hiện có', submit: 'Gán' },
|
||||
form: { cancel: 'Hủy' }
|
||||
},
|
||||
zh: {
|
||||
tabs: { overview: '概览', packages: '套餐与合同', sessions: '会话', reports: '报告', settings: '高级设置' },
|
||||
clock: { title: '服务器时间 (NTP)', check_now: '立即检查', unsynced: '服务器时钟未同步 — 可计费会话可能被阻止' },
|
||||
stats: { active_sessions: '活动可计费会话' },
|
||||
packages: { new: '新建套餐', name: '名称', included_minutes: '包含分钟', overage_rate: '超额费率 / 小时', currency: '货币', create_title: '为组织新建套餐', create_submit: '创建套餐', select_org: '组织', select_org_placeholder: '选择组织…' },
|
||||
sessions: { device: '设备', operator: '操作员', duration: '时长', phase: '阶段', amount: '金额' },
|
||||
reports: { session: '会话', summary: '摘要', date: '日期' },
|
||||
empty: { packages: '尚无套餐。请使用上方按钮创建。', contracts: '尚无组织合同。请为组织分配套餐。', sessions: '尚无已记录的可计费会话。', reports: '尚无已提交的工作报告。' },
|
||||
assign: { title: '分配现有套餐', submit: '分配' },
|
||||
form: { cancel: '取消' }
|
||||
},
|
||||
'zh-TW': {
|
||||
tabs: { overview: '概覽', packages: '套件與合約', sessions: '工作階段', reports: '報告', settings: '進階設定' },
|
||||
clock: { title: '伺服器時間 (NTP)', check_now: '立即檢查', unsynced: '伺服器時鐘未同步 — 可計費工作階段可能被封鎖' },
|
||||
stats: { active_sessions: '使用中可計費工作階段' },
|
||||
packages: { new: '新增套件', name: '名稱', included_minutes: '包含分鐘', overage_rate: '超額費率 / 小時', currency: '貨幣', create_title: '為組織新增套件', create_submit: '建立套件', select_org: '組織', select_org_placeholder: '選擇組織…' },
|
||||
sessions: { device: '裝置', operator: '操作員', duration: '時長', phase: '階段', amount: '金額' },
|
||||
reports: { session: '工作階段', summary: '摘要', date: '日期' },
|
||||
empty: { packages: '尚無套件。請使用上方按鈕建立。', contracts: '尚無組織合約。請為組織指派套件。', sessions: '尚無已記錄的可計費工作階段。', reports: '尚無已提交的工作報告。' },
|
||||
assign: { title: '指派現有套件', submit: '指派' },
|
||||
form: { cancel: '取消' }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sectionPatches = require('./commercialization-i18n-sections');
|
||||
|
||||
const langDir = path.join(__dirname, '..', 'lang');
|
||||
|
||||
const MERGE_SECTIONS = ['tabs', 'clock', 'stats', 'packages', 'sessions', 'reports', 'contracts', 'export', 'report', 'empty', 'assign', 'form'];
|
||||
|
||||
const patches = {
|
||||
en: {
|
||||
packages: { heading: 'Support packages' },
|
||||
contracts: {
|
||||
title: 'Organization contracts',
|
||||
new: 'Assign package',
|
||||
org: 'Organization',
|
||||
package: 'Package',
|
||||
remaining: 'Remaining minutes',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
suspend: 'Suspend',
|
||||
activate: 'Activate',
|
||||
select_org: 'Select organization',
|
||||
select_package: 'Select package',
|
||||
no_orgs: 'No organizations found',
|
||||
no_packages: 'Create a package first'
|
||||
},
|
||||
export: {
|
||||
csv: 'Export CSV',
|
||||
pdf: 'Export PDF',
|
||||
sessions_csv: 'Export sessions CSV'
|
||||
},
|
||||
report: {
|
||||
title: 'Work report',
|
||||
subtitle: 'Describe the work performed during this remote session.',
|
||||
category: 'Category',
|
||||
ticket_ref: 'Ticket reference',
|
||||
summary: 'Work performed',
|
||||
skip: 'Skip for now',
|
||||
submit: 'Submit report',
|
||||
summary_required: 'Summary is required',
|
||||
saved: 'Work report saved'
|
||||
},
|
||||
packages_extra: { prompt_name: 'Package name' }
|
||||
},
|
||||
pl: {
|
||||
packages: { heading: 'Pakiety wsparcia' },
|
||||
contracts: {
|
||||
title: 'Kontrakty organizacji',
|
||||
new: 'Przypisz pakiet',
|
||||
org: 'Organizacja',
|
||||
package: 'Pakiet',
|
||||
remaining: 'Pozostałe minuty',
|
||||
status: 'Status',
|
||||
actions: 'Akcje',
|
||||
suspend: 'Wstrzymaj',
|
||||
activate: 'Aktywuj',
|
||||
select_org: 'Wybierz organizację',
|
||||
select_package: 'Wybierz pakiet',
|
||||
no_orgs: 'Brak organizacji',
|
||||
no_packages: 'Najpierw utwórz pakiet'
|
||||
},
|
||||
export: {
|
||||
csv: 'Eksport CSV',
|
||||
pdf: 'Eksport PDF',
|
||||
sessions_csv: 'Eksport sesji CSV'
|
||||
},
|
||||
report: {
|
||||
title: 'Raport pracy',
|
||||
subtitle: 'Opisz wykonaną pracę podczas tej sesji zdalnej.',
|
||||
category: 'Kategoria',
|
||||
ticket_ref: 'Numer zgłoszenia',
|
||||
summary: 'Wykonana praca',
|
||||
skip: 'Pomiń na razie',
|
||||
submit: 'Wyślij raport',
|
||||
summary_required: 'Podsumowanie jest wymagane',
|
||||
saved: 'Raport pracy zapisany'
|
||||
},
|
||||
packages_extra: { prompt_name: 'Nazwa pakietu' }
|
||||
},
|
||||
de: {
|
||||
packages: { heading: 'Support-Pakete' },
|
||||
contracts: { title: 'Organisationsverträge', new: 'Paket zuweisen', org: 'Organisation', package: 'Paket', remaining: 'Verbleibende Minuten', status: 'Status', actions: 'Aktionen', suspend: 'Aussetzen', activate: 'Aktivieren', select_org: 'Organisation wählen', select_package: 'Paket wählen', no_orgs: 'Keine Organisationen gefunden', no_packages: 'Erstellen Sie zuerst ein Paket' },
|
||||
export: { csv: 'CSV exportieren', pdf: 'PDF exportieren', sessions_csv: 'Sitzungen als CSV exportieren' },
|
||||
report: { title: 'Arbeitsbericht', subtitle: 'Beschreiben Sie die während dieser Fernsitzung durchgeführte Arbeit.', category: 'Kategorie', ticket_ref: 'Ticket-Referenz', summary: 'Durchgeführte Arbeit', skip: 'Vorerst überspringen', submit: 'Bericht senden', summary_required: 'Zusammenfassung ist erforderlich', saved: 'Arbeitsbericht gespeichert' },
|
||||
packages_extra: { prompt_name: 'Paketname' }
|
||||
},
|
||||
fr: {
|
||||
packages: { heading: 'Forfaits de support' },
|
||||
contracts: { title: 'Contrats organisationnels', new: 'Assigner un forfait', org: 'Organisation', package: 'Forfait', remaining: 'Minutes restantes', status: 'Statut', actions: 'Actions', suspend: 'Suspendre', activate: 'Activer', select_org: 'Sélectionner une organisation', select_package: 'Sélectionner un forfait', no_orgs: 'Aucune organisation trouvée', no_packages: 'Créez d\'abord un forfait' },
|
||||
export: { csv: 'Exporter CSV', pdf: 'Exporter PDF', sessions_csv: 'Exporter les sessions CSV' },
|
||||
report: { title: 'Rapport de travail', subtitle: 'Décrivez le travail effectué pendant cette session à distance.', category: 'Catégorie', ticket_ref: 'Référence ticket', summary: 'Travail effectué', skip: 'Ignorer pour l\'instant', submit: 'Envoyer le rapport', summary_required: 'Le résumé est obligatoire', saved: 'Rapport de travail enregistré' },
|
||||
packages_extra: { prompt_name: 'Nom du forfait' }
|
||||
},
|
||||
es: {
|
||||
packages: { heading: 'Paquetes de soporte' },
|
||||
contracts: { title: 'Contratos de organización', new: 'Asignar paquete', org: 'Organización', package: 'Paquete', remaining: 'Minutos restantes', status: 'Estado', actions: 'Acciones', suspend: 'Suspender', activate: 'Activar', select_org: 'Seleccionar organización', select_package: 'Seleccionar paquete', no_orgs: 'No se encontraron organizaciones', no_packages: 'Cree un paquete primero' },
|
||||
export: { csv: 'Exportar CSV', pdf: 'Exportar PDF', sessions_csv: 'Exportar sesiones CSV' },
|
||||
report: { title: 'Informe de trabajo', subtitle: 'Describa el trabajo realizado durante esta sesión remota.', category: 'Categoría', ticket_ref: 'Referencia de ticket', summary: 'Trabajo realizado', skip: 'Omitir por ahora', submit: 'Enviar informe', summary_required: 'El resumen es obligatorio', saved: 'Informe de trabajo guardado' },
|
||||
packages_extra: { prompt_name: 'Nombre del paquete' }
|
||||
},
|
||||
cs: {
|
||||
packages: { heading: 'Balíčky podpory' },
|
||||
contracts: { title: 'Smlouvy organizací', new: 'Přiřadit balíček', org: 'Organizace', package: 'Balíček', remaining: 'Zbývající minuty', status: 'Stav', actions: 'Akce', suspend: 'Pozastavit', activate: 'Aktivovat', select_org: 'Vyberte organizaci', select_package: 'Vyberte balíček', no_orgs: 'Nebyly nalezeny žádné organizace', no_packages: 'Nejprve vytvořte balíček' },
|
||||
export: { csv: 'Exportovat CSV', pdf: 'Exportovat PDF', sessions_csv: 'Exportovat relace CSV' },
|
||||
report: { title: 'Pracovní zpráva', subtitle: 'Popište práci provedenou během této vzdálené relace.', category: 'Kategorie', ticket_ref: 'Reference ticketu', summary: 'Provedená práce', skip: 'Přeskočit nyní', submit: 'Odeslat zprávu', summary_required: 'Shrnutí je povinné', saved: 'Pracovní zpráva uložena' },
|
||||
packages_extra: { prompt_name: 'Název balíčku' }
|
||||
},
|
||||
da: {
|
||||
packages: { heading: 'Supportpakker' },
|
||||
contracts: { title: 'Organisationskontrakter', new: 'Tildel pakke', org: 'Organisation', package: 'Pakke', remaining: 'Resterende minutter', status: 'Status', actions: 'Handlinger', suspend: 'Suspender', activate: 'Aktiver', select_org: 'Vælg organisation', select_package: 'Vælg pakke', no_orgs: 'Ingen organisationer fundet', no_packages: 'Opret først en pakke' },
|
||||
export: { csv: 'Eksporter CSV', pdf: 'Eksporter PDF', sessions_csv: 'Eksporter sessioner CSV' },
|
||||
report: { title: 'Arbejdsrapport', subtitle: 'Beskriv arbejdet udført under denne fjernsession.', category: 'Kategori', ticket_ref: 'Ticketreference', summary: 'Udført arbejde', skip: 'Spring over for nu', submit: 'Send rapport', summary_required: 'Resumé er påkrævet', saved: 'Arbejdsrapport gemt' },
|
||||
packages_extra: { prompt_name: 'Pakkenavn' }
|
||||
},
|
||||
fi: {
|
||||
packages: { heading: 'Tukipaketit' },
|
||||
contracts: { title: 'Organisaatiosopimukset', new: 'Määritä paketti', org: 'Organisaatio', package: 'Paketti', remaining: 'Jäljellä olevat minuutit', status: 'Tila', actions: 'Toiminnot', suspend: 'Keskeytä', activate: 'Aktivoi', select_org: 'Valitse organisaatio', select_package: 'Valitse paketti', no_orgs: 'Organisaatioita ei löytynyt', no_packages: 'Luo ensin paketti' },
|
||||
export: { csv: 'Vie CSV', pdf: 'Vie PDF', sessions_csv: 'Vie istunnot CSV' },
|
||||
report: { title: 'Työraportti', subtitle: 'Kuvaile tämän etäistunnon aikana tehty työ.', category: 'Kategoria', ticket_ref: 'Tikettiviite', summary: 'Tehty työ', skip: 'Ohita toistaiseksi', submit: 'Lähetä raportti', summary_required: 'Yhteenveto vaaditaan', saved: 'Työraportti tallennettu' },
|
||||
packages_extra: { prompt_name: 'Paketin nimi' }
|
||||
},
|
||||
it: {
|
||||
packages: { heading: 'Pacchetti di supporto' },
|
||||
contracts: { title: 'Contratti organizzazione', new: 'Assegna pacchetto', org: 'Organizzazione', package: 'Pacchetto', remaining: 'Minuti rimanenti', status: 'Stato', actions: 'Azioni', suspend: 'Sospendi', activate: 'Attiva', select_org: 'Seleziona organizzazione', select_package: 'Seleziona pacchetto', no_orgs: 'Nessuna organizzazione trovata', no_packages: 'Crea prima un pacchetto' },
|
||||
export: { csv: 'Esporta CSV', pdf: 'Esporta PDF', sessions_csv: 'Esporta sessioni CSV' },
|
||||
report: { title: 'Report di lavoro', subtitle: 'Descrivi il lavoro svolto durante questa sessione remota.', category: 'Categoria', ticket_ref: 'Riferimento ticket', summary: 'Lavoro svolto', skip: 'Salta per ora', submit: 'Invia report', summary_required: 'Il riepilogo è obbligatorio', saved: 'Report di lavoro salvato' },
|
||||
packages_extra: { prompt_name: 'Nome pacchetto' }
|
||||
},
|
||||
nl: {
|
||||
packages: { heading: 'Supportpakketten' },
|
||||
contracts: { title: 'Organisatiecontracten', new: 'Pakket toewijzen', org: 'Organisatie', package: 'Pakket', remaining: 'Resterende minuten', status: 'Status', actions: 'Acties', suspend: 'Opschorten', activate: 'Activeren', select_org: 'Selecteer organisatie', select_package: 'Selecteer pakket', no_orgs: 'Geen organisaties gevonden', no_packages: 'Maak eerst een pakket aan' },
|
||||
export: { csv: 'Exporteer CSV', pdf: 'Exporteer PDF', sessions_csv: 'Exporteer sessies CSV' },
|
||||
report: { title: 'Werkrapport', subtitle: 'Beschrijf het uitgevoerde werk tijdens deze remote sessie.', category: 'Categorie', ticket_ref: 'Ticketreferentie', summary: 'Uitgevoerd werk', skip: 'Nu overslaan', submit: 'Rapport verzenden', summary_required: 'Samenvatting is verplicht', saved: 'Werkrapport opgeslagen' },
|
||||
packages_extra: { prompt_name: 'Pakketnaam' }
|
||||
},
|
||||
nb: {
|
||||
packages: { heading: 'Støttepakker' },
|
||||
contracts: { title: 'Organisasjonskontrakter', new: 'Tildel pakke', org: 'Organisasjon', package: 'Pakke', remaining: 'Gjenstående minutter', status: 'Status', actions: 'Handlinger', suspend: 'Suspender', activate: 'Aktiver', select_org: 'Velg organisasjon', select_package: 'Velg pakke', no_orgs: 'Ingen organisasjoner funnet', no_packages: 'Opprett en pakke først' },
|
||||
export: { csv: 'Eksporter CSV', pdf: 'Eksporter PDF', sessions_csv: 'Eksporter økter CSV' },
|
||||
report: { title: 'Arbeidsrapport', subtitle: 'Beskriv arbeidet utført under denne fjernøkten.', category: 'Kategori', ticket_ref: 'Saksreferanse', summary: 'Utført arbeid', skip: 'Hopp over nå', submit: 'Send rapport', summary_required: 'Sammendrag er påkrevd', saved: 'Arbeidsrapport lagret' },
|
||||
packages_extra: { prompt_name: 'Pakkenavn' }
|
||||
},
|
||||
sv: {
|
||||
packages: { heading: 'Supportpaket' },
|
||||
contracts: { title: 'Organisationsavtal', new: 'Tilldela paket', org: 'Organisation', package: 'Paket', remaining: 'Återstående minuter', status: 'Status', actions: 'Åtgärder', suspend: 'Suspendera', activate: 'Aktivera', select_org: 'Välj organisation', select_package: 'Välj paket', no_orgs: 'Inga organisationer hittades', no_packages: 'Skapa ett paket först' },
|
||||
export: { csv: 'Exportera CSV', pdf: 'Exportera PDF', sessions_csv: 'Exportera sessioner CSV' },
|
||||
report: { title: 'Arbetsrapport', subtitle: 'Beskriv arbetet som utfördes under denna fjärrsession.', category: 'Kategori', ticket_ref: 'Ärendereferens', summary: 'Utfört arbete', skip: 'Hoppa över nu', submit: 'Skicka rapport', summary_required: 'Sammanfattning krävs', saved: 'Arbetsrapport sparad' },
|
||||
packages_extra: { prompt_name: 'Paketnamn' }
|
||||
},
|
||||
pt: {
|
||||
packages: { heading: 'Pacotes de suporte' },
|
||||
contracts: { title: 'Contratos de organização', new: 'Atribuir pacote', org: 'Organização', package: 'Pacote', remaining: 'Minutos restantes', status: 'Estado', actions: 'Ações', suspend: 'Suspender', activate: 'Ativar', select_org: 'Selecionar organização', select_package: 'Selecionar pacote', no_orgs: 'Nenhuma organização encontrada', no_packages: 'Crie um pacote primeiro' },
|
||||
export: { csv: 'Exportar CSV', pdf: 'Exportar PDF', sessions_csv: 'Exportar sessões CSV' },
|
||||
report: { title: 'Relatório de trabalho', subtitle: 'Descreva o trabalho realizado durante esta sessão remota.', category: 'Categoria', ticket_ref: 'Referência do ticket', summary: 'Trabalho realizado', skip: 'Ignorar por agora', submit: 'Enviar relatório', summary_required: 'O resumo é obrigatório', saved: 'Relatório de trabalho guardado' },
|
||||
packages_extra: { prompt_name: 'Nome do pacote' }
|
||||
},
|
||||
ro: {
|
||||
packages: { heading: 'Pachete de suport' },
|
||||
contracts: { title: 'Contracte organizație', new: 'Atribuie pachet', org: 'Organizație', package: 'Pachet', remaining: 'Minute rămase', status: 'Stare', actions: 'Acțiuni', suspend: 'Suspendă', activate: 'Activează', select_org: 'Selectează organizația', select_package: 'Selectează pachetul', no_orgs: 'Nu s-au găsit organizații', no_packages: 'Creați mai întâi un pachet' },
|
||||
export: { csv: 'Export CSV', pdf: 'Export PDF', sessions_csv: 'Export sesiuni CSV' },
|
||||
report: { title: 'Raport de lucru', subtitle: 'Descrieți munca efectuată în timpul acestei sesiuni la distanță.', category: 'Categorie', ticket_ref: 'Referință tichet', summary: 'Lucru efectuat', skip: 'Omite deocamdată', submit: 'Trimite raport', summary_required: 'Rezumatul este obligatoriu', saved: 'Raport de lucru salvat' },
|
||||
packages_extra: { prompt_name: 'Nume pachet' }
|
||||
},
|
||||
hu: {
|
||||
packages: { heading: 'Támogatási csomagok' },
|
||||
contracts: { title: 'Szervezeti szerződések', new: 'Csomag hozzárendelése', org: 'Szervezet', package: 'Csomag', remaining: 'Hátralévő percek', status: 'Állapot', actions: 'Műveletek', suspend: 'Felfüggesztés', activate: 'Aktiválás', select_org: 'Válasszon szervezetet', select_package: 'Válasszon csomagot', no_orgs: 'Nem található szervezet', no_packages: 'Először hozzon létre csomagot' },
|
||||
export: { csv: 'CSV export', pdf: 'PDF export', sessions_csv: 'Munkamenetek CSV export' },
|
||||
report: { title: 'Munkajelentés', subtitle: 'Írja le a távoli munkamenet során elvégzett munkát.', category: 'Kategória', ticket_ref: 'Jegy hivatkozás', summary: 'Elvégzett munka', skip: 'Kihagyás most', submit: 'Jelentés küldése', summary_required: 'Az összefoglaló kötelező', saved: 'Munkajelentés mentve' },
|
||||
packages_extra: { prompt_name: 'Csomag neve' }
|
||||
},
|
||||
uk: {
|
||||
packages: { heading: 'Пакети підтримки' },
|
||||
contracts: { title: 'Контракти організацій', new: 'Призначити пакет', org: 'Організація', package: 'Пакет', remaining: 'Залишок хвилин', status: 'Статус', actions: 'Дії', suspend: 'Призупинити', activate: 'Активувати', select_org: 'Оберіть організацію', select_package: 'Оберіть пакет', no_orgs: 'Організації не знайдено', no_packages: 'Спочатку створіть пакет' },
|
||||
export: { csv: 'Експорт CSV', pdf: 'Експорт PDF', sessions_csv: 'Експорт сесій CSV' },
|
||||
report: { title: 'Звіт про роботу', subtitle: 'Опишіть роботу, виконану під час цієї віддаленої сесії.', category: 'Категорія', ticket_ref: 'Посилання на заявку', summary: 'Виконана робота', skip: 'Пропустити зараз', submit: 'Надіслати звіт', summary_required: 'Підсумок обов\'язковий', saved: 'Звіт про роботу збережено' },
|
||||
packages_extra: { prompt_name: 'Назва пакета' }
|
||||
},
|
||||
ru: {},
|
||||
tr: {
|
||||
packages: { heading: 'Destek paketleri' },
|
||||
contracts: { title: 'Kuruluş sözleşmeleri', new: 'Paket ata', org: 'Kuruluş', package: 'Paket', remaining: 'Kalan dakika', status: 'Durum', actions: 'İşlemler', suspend: 'Askıya al', activate: 'Etkinleştir', select_org: 'Kuruluş seçin', select_package: 'Paket seçin', no_orgs: 'Kuruluş bulunamadı', no_packages: 'Önce bir paket oluşturun' },
|
||||
export: { csv: 'CSV dışa aktar', pdf: 'PDF dışa aktar', sessions_csv: 'Oturumları CSV dışa aktar' },
|
||||
report: { title: 'İş raporu', subtitle: 'Bu uzaktan oturum sırasında yapılan işi açıklayın.', category: 'Kategori', ticket_ref: 'Bilet referansı', summary: 'Yapılan iş', skip: 'Şimdilik atla', submit: 'Rapor gönder', summary_required: 'Özet gerekli', saved: 'İş raporu kaydedildi' },
|
||||
packages_extra: { prompt_name: 'Paket adı' }
|
||||
},
|
||||
ja: {
|
||||
packages: { heading: 'サポートパッケージ' },
|
||||
contracts: { title: '組織契約', new: 'パッケージを割り当て', org: '組織', package: 'パッケージ', remaining: '残り分数', status: 'ステータス', actions: '操作', suspend: '一時停止', activate: '有効化', select_org: '組織を選択', select_package: 'パッケージを選択', no_orgs: '組織が見つかりません', no_packages: '先にパッケージを作成してください' },
|
||||
export: { csv: 'CSVエクスポート', pdf: 'PDFエクスポート', sessions_csv: 'セッションCSVエクスポート' },
|
||||
report: { title: '作業レポート', subtitle: 'このリモートセッション中に行った作業を説明してください。', category: 'カテゴリ', ticket_ref: 'チケット参照', summary: '実施した作業', skip: '今はスキップ', submit: 'レポートを送信', summary_required: '概要は必須です', saved: '作業レポートを保存しました' },
|
||||
packages_extra: { prompt_name: 'パッケージ名' }
|
||||
},
|
||||
ko: {
|
||||
packages: { heading: '지원 패키지' },
|
||||
contracts: { title: '조직 계약', new: '패키지 할당', org: '조직', package: '패키지', remaining: '남은 분', status: '상태', actions: '작업', suspend: '일시 중지', activate: '활성화', select_org: '조직 선택', select_package: '패키지 선택', no_orgs: '조직을 찾을 수 없습니다', no_packages: '먼저 패키지를 만드세요' },
|
||||
export: { csv: 'CSV 내보내기', pdf: 'PDF 내보내기', sessions_csv: '세션 CSV 내보내기' },
|
||||
report: { title: '작업 보고서', subtitle: '이 원격 세션 동안 수행한 작업을 설명하세요.', category: '카테고리', ticket_ref: '티켓 참조', summary: '수행한 작업', skip: '지금 건너뛰기', submit: '보고서 제출', summary_required: '요약은 필수입니다', saved: '작업 보고서가 저장되었습니다' },
|
||||
packages_extra: { prompt_name: '패키지 이름' }
|
||||
},
|
||||
zh: {
|
||||
packages: { heading: '支持套餐' },
|
||||
contracts: { title: '组织合同', new: '分配套餐', org: '组织', package: '套餐', remaining: '剩余分钟', status: '状态', actions: '操作', suspend: '暂停', activate: '激活', select_org: '选择组织', select_package: '选择套餐', no_orgs: '未找到组织', no_packages: '请先创建套餐' },
|
||||
export: { csv: '导出 CSV', pdf: '导出 PDF', sessions_csv: '导出会话 CSV' },
|
||||
report: { title: '工作报告', subtitle: '描述此远程会话期间执行的工作。', category: '类别', ticket_ref: '工单参考', summary: '执行的工作', skip: '暂时跳过', submit: '提交报告', summary_required: '摘要为必填项', saved: '工作报告已保存' },
|
||||
packages_extra: { prompt_name: '套餐名称' }
|
||||
},
|
||||
'zh-TW': {
|
||||
packages: { heading: '支援套件' },
|
||||
contracts: { title: '組織合約', new: '指派套件', org: '組織', package: '套件', remaining: '剩餘分鐘', status: '狀態', actions: '操作', suspend: '暫停', activate: '啟用', select_org: '選擇組織', select_package: '選擇套件', no_orgs: '找不到組織', no_packages: '請先建立套件' },
|
||||
export: { csv: '匯出 CSV', pdf: '匯出 PDF', sessions_csv: '匯出工作階段 CSV' },
|
||||
report: { title: '工作報告', subtitle: '描述此遠端工作階段期間執行的工作。', category: '類別', ticket_ref: '工單參考', summary: '執行的工作', skip: '暫時略過', submit: '提交報告', summary_required: '摘要為必填', saved: '工作報告已儲存' },
|
||||
packages_extra: { prompt_name: '套件名稱' }
|
||||
},
|
||||
ar: {
|
||||
packages: { heading: 'حزم الدعم' },
|
||||
contracts: { title: 'عقود المنظمات', new: 'تعيين حزمة', org: 'المنظمة', package: 'الحزمة', remaining: 'الدقائق المتبقية', status: 'الحالة', actions: 'الإجراءات', suspend: 'تعليق', activate: 'تفعيل', select_org: 'اختر المنظمة', select_package: 'اختر الحزمة', no_orgs: 'لم يتم العثور على منظمات', no_packages: 'أنشئ حزمة أولاً' },
|
||||
export: { csv: 'تصدير CSV', pdf: 'تصدير PDF', sessions_csv: 'تصدير الجلسات CSV' },
|
||||
report: { title: 'تقرير العمل', subtitle: 'صف العمل الذي تم خلال جلسة التحكم عن بُعد هذه.', category: 'الفئة', ticket_ref: 'مرجع التذكرة', summary: 'العمل المنجز', skip: 'تخطي الآن', submit: 'إرسال التقرير', summary_required: 'الملخص مطلوب', saved: 'تم حفظ تقرير العمل' },
|
||||
packages_extra: { prompt_name: 'اسم الحزمة' }
|
||||
},
|
||||
hi: {
|
||||
packages: { heading: 'सहायता पैकेज' },
|
||||
contracts: { title: 'संगठन अनुबंध', new: 'पैकेज असाइन करें', org: 'संगठन', package: 'पैकेज', remaining: 'शेष मिनट', status: 'स्थिति', actions: 'कार्रवाइयाँ', suspend: 'निलंबित', activate: 'सक्रिय', select_org: 'संगठन चुनें', select_package: 'पैकेज चुनें', no_orgs: 'कोई संगठन नहीं मिला', no_packages: 'पहले एक पैकेज बनाएं' },
|
||||
export: { csv: 'CSV निर्यात', pdf: 'PDF निर्यात', sessions_csv: 'सत्र CSV निर्यात' },
|
||||
report: { title: 'कार्य रिपोर्ट', subtitle: 'इस रिमोट सत्र के दौरान किए गए कार्य का वर्णन करें।', category: 'श्रेणी', ticket_ref: 'टिकट संदर्भ', summary: 'किया गया कार्य', skip: 'अभी छोड़ें', submit: 'रिपोर्ट भेजें', summary_required: 'सारांश आवश्यक है', saved: 'कार्य रिपोर्ट सहेजी गई' },
|
||||
packages_extra: { prompt_name: 'पैकेज का नाम' }
|
||||
},
|
||||
id: {
|
||||
packages: { heading: 'Paket dukungan' },
|
||||
contracts: { title: 'Kontrak organisasi', new: 'Tetapkan paket', org: 'Organisasi', package: 'Paket', remaining: 'Menit tersisa', status: 'Status', actions: 'Tindakan', suspend: 'Tangguhkan', activate: 'Aktifkan', select_org: 'Pilih organisasi', select_package: 'Pilih paket', no_orgs: 'Organisasi tidak ditemukan', no_packages: 'Buat paket terlebih dahulu' },
|
||||
export: { csv: 'Ekspor CSV', pdf: 'Ekspor PDF', sessions_csv: 'Ekspor sesi CSV' },
|
||||
report: { title: 'Laporan kerja', subtitle: 'Jelaskan pekerjaan yang dilakukan selama sesi jarak jauh ini.', category: 'Kategori', ticket_ref: 'Referensi tiket', summary: 'Pekerjaan yang dilakukan', skip: 'Lewati untuk sekarang', submit: 'Kirim laporan', summary_required: 'Ringkasan wajib diisi', saved: 'Laporan kerja disimpan' },
|
||||
packages_extra: { prompt_name: 'Nama paket' }
|
||||
},
|
||||
vi: {
|
||||
packages: { heading: 'Gói hỗ trợ' },
|
||||
contracts: { title: 'Hợp đồng tổ chức', new: 'Gán gói', org: 'Tổ chức', package: 'Gói', remaining: 'Phút còn lại', status: 'Trạng thái', actions: 'Thao tác', suspend: 'Tạm ngưng', activate: 'Kích hoạt', select_org: 'Chọn tổ chức', select_package: 'Chọn gói', no_orgs: 'Không tìm thấy tổ chức', no_packages: 'Hãy tạo gói trước' },
|
||||
export: { csv: 'Xuất CSV', pdf: 'Xuất PDF', sessions_csv: 'Xuất phiên CSV' },
|
||||
report: { title: 'Báo cáo công việc', subtitle: 'Mô tả công việc đã thực hiện trong phiên điều khiển từ xa này.', category: 'Danh mục', ticket_ref: 'Tham chiếu ticket', summary: 'Công việc đã thực hiện', skip: 'Bỏ qua bây giờ', submit: 'Gửi báo cáo', summary_required: 'Tóm tắt là bắt buộc', saved: 'Đã lưu báo cáo công việc' },
|
||||
packages_extra: { prompt_name: 'Tên gói' }
|
||||
},
|
||||
th: {
|
||||
packages: { heading: 'แพ็กเกจสนับสนุน' },
|
||||
contracts: { title: 'สัญญาองค์กร', new: 'กำหนดแพ็กเกจ', org: 'องค์กร', package: 'แพ็กเกจ', remaining: 'นาทีที่เหลือ', status: 'สถานะ', actions: 'การดำเนินการ', suspend: 'ระงับ', activate: 'เปิดใช้งาน', select_org: 'เลือกองค์กร', select_package: 'เลือกแพ็กเกจ', no_orgs: 'ไม่พบองค์กร', no_packages: 'สร้างแพ็กเกจก่อน' },
|
||||
export: { csv: 'ส่งออก CSV', pdf: 'ส่งออก PDF', sessions_csv: 'ส่งออกเซสชัน CSV' },
|
||||
report: { title: 'รายงานการทำงาน', subtitle: 'อธิบายงานที่ทำระหว่างเซสชันรีโมตนี้', category: 'หมวดหมู่', ticket_ref: 'อ้างอิงตั๋ว', summary: 'งานที่ทำ', skip: 'ข้ามไปก่อน', submit: 'ส่งรายงาน', summary_required: 'ต้องมีสรุป', saved: 'บันทึกรายงานการทำงานแล้ว' },
|
||||
packages_extra: { prompt_name: 'ชื่อแพ็กเกจ' }
|
||||
}
|
||||
};
|
||||
|
||||
function deepMerge(target, source) {
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
target[k] = target[k] || {};
|
||||
deepMerge(target[k], v);
|
||||
} else {
|
||||
target[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPatch(locale) {
|
||||
const base = patches[locale] || patches.en;
|
||||
const sections = sectionPatches[locale] || {};
|
||||
const merged = { ...base };
|
||||
for (const key of MERGE_SECTIONS) {
|
||||
if (sections[key]) {
|
||||
merged[key] = { ...(merged[key] || {}), ...sections[key] };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function applyCommercializationPatches() {
|
||||
let count = 0;
|
||||
for (const file of fs.readdirSync(langDir).filter((f) => f.endsWith('.json'))) {
|
||||
const locale = file.replace('.json', '');
|
||||
if (locale === 'en') continue;
|
||||
|
||||
const filePath = path.join(langDir, file);
|
||||
const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!json.commercialization) continue;
|
||||
|
||||
const patch = buildPatch(locale);
|
||||
for (const section of MERGE_SECTIONS) {
|
||||
if (!patch[section]) continue;
|
||||
json.commercialization[section] = { ...(json.commercialization[section] || {}), ...patch[section] };
|
||||
}
|
||||
if (patch.packages_extra) {
|
||||
deepMerge(json.commercialization.packages, patch.packages_extra);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
|
||||
console.log('patched', file);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const count = applyCommercializationPatches();
|
||||
if (count === 0) {
|
||||
console.error('No commercialization locale files patched.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { applyCommercializationPatches, buildPatch, deepMerge };
|
||||
@@ -0,0 +1,172 @@
|
||||
<%- include('layouts/main', {
|
||||
title: title,
|
||||
pageStyles: pageStyles,
|
||||
pageScripts: pageScripts,
|
||||
currentPage: currentPage,
|
||||
breadcrumb: breadcrumb,
|
||||
body: `
|
||||
|
||||
<div class="commercialization-page" data-active-tab="${typeof activeTab !== 'undefined' ? activeTab : 'overview'}">
|
||||
<div class="page-header">
|
||||
<h1>${activeTab === 'overview' ? _('commercialization.title') : _('commercialization.tabs.' + activeTab)} <span class="beta-badge" title="${_('commercialization.alpha_tooltip')}">${_('commercialization.alpha_badge')}</span></h1>
|
||||
<p class="page-subtitle">${activeTab === 'overview' ? _('commercialization.subtitle') : _('commercialization.title')}</p>
|
||||
</div>
|
||||
|
||||
<div id="timesync-banner" class="timesync-banner hidden"></div>
|
||||
|
||||
<section id="tab-overview" class="tab-panel ${activeTab === 'overview' ? '' : 'hidden'}">
|
||||
<div class="card-grid">
|
||||
<div class="stat-card" id="stat-clock">
|
||||
<h3>${_('commercialization.clock.title')}</h3>
|
||||
<p class="stat-value" id="clock-status">—</p>
|
||||
<p class="stat-meta" id="clock-offset"></p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>${_('commercialization.stats.active_sessions')}</h3>
|
||||
<p class="stat-value" id="stat-active-sessions">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-packages" class="tab-panel ${activeTab === 'packages' ? '' : 'hidden'}">
|
||||
<div class="panel-toolbar">
|
||||
<button type="button" class="btn btn-primary" id="btn-new-package">${_('commercialization.packages.new')}</button>
|
||||
</div>
|
||||
<h3 class="section-heading">${_('commercialization.packages.heading')}</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="packages-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${_('commercialization.packages.name')}</th>
|
||||
<th>${_('commercialization.packages.included_minutes')}</th>
|
||||
<th>${_('commercialization.packages.overage_rate')}</th>
|
||||
<th>${_('commercialization.packages.currency')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 class="section-heading">${_('commercialization.contracts.title')}</h3>
|
||||
<div class="panel-toolbar">
|
||||
<button type="button" class="btn btn-primary" id="btn-new-contract">${_('commercialization.contracts.new')}</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="contracts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${_('commercialization.contracts.org')}</th>
|
||||
<th>${_('commercialization.contracts.package')}</th>
|
||||
<th>${_('commercialization.contracts.remaining')}</th>
|
||||
<th>${_('commercialization.contracts.status')}</th>
|
||||
<th>${_('commercialization.contracts.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-sessions" class="tab-panel ${activeTab === 'sessions' ? '' : 'hidden'}">
|
||||
<div class="panel-toolbar">
|
||||
<button type="button" class="btn btn-secondary" id="btn-export-sessions">${_('commercialization.export.sessions_csv')}</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${_('commercialization.sessions.device')}</th>
|
||||
<th>${_('commercialization.sessions.operator')}</th>
|
||||
<th>${_('commercialization.sessions.duration')}</th>
|
||||
<th>${_('commercialization.sessions.phase')}</th>
|
||||
<th>${_('commercialization.sessions.amount')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-reports" class="tab-panel ${activeTab === 'reports' ? '' : 'hidden'}">
|
||||
<div class="panel-toolbar">
|
||||
<button type="button" class="btn btn-secondary" id="btn-export-reports-csv">${_('commercialization.export.csv')}</button>
|
||||
<button type="button" class="btn btn-secondary" id="btn-export-reports-pdf">${_('commercialization.export.pdf')}</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${_('commercialization.reports.session')}</th>
|
||||
<th>${_('commercialization.reports.summary')}</th>
|
||||
<th>${_('commercialization.reports.date')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-settings" class="tab-panel ${activeTab === 'settings' ? '' : 'hidden'}">
|
||||
<div class="card">
|
||||
<h3>${_('commercialization.clock.title')}</h3>
|
||||
<p id="settings-clock-detail"></p>
|
||||
<button type="button" class="btn btn-secondary" id="btn-timesync-check">${_('commercialization.clock.check_now')}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="package-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">${_('commercialization.packages.create_title')}</h2>
|
||||
<button type="button" class="modal-close" data-close-modal="package-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">${_('commercialization.packages.select_org')}
|
||||
<select id="package-org-select" class="form-control" required></select>
|
||||
</label>
|
||||
<label class="form-label">${_('commercialization.packages.name')}
|
||||
<input type="text" id="package-name" class="form-control" maxlength="128" required>
|
||||
</label>
|
||||
<label class="form-label">${_('commercialization.packages.included_minutes')}
|
||||
<input type="number" id="package-minutes" class="form-control" value="600" min="1" step="1" required>
|
||||
</label>
|
||||
<label class="form-label">${_('commercialization.packages.overage_rate')}
|
||||
<input type="number" id="package-overage" class="form-control" value="100" min="0" step="0.01" required>
|
||||
</label>
|
||||
<label class="form-label">${_('commercialization.packages.currency')}
|
||||
<input type="text" id="package-currency" class="form-control" value="PLN" maxlength="8" required>
|
||||
</label>
|
||||
<p id="package-modal-error" class="form-error hidden"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal="package-modal">${_('commercialization.form.cancel')}</button>
|
||||
<button type="button" class="btn btn-primary" id="package-modal-submit">${_('commercialization.packages.create_submit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="assign-modal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">${_('commercialization.assign.title')}</h2>
|
||||
<button type="button" class="modal-close" data-close-modal="assign-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label">${_('commercialization.packages.select_org')}
|
||||
<select id="assign-org-select" class="form-control" required></select>
|
||||
</label>
|
||||
<label class="form-label">${_('commercialization.contracts.package')}
|
||||
<select id="assign-package-select" class="form-control" required></select>
|
||||
</label>
|
||||
<p id="assign-modal-error" class="form-error hidden"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal="assign-modal">${_('commercialization.form.cancel')}</button>
|
||||
<button type="button" class="btn btn-primary" id="assign-modal-submit">${_('commercialization.assign.submit')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
`
|
||||
}) %>
|
||||
@@ -1,4 +1,5 @@
|
||||
<% const sidebarServerTab = typeof currentTab !== 'undefined' && currentTab ? currentTab : 'overview'; %>
|
||||
<% const sidebarCommercialTab = typeof currentTab !== 'undefined' && currentTab ? currentTab : 'overview'; %>
|
||||
<!-- Sidebar Navigation — TeamViewer-style icon rail + flyout -->
|
||||
<aside class="sidebar" id="sidebar" data-collapsed="true">
|
||||
<!-- Icon rail (always visible) -->
|
||||
@@ -44,6 +45,13 @@
|
||||
<span class="material-icons">dns</span>
|
||||
</button>
|
||||
<% } %>
|
||||
<% if (hasPermission('billing.view')) { %>
|
||||
<button class="sidebar-rail-btn sidebar-rail-btn--alpha <%= currentPage === 'commercialization' ? 'active' : '' %>"
|
||||
data-category="commercialization" title="<%= _('commercialization.alpha_tooltip') || _('nav.commercialization') || 'Commercialization' %>">
|
||||
<span class="material-icons">payments</span>
|
||||
<span class="rail-stage-badge" aria-hidden="true"><%= _('commercialization.alpha_badge') || 'ALPHA' %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<% if (hasPermission('server.attestation')) { %>
|
||||
<a href="/server-attestation" class="sidebar-rail-btn <%= currentPage === 'server-attestation' ? 'active' : '' %>"
|
||||
title="<%= _('nav.server_attestation') || 'Server Attestation' %>">
|
||||
@@ -71,7 +79,32 @@
|
||||
<div class="sidebar-flyout" id="sidebar-flyout">
|
||||
<div class="sidebar-flyout-header">
|
||||
<span class="sidebar-flyout-title" id="sidebar-flyout-title"></span>
|
||||
<span class="sidebar-logo-text"><%= appName %></span>
|
||||
</div>
|
||||
|
||||
<!-- Commercialization category links -->
|
||||
<div class="sidebar-flyout-panel" data-panel="commercialization">
|
||||
<% if (hasPermission('billing.view')) { %>
|
||||
<a href="/commercialization?tab=overview" class="sidebar-link <%= currentPage === 'commercialization' && sidebarCommercialTab === 'overview' ? 'active' : '' %>">
|
||||
<span class="material-icons">dashboard</span>
|
||||
<span class="sidebar-link-text"><%= _('commercialization.tabs.overview') %></span>
|
||||
</a>
|
||||
<a href="/commercialization?tab=packages" class="sidebar-link <%= currentPage === 'commercialization' && sidebarCommercialTab === 'packages' ? 'active' : '' %>">
|
||||
<span class="material-icons">inventory_2</span>
|
||||
<span class="sidebar-link-text"><%= _('commercialization.tabs.packages') %></span>
|
||||
</a>
|
||||
<a href="/commercialization?tab=sessions" class="sidebar-link <%= currentPage === 'commercialization' && sidebarCommercialTab === 'sessions' ? 'active' : '' %>">
|
||||
<span class="material-icons">schedule</span>
|
||||
<span class="sidebar-link-text"><%= _('commercialization.tabs.sessions') %></span>
|
||||
</a>
|
||||
<a href="/commercialization?tab=reports" class="sidebar-link <%= currentPage === 'commercialization' && sidebarCommercialTab === 'reports' ? 'active' : '' %>">
|
||||
<span class="material-icons">description</span>
|
||||
<span class="sidebar-link-text"><%= _('commercialization.tabs.reports') %></span>
|
||||
</a>
|
||||
<a href="/commercialization?tab=settings" class="sidebar-link <%= currentPage === 'commercialization' && sidebarCommercialTab === 'settings' ? 'active' : '' %>">
|
||||
<span class="material-icons">tune</span>
|
||||
<span class="sidebar-link-text"><%= _('commercialization.tabs.settings') %></span>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<!-- Main category links -->
|
||||
@@ -172,9 +205,10 @@
|
||||
<span class="material-icons">vpn_key</span>
|
||||
<span class="sidebar-link-text"><%= _('nav.keys') %></span>
|
||||
</a>
|
||||
<a href="/generator" class="sidebar-link <%= currentPage === 'generator' ? 'active' : '' %>">
|
||||
<a href="/generator" class="sidebar-link <%= currentPage === 'generator' ? 'active' : '' %>" title="<%= _('generator.alpha_tooltip') || _('nav.generator') %>">
|
||||
<span class="material-icons">build</span>
|
||||
<span class="sidebar-link-text"><%= _('nav.generator') %></span>
|
||||
<span class="badge-sidebar badge-sidebar--alpha"><%= _('generator.alpha_badge') || 'ALPHA' %></span>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (hasPermission('device.connect')) { %>
|
||||
@@ -354,6 +388,7 @@
|
||||
|
||||
// Title mapping
|
||||
var titles = {
|
||||
commercialization: '<%= _("nav.commercialization") || "Commercialization" %>',
|
||||
main: '<%= _("nav.main") %>',
|
||||
management: '<%= _("nav.management") || "Management" %>',
|
||||
tools: '<%= _("nav.tools") %>',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%- include('layouts/viewer', {
|
||||
title: title || _('remote.title'),
|
||||
pageScripts: ['remote'],
|
||||
pageScripts: ['billing-report', 'remote'],
|
||||
body: `
|
||||
|
||||
<!-- Session Tab Bar -->
|
||||
|
||||
Reference in New Issue
Block a user