diff --git a/apps/api-server/cmd/server.go b/apps/api-server/cmd/server.go deleted file mode 100644 index 177de8c..0000000 --- a/apps/api-server/cmd/server.go +++ /dev/null @@ -1,385 +0,0 @@ -package main - -import ( - "context" - "log" - "math" - "net/http" - "os" - "os/signal" - "strconv" - "strings" - "syscall" - "time" - - "github.com/gin-gonic/gin" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/graphql" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/metrics" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -func main() { - // Load configuration - cfg := config.LoadConfig() - - // Initialize database - db, err := database.NewDatabase(&cfg.Database) - if err != nil { - log.Fatalf("Failed to initialize database: %v", err) - } - defer db.Close() - - // Initialize metrics collector - prometheusCollector := metrics.NewMetricsCollector() - prometheusCollector.Register() - - // Initialize time-series storage (optional) - var timeSeriesStorage *metrics.TimeSeriesStorage - if cfg.InfluxDB.URL != "" { - timeSeriesStorage, err = metrics.NewTimeSeriesStorage( - cfg.InfluxDB.URL, - cfg.InfluxDB.Token, - cfg.InfluxDB.Org, - cfg.InfluxDB.Bucket, - ) - if err != nil { - log.Printf("Failed to initialize time-series storage: %v", err) - } - defer func() { - if timeSeriesStorage != nil { - timeSeriesStorage.Close() - } - }() - } - - // Create metrics collector with time-series - var metricsCollector *metrics.MetricsCollectorWithTimeSeries - if timeSeriesStorage != nil { - metricsCollector = metrics.NewMetricsCollectorWithTimeSeries(prometheusCollector, timeSeriesStorage) - } else { - metricsCollector = &metrics.MetricsCollectorWithTimeSeries{ - MetricsCollector: prometheusCollector, - } - } - - // Initialize GraphQL resolver - resolver := graphql.NewResolver(db, cfg) - - // Setup HTTP server with GraphQL handler - router := gin.Default() - graphql.SetupGraphQLHandler(router, resolver) - - // Add metrics middleware - router.Use(func(c *gin.Context) { - // Create a wrapper handler that calls the next handler - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c.Next() - }) - - // Apply prometheus middleware - middlewareHandler := prometheusCollector.HTTPMiddleware(next) - middlewareHandler.ServeHTTP(c.Writer, c.Request) - }) - - // Add metrics endpoint - router.GET("/metrics", gin.WrapH(prometheusCollector.Handler())) - - // Add health check endpoint - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "healthy", - "service": "api-server", - }) - }) - - // Create server - server := &http.Server{ - Addr: ":" + cfg.Server.Port, - Handler: router, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } - - // Start metrics server in background - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - metricsAddr := cfg.Server.MetricsPort - if metricsAddr == "" { - metricsAddr = "9090" - } - log.Printf("Starting metrics server on port %s", metricsAddr) - if err := prometheusCollector.StartMetricsServer(ctx, ":"+metricsAddr); err != nil { - log.Printf("Metrics server error: %v", err) - } - }() - - // Start periodic metrics collection - go startMetricsCollection(ctx, metricsCollector, db) - - // Start server in a goroutine - go func() { - log.Printf("Starting API server on port %s", cfg.Server.Port) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server failed to start: %v", err) - } - }() - - // Wait for interrupt signal to gracefully shutdown the server - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - log.Println("Shutting down server...") - - // Shutdown server with timeout - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - log.Printf("Server forced to shutdown: %v", err) - } - - log.Println("Server exited") -} - -// startMetricsCollection periodically collects and stores metrics -func startMetricsCollection(ctx context.Context, mc *metrics.MetricsCollectorWithTimeSeries, db *database.Database) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - collectMetrics(ctx, mc, db) - } - } -} - -// collectMetrics gathers metrics from various sources -func collectMetrics(ctx context.Context, mc *metrics.MetricsCollectorWithTimeSeries, db *database.Database) { - // Collect subscriber metrics - totalSubs, activeSubs, suspendedSubs := getSubscriberStats(db) - mc.UpdateSubscriberMetrics(totalSubs, activeSubs, suspendedSubs) - - // Collect system metrics - systemMetrics := metrics.SystemMetrics{ - ActiveSessions: getActiveSessionsCount(db), - CPUUsage: getCPUUsage(), - MemoryUsage: getMemoryUsage(), - NetworkRX: getNetworkRX(), - NetworkTX: getNetworkTX(), - Timestamp: time.Now(), - } - - // Store in time-series if available - if ts := mc.GetTimeSeriesStorage(); ts != nil { - if err := ts.StoreSystemMetrics(ctx, systemMetrics); err != nil { - log.Printf("Failed to store system metrics: %v", err) - } - } -} - -// Helper functions for metrics collection (actual implementations) -func getSubscriberStats(db *database.Database) (total, active, suspended int) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Count total subscribers using GORM - var totalCount int64 - if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Count(&totalCount).Error; err != nil { - log.Printf("Error counting total subscribers: %v", err) - return 0, 0, 0 - } - - // Count active subscribers - var activeCount int64 - if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("status = ?", "active").Count(&activeCount).Error; err != nil { - log.Printf("Error counting active subscribers: %v", err) - return int(totalCount), 0, 0 - } - - // Count suspended subscribers - var suspendedCount int64 - if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("status IN ?", []string{"suspended", "deactivated", "blocked"}).Count(&suspendedCount).Error; err != nil { - log.Printf("Error counting suspended subscribers: %v", err) - return int(totalCount), int(activeCount), 0 - } - - return int(totalCount), int(activeCount), int(suspendedCount) -} - -func getActiveSessionsCount(db *database.Database) int { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Count active charging sessions using GORM - var count int64 - if err := db.DB.WithContext(ctx).Model(&models.Session{}).Where( - "status IN ? AND end_time IS NULL", - []string{"active", "connected", "established"}, - ).Count(&count).Error; err != nil { - log.Printf("Error counting active sessions: %v", err) - return 0 - } - - return int(count) -} - -func getCPUUsage() float64 { - // Read CPU usage from /proc/stat on Linux systems - data, err := os.ReadFile("/proc/stat") - if err != nil { - log.Printf("Error reading /proc/stat: %v", err) - return 0 - } - - lines := strings.Split(string(data), "\n") - if len(lines) == 0 { - return 0 - } - - // Parse the first line (total CPU usage) - fields := strings.Fields(lines[0]) - if len(fields) < 8 || fields[0] != "cpu" { - return 0 - } - - // Parse CPU time values (user, nice, system, idle, iowait, irq, softirq, steal) - var totalCPU, idleCPU float64 - for i, field := range fields[1:8] { - val, err := strconv.ParseFloat(field, 64) - if err != nil { - continue - } - totalCPU += val - if i == 3 { // idle time - idleCPU = val - } - } - - if totalCPU == 0 { - return 0 - } - - // Calculate CPU usage percentage (non-idle time) - cpuUsage := ((totalCPU - idleCPU) / totalCPU) * 100 - return math.Min(cpuUsage, 100.0) // Cap at 100% -} - -func getMemoryUsage() float64 { - // Read memory usage from /proc/meminfo on Linux systems - data, err := os.ReadFile("/proc/meminfo") - if err != nil { - log.Printf("Error reading /proc/meminfo: %v", err) - return 0 - } - - lines := strings.Split(string(data), "\n") - var totalMem, availableMem float64 - - // Parse memory information - for _, line := range lines { - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - - switch fields[0] { - case "MemTotal:": - if val, err := strconv.ParseFloat(fields[1], 64); err == nil { - totalMem = val * 1024 // Convert KB to bytes - } - case "MemAvailable:": - if val, err := strconv.ParseFloat(fields[1], 64); err == nil { - availableMem = val * 1024 // Convert KB to bytes - } - } - } - - if totalMem == 0 { - return 0 - } - - // Calculate memory usage percentage - usedMem := totalMem - availableMem - memoryUsage := (usedMem / totalMem) * 100 - return math.Min(memoryUsage, 100.0) // Cap at 100% -} - -func getNetworkRX() int64 { - // Read network statistics from /proc/net/dev on Linux systems - data, err := os.ReadFile("/proc/net/dev") - if err != nil { - log.Printf("Error reading /proc/net/dev: %v", err) - return 0 - } - - lines := strings.Split(string(data), "\n") - var totalRX int64 - - // Skip header lines (first two lines) - for i, line := range lines { - if i < 2 { - continue - } - - fields := strings.Fields(line) - if len(fields) < 10 { - continue - } - - // Skip loopback interface - if strings.HasPrefix(fields[0], "lo:") { - continue - } - - // RX bytes are in field 1 (after interface name) - if rxBytes, err := strconv.ParseInt(fields[1], 10, 64); err == nil { - totalRX += rxBytes - } - } - - return totalRX -} - -func getNetworkTX() int64 { - // Read network statistics from /proc/net/dev on Linux systems - data, err := os.ReadFile("/proc/net/dev") - if err != nil { - log.Printf("Error reading /proc/net/dev: %v", err) - return 0 - } - - lines := strings.Split(string(data), "\n") - var totalTX int64 - - // Skip header lines (first two lines) - for i, line := range lines { - if i < 2 { - continue - } - - fields := strings.Fields(line) - if len(fields) < 10 { - continue - } - - // Skip loopback interface - if strings.HasPrefix(fields[0], "lo:") { - continue - } - - // TX bytes are in field 9 (after interface name and RX stats) - if txBytes, err := strconv.ParseInt(fields[9], 10, 64); err == nil { - totalTX += txBytes - } - } - - return totalTX -} diff --git a/apps/api-server/go.mod b/apps/api-server/go.mod index a9864d7..457775d 100644 --- a/apps/api-server/go.mod +++ b/apps/api-server/go.mod @@ -13,22 +13,6 @@ require ( ) require ( - github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oapi-codegen/runtime v1.4.0 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v1.20.99 // indirect - github.com/prometheus/procfs v0.20.1 // indirect - github.com/sosodev/duration v1.4.0 // indirect - github.com/vektah/gqlparser/v2 v2.5.32 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect -) - -require ( - github.com/99designs/gqlgen v0.17.89 github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect @@ -41,7 +25,6 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/influxdata/influxdb-client-go/v2 v2.14.0 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect @@ -58,17 +41,15 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.9.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect - github.com/prometheus/client_golang v1.23.2 github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/stripe/stripe-go/v82 v82.5.1 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.26.0 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/net v0.53.0 // indirect diff --git a/apps/api-server/go.sum b/apps/api-server/go.sum index f5d1272..4c521e6 100644 --- a/apps/api-server/go.sum +++ b/apps/api-server/go.sum @@ -1,19 +1,9 @@ -github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8= -github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ= -github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= -github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -44,10 +34,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= -github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= -github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= -github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -64,7 +50,6 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -82,31 +67,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= -github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= -github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -119,14 +89,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8= -github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= -github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -142,8 +108,6 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4= golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/apps/api-server/internal/config/config.go b/apps/api-server/internal/config/config.go index 268fae5..10d412d 100644 --- a/apps/api-server/internal/config/config.go +++ b/apps/api-server/internal/config/config.go @@ -5,30 +5,15 @@ import ( ) type Config struct { - Server ServerConfig `json:"server"` - Database DatabaseConfig `json:"database"` - Redis RedisConfig `json:"redis"` - IMSI IMSIConfig `json:"imsi"` - ES2 ES2Config `json:"es2"` - Payment PaymentConfig `json:"payment"` - ChargingEngine ChargingEngineConfig `json:"charging_engine"` - InfluxDB InfluxDBConfig `json:"influxdb"` -} - -type ChargingEngineConfig struct { - BaseURL string `json:"base_url"` - Timeout time.Duration `json:"timeout"` -} - -type PaymentConfig struct { - Provider string `json:"provider"` - StripeAPIKey string `json:"stripe_api_key"` - StripeWebhookSecret string `json:"stripe_webhook_secret"` + Server ServerConfig `json:"server"` + Database DatabaseConfig `json:"database"` + Redis RedisConfig `json:"redis"` + IMSI IMSIConfig `json:"imsi"` + ES2 ES2Config `json:"es2"` } type ServerConfig struct { Port string `json:"port"` - MetricsPort string `json:"metrics_port"` ReadTimeout time.Duration `json:"read_timeout"` WriteTimeout time.Duration `json:"write_timeout"` } @@ -50,22 +35,15 @@ type RedisConfig struct { } type IMSIConfig struct { - Prefix string `json:"prefix"` + Prefix string `json:"prefix"` MinIMSI uint64 `json:"min_imsi"` MaxIMSI uint64 `json:"max_imsi"` } -type InfluxDBConfig struct { - URL string `json:"url"` - Token string `json:"token"` - Org string `json:"org"` - Bucket string `json:"bucket"` -} - type ES2Config struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - InsecureSkipVerify bool `json:"insecure_skip_verify"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` FunctionalityRequesterID string `json:"functionality_requester_id"` } @@ -73,7 +51,6 @@ func LoadConfig() *Config { return &Config{ Server: ServerConfig{ Port: "8000", - MetricsPort: "9090", ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, }, @@ -92,30 +69,15 @@ func LoadConfig() *Config { DB: 0, }, IMSI: IMSIConfig{ - Prefix: "20893", // France MCC+MNC + Prefix: "20893", // France MCC+MNC MinIMSI: 1, MaxIMSI: 999999999, }, ES2: ES2Config{ - BaseURL: "https://smdp.example.com", - APIKey: "", - InsecureSkipVerify: true, + BaseURL: "https://smdp.example.com", + APIKey: "", + InsecureSkipVerify: true, FunctionalityRequesterID: "telecom-platform", }, - Payment: PaymentConfig{ - Provider: "stripe", - StripeAPIKey: "", - StripeWebhookSecret: "", - }, - ChargingEngine: ChargingEngineConfig{ - BaseURL: "http://localhost:3001", - Timeout: 10 * time.Second, - }, - InfluxDB: InfluxDBConfig{ - URL: "http://localhost:8086", - Token: "", - Org: "telecom", - Bucket: "telecom", - }, } } diff --git a/apps/api-server/internal/database/database.go b/apps/api-server/internal/database/database.go index b1feed7..0728d69 100644 --- a/apps/api-server/internal/database/database.go +++ b/apps/api-server/internal/database/database.go @@ -188,88 +188,11 @@ func (d *Database) UpdateSession(ctx context.Context, session *models.Session) e return d.DB.WithContext(ctx).Save(session).Error } -func (d *Database) CreateTransaction(ctx context.Context, transaction *models.Transaction) error { - return d.DB.WithContext(ctx).Create(transaction).Error -} - -func (d *Database) UpdateSubscriberBalance(ctx context.Context, subscriberID uint, amount float64) error { - return d.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("id = ?", subscriberID). - UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error -} - -func (d *Database) CreatePaymentMethod(ctx context.Context, pm *models.PaymentMethod) error { - return d.DB.WithContext(ctx).Create(pm).Error -} - -func (d *Database) GetPaymentMethod(ctx context.Context, id string) (*models.PaymentMethod, error) { - var pm models.PaymentMethod - err := d.DB.WithContext(ctx).Where("id = ?", id).First(&pm).Error - if err != nil { - return nil, err - } - return &pm, nil -} - -func (d *Database) DeletePaymentMethod(ctx context.Context, id string) error { - return d.DB.WithContext(ctx).Where("id = ?", id).Delete(&models.PaymentMethod{}).Error -} - -func (d *Database) ListPaymentMethods(ctx context.Context, subscriberID uint) ([]models.PaymentMethod, error) { - var methods []models.PaymentMethod - err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Find(&methods).Error - return methods, err -} - -func (d *Database) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", transactionID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) GetTransactionByGatewayID(ctx context.Context, gatewayID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", gatewayID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) GetTransactionByChargeID(ctx context.Context, chargeID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", chargeID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) UpdateTransaction(ctx context.Context, tx *models.Transaction) error { - return d.DB.WithContext(ctx).Save(tx).Error -} - -func (d *Database) ListTransactions(ctx context.Context, subscriberID uint) ([]models.Transaction, error) { - var transactions []models.Transaction - err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Order("created_at DESC").Find(&transactions).Error - return transactions, err -} - -func (d *Database) CreateAlert(ctx context.Context, alert *models.Alert) error { - return d.DB.WithContext(ctx).Create(alert).Error -} - -func (d *Database) CreateNotification(ctx context.Context, notification *models.Notification) error { - return d.DB.WithContext(ctx).Create(notification).Error -} - // Request types for database operations type ListSubscribersRequest struct { - Page int `json:"page"` - PageSize int `json:"page_size"` + Page int `json:"page"` + PageSize int `json:"page_size"` Status models.SubscriberStatus `json:"status"` - OrganizationID string `json:"organization_id"` - Search string `json:"search"` + OrganizationID string `json:"organization_id"` + Search string `json:"search"` } diff --git a/apps/api-server/internal/graphql/resolvers.go b/apps/api-server/internal/graphql/resolvers.go index 493f7e9..e084b73 100644 --- a/apps/api-server/internal/graphql/resolvers.go +++ b/apps/api-server/internal/graphql/resolvers.go @@ -298,6 +298,8 @@ func (r *Resolver) SearchUsageEvents(ctx context.Context, query string, limit in return r.charging.SearchUsageEvents(ctx, query, limit) } +// --- Mutation resolvers --- + func (r *Resolver) CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error) { req := &services.CreateSubscriberRequest{ MSISDN: input.MSISDN, @@ -441,6 +443,8 @@ func (r *Resolver) TriggerSystemMaintenance(ctx context.Context) (bool, error) { return r.charging.TriggerMaintenance(ctx) } +// --- Subscription resolvers --- + func (r *Resolver) SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) { return r.subscriber.SubscribeToSubscriberUpdates(ctx, subscriberId) } @@ -450,15 +454,15 @@ func (r *Resolver) UsageUpdates(ctx context.Context, imsi string) (<-chan *model } func (r *Resolver) AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error) { - // Convert severity to string for the service call - severityStr := string(severity) - return r.subscriber.SubscribeToAlertUpdates(ctx, &severityStr) + return r.subscriber.SubscribeToAlertUpdates(ctx, severity) } func (r *Resolver) SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) { return r.charging.SubscribeToSystemStatsUpdates(ctx) } +// --- Filter / sort helpers --- + func buildUsageEventFilter(imsi *string, filter *UsageEventFilter) string { conditions := []string{} if imsi != nil { @@ -487,10 +491,13 @@ func buildUsageEventFilter(imsi *string, filter *UsageEventFilter) string { return "" } +// --- ResolverRoot interface implementation --- + func (r *Resolver) Query() QueryResolver { return r } func (r *Resolver) Mutation() MutationResolver { return r } func (r *Resolver) Subscription() SubscriptionResolver { return r } +// ChargingSessions placeholder query resolver func (r *Resolver) ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error) { return &ChargingSessionConnection{ Edges: []*ChargingSessionEdge{}, @@ -504,6 +511,8 @@ func (r *Resolver) ChargingSession(ctx context.Context, sessionId string) (*Char return nil, fmt.Errorf("charging session %s not found", sessionId) } +// --- GraphQL handler setup --- + func SetupGraphQLHandler(router *gin.Engine, resolver *Resolver) { graphqlHandler := handler.NewDefaultServer( NewExecutableSchema(Config{Resolvers: resolver}), diff --git a/apps/api-server/internal/metrics/prometheus.go b/apps/api-server/internal/metrics/prometheus.go deleted file mode 100644 index d5681d4..0000000 --- a/apps/api-server/internal/metrics/prometheus.go +++ /dev/null @@ -1,333 +0,0 @@ -package metrics - -import ( - "context" - "net/http" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -var ( - // HTTP request metrics - httpRequestsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_http_requests_total", - Help: "Total number of HTTP requests", - }, - []string{"method", "endpoint", "status_code"}, - ) - - httpRequestDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "telecom_http_request_duration_seconds", - Help: "HTTP request duration in seconds", - Buckets: prometheus.DefBuckets, - }, - []string{"method", "endpoint"}, - ) - - // Subscriber metrics - subscribersTotal = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_subscribers_total", - Help: "Total number of subscribers", - }, - ) - - subscribersActive = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_subscribers_active", - Help: "Number of active subscribers", - }, - ) - - subscribersSuspended = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_subscribers_suspended", - Help: "Number of suspended subscribers", - }, - ) - - // Usage metrics - dataUsageBytes = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_data_usage_bytes_total", - Help: "Total data usage in bytes", - }, - []string{"subscriber_id", "direction"}, - ) - - voiceUsageSeconds = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_voice_usage_seconds_total", - Help: "Total voice usage in seconds", - }, - []string{"subscriber_id"}, - ) - - smsUsageTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_sms_usage_total", - Help: "Total SMS count", - }, - []string{"subscriber_id"}, - ) - - // Charging metrics - creditBalance = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "telecom_credit_balance", - Help: "Subscriber credit balance", - }, - []string{"subscriber_id"}, - ) - - paymentTransactionsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_payment_transactions_total", - Help: "Total payment transactions", - }, - []string{"status", "gateway"}, - ) - - // System metrics - activeSessions = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_active_sessions", - Help: "Number of active sessions", - }, - ) - - systemUptime = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_system_uptime_seconds", - Help: "System uptime in seconds", - }, - ) - - // Database metrics - dbConnectionsActive = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_db_connections_active", - Help: "Number of active database connections", - }, - ) - - dbQueryDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "telecom_db_query_duration_seconds", - Help: "Database query duration in seconds", - Buckets: prometheus.DefBuckets, - }, - []string{"operation"}, - ) - - // Redis metrics - redisConnectionsActive = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "telecom_redis_connections_active", - Help: "Number of active Redis connections", - }, - ) - - redisOperationsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_redis_operations_total", - Help: "Total Redis operations", - }, - []string{"operation", "status"}, - ) - - // Chaos engineering metrics - chaosExperimentsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_chaos_experiments_total", - Help: "Total chaos experiments run", - }, - []string{"type", "status"}, - ) - - // eBPF packet gateway metrics - packetsProcessed = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_packets_processed_total", - Help: "Total packets processed by eBPF gateway", - }, - []string{"action"}, - ) - - packetsDropped = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "telecom_packets_dropped_total", - Help: "Total packets dropped by eBPF gateway", - }, - []string{"reason"}, - ) -) - -// MetricsCollector handles Prometheus metrics collection -type MetricsCollector struct { - startTime time.Time -} - -// NewMetricsCollector creates a new metrics collector -func NewMetricsCollector() *MetricsCollector { - return &MetricsCollector{ - startTime: time.Now(), - } -} - -// Register registers all metrics with Prometheus -func (mc *MetricsCollector) Register() { - prometheus.MustRegister(httpRequestsTotal) - prometheus.MustRegister(httpRequestDuration) - prometheus.MustRegister(subscribersTotal) - prometheus.MustRegister(subscribersActive) - prometheus.MustRegister(subscribersSuspended) - prometheus.MustRegister(dataUsageBytes) - prometheus.MustRegister(voiceUsageSeconds) - prometheus.MustRegister(smsUsageTotal) - prometheus.MustRegister(creditBalance) - prometheus.MustRegister(paymentTransactionsTotal) - prometheus.MustRegister(activeSessions) - prometheus.MustRegister(systemUptime) - prometheus.MustRegister(dbConnectionsActive) - prometheus.MustRegister(dbQueryDuration) - prometheus.MustRegister(redisConnectionsActive) - prometheus.MustRegister(redisOperationsTotal) - prometheus.MustRegister(chaosExperimentsTotal) - prometheus.MustRegister(packetsProcessed) - prometheus.MustRegister(packetsDropped) - - // Start system uptime goroutine - go mc.updateSystemMetrics() -} - -// updateSystemMetrics updates system-level metrics periodically -func (mc *MetricsCollector) updateSystemMetrics() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for range ticker.C { - systemUptime.Set(time.Since(mc.startTime).Seconds()) - } -} - -// HTTPMiddleware returns middleware to track HTTP metrics -func (mc *MetricsCollector) HTTPMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Create a response writer to capture status code - rw := &responseWriter{ResponseWriter: w, statusCode: 200} - - // Process request - next.ServeHTTP(rw, r) - - // Record metrics - duration := time.Since(start).Seconds() - statusCode := rw.statusCode - - httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, http.StatusText(statusCode)).Inc() - httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration) - }) -} - -// responseWriter is a wrapper to capture status code -type responseWriter struct { - http.ResponseWriter - statusCode int -} - -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} - -// UpdateSubscriberMetrics updates subscriber-related metrics -func (mc *MetricsCollector) UpdateSubscriberMetrics(total, active, suspended int) { - subscribersTotal.Set(float64(total)) - subscribersActive.Set(float64(active)) - subscribersSuspended.Set(float64(suspended)) -} - -// RecordDataUsage records data usage for a subscriber -func (mc *MetricsCollector) RecordDataUsage(subscriberID string, direction string, bytes int64) { - dataUsageBytes.WithLabelValues(subscriberID, direction).Add(float64(bytes)) -} - -// RecordVoiceUsage records voice usage for a subscriber -func (mc *MetricsCollector) RecordVoiceUsage(subscriberID string, seconds int64) { - voiceUsageSeconds.WithLabelValues(subscriberID).Add(float64(seconds)) -} - -// RecordSMSUsage records SMS usage for a subscriber -func (mc *MetricsCollector) RecordSMSUsage(subscriberID string, count int64) { - smsUsageTotal.WithLabelValues(subscriberID).Add(float64(count)) -} - -// UpdateCreditBalance updates credit balance for a subscriber -func (mc *MetricsCollector) UpdateCreditBalance(subscriberID string, balance float64) { - creditBalance.WithLabelValues(subscriberID).Set(balance) -} - -// RecordPaymentTransaction records a payment transaction -func (mc *MetricsCollector) RecordPaymentTransaction(status, gateway string) { - paymentTransactionsTotal.WithLabelValues(status, gateway).Inc() -} - -// UpdateActiveSessions updates the active sessions count -func (mc *MetricsCollector) UpdateActiveSessions(count int) { - activeSessions.Set(float64(count)) -} - -// UpdateDBMetrics updates database metrics -func (mc *MetricsCollector) UpdateDBMetrics(activeConnections int, operation string, duration time.Duration) { - dbConnectionsActive.Set(float64(activeConnections)) - dbQueryDuration.WithLabelValues(operation).Observe(duration.Seconds()) -} - -// UpdateRedisMetrics updates Redis metrics -func (mc *MetricsCollector) UpdateRedisMetrics(activeConnections int, operation, status string) { - redisConnectionsActive.Set(float64(activeConnections)) - redisOperationsTotal.WithLabelValues(operation, status).Inc() -} - -// RecordChaosExperiment records a chaos experiment -func (mc *MetricsCollector) RecordChaosExperiment(experimentType, status string) { - chaosExperimentsTotal.WithLabelValues(experimentType, status).Inc() -} - -// RecordPacketMetrics records packet processing metrics -func (mc *MetricsCollector) RecordPacketMetrics(action string, count int64, reason string) { - if action == "dropped" { - packetsDropped.WithLabelValues(reason).Add(float64(count)) - } else { - packetsProcessed.WithLabelValues(action).Add(float64(count)) - } -} - -// Handler returns the Prometheus metrics HTTP handler -func (mc *MetricsCollector) Handler() http.Handler { - return promhttp.Handler() -} - -// StartMetricsServer starts the Prometheus metrics server -func (mc *MetricsCollector) StartMetricsServer(ctx context.Context, addr string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", mc.Handler()) - - server := &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - <-ctx.Done() - server.Shutdown(context.Background()) - }() - - return server.ListenAndServe() -} diff --git a/apps/api-server/internal/metrics/timeseries.go b/apps/api-server/internal/metrics/timeseries.go deleted file mode 100644 index 8199d68..0000000 --- a/apps/api-server/internal/metrics/timeseries.go +++ /dev/null @@ -1,387 +0,0 @@ -package metrics - -import ( - "context" - "fmt" - "time" - - influxdb2 "github.com/influxdata/influxdb-client-go/v2" - "github.com/influxdata/influxdb-client-go/v2/api" -) - -// TimeSeriesStorage handles advanced time-series metrics storage -type TimeSeriesStorage struct { - client api.WriteAPIBlocking - queryAPI api.QueryAPI - org string - bucket string -} - -// MetricPoint represents a single metric data point -type MetricPoint struct { - Measurement string `json:"measurement"` - Tags map[string]string `json:"tags"` - Fields map[string]any `json:"fields"` - Timestamp time.Time `json:"timestamp"` -} - -// UsageMetrics represents usage metrics for time-series storage -type UsageMetrics struct { - SubscriberID string `json:"subscriber_id"` - IMSI string `json:"imsi"` - DataUp int64 `json:"data_up"` - DataDown int64 `json:"data_down"` - VoiceSeconds int64 `json:"voice_seconds"` - SMSCount int64 `json:"sms_count"` - Timestamp time.Time `json:"timestamp"` -} - -// SystemMetrics represents system metrics for time-series storage -type SystemMetrics struct { - ActiveSessions int `json:"active_sessions"` - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - NetworkRX int64 `json:"network_rx"` - NetworkTX int64 `json:"network_tx"` - Timestamp time.Time `json:"timestamp"` -} - -// ChargingMetrics represents charging metrics for time-series storage -type ChargingMetrics struct { - SubscriberID string `json:"subscriber_id"` - Balance float64 `json:"balance"` - TransactionID string `json:"transaction_id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` -} - -// NewTimeSeriesStorage creates a new time-series storage client -func NewTimeSeriesStorage(url, token, org, bucket string) (*TimeSeriesStorage, error) { - client := influxdb2.NewClient(url, token) - writeAPI := client.WriteAPIBlocking(org, bucket) - queryAPI := client.QueryAPI(org) - - return &TimeSeriesStorage{ - client: writeAPI, - queryAPI: queryAPI, - org: org, - bucket: bucket, - }, nil -} - -// StoreUsageMetrics stores usage metrics in InfluxDB -func (ts *TimeSeriesStorage) StoreUsageMetrics(ctx context.Context, metrics UsageMetrics) error { - point := influxdb2.NewPoint( - "usage", - map[string]string{ - "subscriber_id": metrics.SubscriberID, - "imsi": metrics.IMSI, - }, - map[string]interface{}{ - "data_up": metrics.DataUp, - "data_down": metrics.DataDown, - "voice_seconds": metrics.VoiceSeconds, - "sms_count": metrics.SMSCount, - }, - metrics.Timestamp, - ) - - return ts.client.WritePoint(ctx, point) -} - -// StoreSystemMetrics stores system metrics in InfluxDB -func (ts *TimeSeriesStorage) StoreSystemMetrics(ctx context.Context, metrics SystemMetrics) error { - point := influxdb2.NewPoint( - "system", - map[string]string{}, - map[string]interface{}{ - "active_sessions": metrics.ActiveSessions, - "cpu_usage": metrics.CPUUsage, - "memory_usage": metrics.MemoryUsage, - "network_rx": metrics.NetworkRX, - "network_tx": metrics.NetworkTX, - }, - metrics.Timestamp, - ) - - return ts.client.WritePoint(ctx, point) -} - -// StoreChargingMetrics stores charging metrics in InfluxDB -func (ts *TimeSeriesStorage) StoreChargingMetrics(ctx context.Context, metrics ChargingMetrics) error { - point := influxdb2.NewPoint( - "charging", - map[string]string{ - "subscriber_id": metrics.SubscriberID, - "transaction_id": metrics.TransactionID, - "currency": metrics.Currency, - "status": metrics.Status, - }, - map[string]interface{}{ - "balance": metrics.Balance, - "amount": metrics.Amount, - }, - metrics.Timestamp, - ) - - return ts.client.WritePoint(ctx, point) -} - -// StorePacketMetrics stores packet processing metrics in InfluxDB -func (ts *TimeSeriesStorage) StorePacketMetrics(ctx context.Context, subscriberID string, packetsProcessed, packetsDropped int64, bytesProcessed int64) error { - point := influxdb2.NewPoint( - "packets", - map[string]string{ - "subscriber_id": subscriberID, - }, - map[string]interface{}{ - "packets_processed": packetsProcessed, - "packets_dropped": packetsDropped, - "bytes_processed": bytesProcessed, - }, - time.Now(), - ) - - return ts.client.WritePoint(ctx, point) -} - -// GetUsageStats retrieves usage statistics for a time range -func (ts *TimeSeriesStorage) GetUsageStats(ctx context.Context, subscriberID string, start, end time.Time) (*UsageMetrics, error) { - query := fmt.Sprintf(` - from(bucket: "%s") - |> range(start: %s, stop: %s) - |> filter(fn: (r) => r._measurement == "usage") - |> filter(fn: (r) => r.subscriber_id == "%s") - |> last() - `, ts.bucket, start.Format(time.RFC3339), end.Format(time.RFC3339), subscriberID) - - result, err := ts.queryAPI.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query usage stats: %w", err) - } - - var metrics UsageMetrics - for result.Next() { - record := result.Record() - metrics = UsageMetrics{ - SubscriberID: record.ValueByKey("subscriber_id").(string), - IMSI: record.ValueByKey("imsi").(string), - DataUp: record.ValueByKey("data_up").(int64), - DataDown: record.ValueByKey("data_down").(int64), - VoiceSeconds: record.ValueByKey("voice_seconds").(int64), - SMSCount: record.ValueByKey("sms_count").(int64), - Timestamp: record.Time(), - } - } - - if result.Err() != nil { - return nil, result.Err() - } - - return &metrics, nil -} - -// GetSystemMetricsHistory retrieves system metrics history -func (ts *TimeSeriesStorage) GetSystemMetricsHistory(ctx context.Context, start, end time.Time, interval string) ([]SystemMetrics, error) { - query := fmt.Sprintf(` - from(bucket: "%s") - |> range(start: %s, stop: %s) - |> filter(fn: (r) => r._measurement == "system") - |> aggregateWindow(every: %s, fn: mean, createEmpty: false) - |> yield(name: "mean") - `, ts.bucket, start.Format(time.RFC3339), end.Format(time.RFC3339), interval) - - result, err := ts.queryAPI.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query system metrics: %w", err) - } - - var metrics []SystemMetrics - for result.Next() { - record := result.Record() - metric := SystemMetrics{ - ActiveSessions: int(record.ValueByKey("active_sessions").(int64)), - CPUUsage: record.ValueByKey("cpu_usage").(float64), - MemoryUsage: record.ValueByKey("memory_usage").(float64), - NetworkRX: record.ValueByKey("network_rx").(int64), - NetworkTX: record.ValueByKey("network_tx").(int64), - Timestamp: record.Time(), - } - metrics = append(metrics, metric) - } - - if result.Err() != nil { - return nil, result.Err() - } - - return metrics, nil -} - -// GetSubscriberUsageHistory retrieves usage history for a subscriber -func (ts *TimeSeriesStorage) GetSubscriberUsageHistory(ctx context.Context, subscriberID string, start, end time.Time, interval string) ([]UsageMetrics, error) { - query := fmt.Sprintf(` - from(bucket: "%s") - |> range(start: %s, stop: %s) - |> filter(fn: (r) => r._measurement == "usage") - |> filter(fn: (r) => r.subscriber_id == "%s") - |> aggregateWindow(every: %s, fn: sum, createEmpty: false) - |> yield(name: "sum") - `, ts.bucket, start.Format(time.RFC3339), end.Format(time.RFC3339), subscriberID, interval) - - result, err := ts.queryAPI.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query subscriber usage: %w", err) - } - - var metrics []UsageMetrics - for result.Next() { - record := result.Record() - metric := UsageMetrics{ - SubscriberID: subscriberID, - IMSI: record.ValueByKey("imsi").(string), - DataUp: record.ValueByKey("data_up").(int64), - DataDown: record.ValueByKey("data_down").(int64), - VoiceSeconds: record.ValueByKey("voice_seconds").(int64), - SMSCount: record.ValueByKey("sms_count").(int64), - Timestamp: record.Time(), - } - metrics = append(metrics, metric) - } - - if result.Err() != nil { - return nil, result.Err() - } - - return metrics, nil -} - -// GetChargingHistory retrieves charging history for a subscriber -func (ts *TimeSeriesStorage) GetChargingHistory(ctx context.Context, subscriberID string, start, end time.Time) ([]ChargingMetrics, error) { - query := fmt.Sprintf(` - from(bucket: "%s") - |> range(start: %s, stop: %s) - |> filter(fn: (r) => r._measurement == "charging") - |> filter(fn: (r) => r.subscriber_id == "%s") - |> sort(columns: ["_time"]) - `, ts.bucket, start.Format(time.RFC3339), end.Format(time.RFC3339), subscriberID) - - result, err := ts.queryAPI.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query charging history: %w", err) - } - - var metrics []ChargingMetrics - for result.Next() { - record := result.Record() - metric := ChargingMetrics{ - SubscriberID: record.ValueByKey("subscriber_id").(string), - TransactionID: record.ValueByKey("transaction_id").(string), - Balance: record.ValueByKey("balance").(float64), - Amount: record.ValueByKey("amount").(float64), - Currency: record.ValueByKey("currency").(string), - Status: record.ValueByKey("status").(string), - Timestamp: record.Time(), - } - metrics = append(metrics, metric) - } - - if result.Err() != nil { - return nil, result.Err() - } - - return metrics, nil -} - -// GetTopDataUsers retrieves top data users for a time period -func (ts *TimeSeriesStorage) GetTopDataUsers(ctx context.Context, start, end time.Time, limit int) ([]map[string]interface{}, error) { - query := fmt.Sprintf(` - from(bucket: "%s") - |> range(start: %s, stop: %s) - |> filter(fn: (r) => r._measurement == "usage") - |> group(columns: ["subscriber_id"]) - |> sum(columns: ["data_up", "data_down"]) - |> map(fn: (r) => ({r with total_data: r.data_up + r.data_down})) - |> sort(columns: ["total_data"], desc: true) - |> limit(n: %d) - `, ts.bucket, start.Format(time.RFC3339), end.Format(time.RFC3339), limit) - - result, err := ts.queryAPI.Query(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query top data users: %w", err) - } - - var users []map[string]interface{} - for result.Next() { - record := result.Record() - user := map[string]interface{}{ - "subscriber_id": record.ValueByKey("subscriber_id"), - "total_data": record.ValueByKey("total_data"), - "data_up": record.ValueByKey("data_up"), - "data_down": record.ValueByKey("data_down"), - } - users = append(users, user) - } - - if result.Err() != nil { - return nil, result.Err() - } - - return users, nil -} - -// Close closes the InfluxDB client connection -func (ts *TimeSeriesStorage) Close() { - // InfluxDB client doesn't need explicit closing in v2 -} - -// MetricsCollectorWithTimeSeries extends MetricsCollector with time-series storage -type MetricsCollectorWithTimeSeries struct { - *MetricsCollector - timeSeries *TimeSeriesStorage -} - -// NewMetricsCollectorWithTimeSeries creates a new metrics collector with time-series storage -func NewMetricsCollectorWithTimeSeries(prometheusCollector *MetricsCollector, timeSeries *TimeSeriesStorage) *MetricsCollectorWithTimeSeries { - return &MetricsCollectorWithTimeSeries{ - MetricsCollector: prometheusCollector, - timeSeries: timeSeries, - } -} - -// StoreUsageMetricsInTimeSeries stores usage metrics in both Prometheus and time-series -func (mc *MetricsCollectorWithTimeSeries) StoreUsageMetricsInTimeSeries(ctx context.Context, metrics UsageMetrics) error { - // Update Prometheus metrics - mc.RecordDataUsage(metrics.SubscriberID, "up", metrics.DataUp) - mc.RecordDataUsage(metrics.SubscriberID, "down", metrics.DataDown) - mc.RecordVoiceUsage(metrics.SubscriberID, metrics.VoiceSeconds) - mc.RecordSMSUsage(metrics.SubscriberID, metrics.SMSCount) - - // Store in time-series - return mc.timeSeries.StoreUsageMetrics(ctx, metrics) -} - -// StoreSystemMetricsInTimeSeries stores system metrics in both Prometheus and time-series -func (mc *MetricsCollectorWithTimeSeries) StoreSystemMetricsInTimeSeries(ctx context.Context, metrics SystemMetrics) error { - // Update Prometheus metrics - mc.UpdateActiveSessions(metrics.ActiveSessions) - - // Store in time-series - return mc.timeSeries.StoreSystemMetrics(ctx, metrics) -} - -// StoreChargingMetricsInTimeSeries stores charging metrics in both Prometheus and time-series -func (mc *MetricsCollectorWithTimeSeries) StoreChargingMetricsInTimeSeries(ctx context.Context, metrics ChargingMetrics) error { - // Update Prometheus metrics - mc.UpdateCreditBalance(metrics.SubscriberID, metrics.Balance) - mc.RecordPaymentTransaction(metrics.Status, "stripe") - - // Store in time-series - return mc.timeSeries.StoreChargingMetrics(ctx, metrics) -} - -// GetTimeSeriesStorage returns the time-series storage instance -func (mc *MetricsCollectorWithTimeSeries) GetTimeSeriesStorage() *TimeSeriesStorage { - return mc.timeSeries -} diff --git a/apps/api-server/internal/models/account.go b/apps/api-server/internal/models/account.go deleted file mode 100644 index 8e23660..0000000 --- a/apps/api-server/internal/models/account.go +++ /dev/null @@ -1,235 +0,0 @@ -package models - -import ( - "time" -) - -type SubscriberAccount struct { - IMSI string `json:"imsi"` - Balance float64 `json:"balance"` - DataLimit float64 `json:"data_limit"` - DataUsed float64 `json:"data_used"` - VoiceLimit float64 `json:"voice_limit"` - VoiceUsed float64 `json:"voice_used"` - SMSLimit float64 `json:"sms_limit"` - SMSUsed float64 `json:"sms_used"` - Status string `json:"status"` - LastUpdated time.Time `json:"last_updated"` -} - -type UsageEvent struct { - ID uint `json:"id"` - IMSI string `json:"imsi"` - SessionID string `json:"session_id"` - UsageType UsageType `json:"usage_type"` - Volume float64 `json:"volume"` - Timestamp time.Time `json:"timestamp"` - Rate float64 `json:"rate"` - Cost float64 `json:"cost"` - Subscriber *Subscriber `json:"subscriber,omitempty"` -} - -type Invoice struct { - ID uint `json:"id"` - SubscriberID uint `json:"subscriber_id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status InvoiceStatus `json:"status"` - DueDate time.Time `json:"due_date"` - CreatedAt time.Time `json:"created_at"` - LineItems []InvoiceLineItem `json:"line_items"` - Subscriber *Subscriber `json:"subscriber,omitempty"` -} - -type InvoiceStatus string - -const ( - InvoiceStatusDraft InvoiceStatus = "DRAFT" - InvoiceStatusPending InvoiceStatus = "PENDING" - InvoiceStatusPaid InvoiceStatus = "PAID" - InvoiceStatusOverdue InvoiceStatus = "OVERDUE" - InvoiceStatusCancelled InvoiceStatus = "CANCELLED" -) - -type InvoiceLineItem struct { - Description string `json:"description"` - Quantity int `json:"quantity"` - UnitPrice float64 `json:"unit_price"` - Amount float64 `json:"amount"` -} - -type RatingPlan struct { - PlanID string `json:"plan_id"` - Name string `json:"name"` - DataRate float64 `json:"data_rate"` - VoiceRate float64 `json:"voice_rate"` - SMSRate float64 `json:"sms_rate"` - MonthlyFee float64 `json:"monthly_fee"` - DataLimit float64 `json:"data_limit"` - VoiceLimit float64 `json:"voice_limit"` - SMSLimit float64 `json:"sms_limit"` - Subscribers []*Subscriber `json:"subscribers,omitempty"` -} - -type Alert struct { - ID uint `json:"id"` - Type AlertType `json:"type"` - Severity AlertSeverity `json:"severity"` - Message string `json:"message"` - SubscriberID *int `json:"subscriber_id"` - Timestamp time.Time `json:"timestamp"` - Resolved bool `json:"resolved"` - Subscriber *Subscriber `json:"subscriber,omitempty"` -} - -type AlertType string - -const ( - AlertTypeLowBalance AlertType = "LOW_BALANCE" - AlertTypeHighUsage AlertType = "HIGH_USAGE" - AlertTypePaymentFailed AlertType = "PAYMENT_FAILED" - AlertTypeSystemError AlertType = "SYSTEM_ERROR" -) - -type AlertSeverity string - -const ( - AlertSeverityLow AlertSeverity = "LOW" - AlertSeverityMedium AlertSeverity = "MEDIUM" - AlertSeverityHigh AlertSeverity = "HIGH" - AlertSeverityCritical AlertSeverity = "CRITICAL" -) - -type SystemStats struct { - ActiveSessions int `json:"active_sessions"` - TotalAccounts int `json:"total_accounts"` - BlockedUsers int `json:"blocked_users"` - LowBalanceAlerts int `json:"low_balance_alerts"` - Uptime float64 `json:"uptime"` -} - -type HealthStatus struct { - RedisConnected bool `json:"redis_connected"` - ActiveSync bool `json:"active_sync"` - LastSync time.Time `json:"last_sync"` - MemoryUsage float64 `json:"memory_usage"` -} - -type PaymentMethod struct { - ID string `json:"id" gorm:"primaryKey"` - SubscriberID uint `json:"subscriber_id"` - GatewayID string `json:"gateway_id"` - Type PaymentMethodType `json:"type"` - CustomerID string `json:"customer_id"` - Last4 string `json:"last4"` - Brand string `json:"brand"` - ExpiryMonth int `json:"expiry_month"` - ExpiryYear int `json:"expiry_year"` - IsDefault bool `json:"is_default"` - CreatedAt time.Time `json:"created_at"` - Metadata map[string]string `json:"metadata" gorm:"-"` -} - -type PaymentMethodType string - -const ( - PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD" - PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT" -) - -type Transaction struct { - ID uint `json:"id"` - SubscriberID uint `json:"subscriber_id"` - TransactionID string `json:"transaction_id"` - Type string `json:"type"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - Description string `json:"description"` - ParentID *uint `json:"parent_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Notification struct { - ID uint `json:"id"` - SubscriberID uint `json:"subscriber_id"` - Type string `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Read bool `json:"read"` - CreatedAt time.Time `json:"created_at"` -} - -type CreateSubscriberRequest struct { - MSISDN string `json:"msisdn"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` - OrganizationID *string `json:"organization_id"` - PlanID int `json:"plan_id"` - EUICCID *string `json:"euicc_id"` -} - -type UpdateSubscriberRequest struct { - FirstName *string `json:"first_name"` - LastName *string `json:"last_name"` - Email *string `json:"email"` - OrganizationID *string `json:"organization_id"` - PlanID *int `json:"plan_id"` -} - -type TopUpRequest struct { - Amount float64 `json:"amount"` - PaymentMethodID *string `json:"payment_method_id"` -} - -type AddPaymentMethodRequest struct { - Type PaymentMethodType `json:"type"` - Token string `json:"token"` - IsDefault bool `json:"is_default"` -} - -type CreateAlertRequest struct { - Type AlertType `json:"type"` - Severity AlertSeverity `json:"severity"` - Message string `json:"message"` - SubscriberID *int `json:"subscriber_id"` -} - -type ListSubscribersRequest struct { - First int `json:"first"` - After *string `json:"after"` - Filter *SubscriberFilter `json:"filter"` - Sort *SubscriberSort `json:"sort"` -} - -type SubscriberFilter struct { - Status *SubscriberStatus `json:"status"` - OrganizationID *string `json:"organization_id"` - PlanID *int `json:"plan_id"` - Search *string `json:"search"` - BalanceMin *float64 `json:"balance_min"` - BalanceMax *float64 `json:"balance_max"` - DataUsageMin *float64 `json:"data_usage_min"` - DataUsageMax *float64 `json:"data_usage_max"` -} - -type SubscriberSort string - -const ( - SubscriberSortCreatedAtAsc SubscriberSort = "CREATED_AT_ASC" - SubscriberSortCreatedAtDesc SubscriberSort = "CREATED_AT_DESC" - SubscriberSortUpdatedAtAsc SubscriberSort = "UPDATED_AT_ASC" - SubscriberSortUpdatedAtDesc SubscriberSort = "UPDATED_AT_DESC" - SubscriberSortFirstNameAsc SubscriberSort = "FIRST_NAME_ASC" - SubscriberSortFirstNameDesc SubscriberSort = "FIRST_NAME_DESC" - SubscriberSortLastNameAsc SubscriberSort = "LAST_NAME_ASC" - SubscriberSortLastNameDesc SubscriberSort = "LAST_NAME_DESC" - SubscriberSortEmailAsc SubscriberSort = "EMAIL_ASC" - SubscriberSortEmailDesc SubscriberSort = "EMAIL_DESC" - SubscriberSortBalanceAsc SubscriberSort = "BALANCE_ASC" - SubscriberSortBalanceDesc SubscriberSort = "BALANCE_DESC" - SubscriberSortDataUsedAsc SubscriberSort = "DATA_USED_ASC" - SubscriberSortDataUsedDesc SubscriberSort = "DATA_USED_DESC" -) diff --git a/apps/api-server/internal/models/subscriber.go b/apps/api-server/internal/models/subscriber.go index 2ad0d34..a1d38ab 100644 --- a/apps/api-server/internal/models/subscriber.go +++ b/apps/api-server/internal/models/subscriber.go @@ -19,9 +19,8 @@ type Subscriber struct { Email string `json:"email" gorm:"uniqueIndex"` OrganizationID string `json:"organization_id"` - Status SubscriberStatus `json:"status" gorm:"default:'active'"` - PlanID uint `json:"plan_id"` - Balance float64 `json:"balance" gorm:"default:0"` + Status SubscriberStatus `json:"status" gorm:"default:'active'"` + PlanID uint `json:"plan_id"` AuthKey string `json:"auth_key" gorm:"not null"` OPc string `json:"opc" gorm:"not null"` diff --git a/apps/api-server/internal/payment/gateway.go b/apps/api-server/internal/payment/gateway.go deleted file mode 100644 index ac4e5f8..0000000 --- a/apps/api-server/internal/payment/gateway.go +++ /dev/null @@ -1,620 +0,0 @@ -package payment - -import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// PaymentGateway interface -type PaymentGateway interface { - ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) - RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) - GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) - CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) - DeletePaymentMethod(ctx context.Context, paymentMethodID string) error - ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) -} - -// PaymentRequest represents a payment request -type PaymentRequest struct { - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Description string `json:"description"` - CustomerID string `json:"customerId"` - PaymentMethodID string `json:"paymentMethodId"` - Metadata map[string]string `json:"metadata"` - WebhookURL string `json:"webhookUrl"` -} - -// PaymentResponse represents a payment response -type PaymentResponse struct { - TransactionID string `json:"transactionId"` - Status PaymentStatusType `json:"status"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - ProcessedAt time.Time `json:"processedAt"` - FailureReason *string `json:"failureReason,omitempty"` - Metadata map[string]string `json:"metadata"` - RedirectURL *string `json:"redirectUrl,omitempty"` -} - -// RefundResponse represents a refund response -type RefundResponse struct { - RefundID string `json:"refundId"` - TransactionID string `json:"transactionId"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status PaymentStatusType `json:"status"` - ProcessedAt time.Time `json:"processedAt"` - FailureReason *string `json:"failureReason,omitempty"` -} - -// PaymentStatus represents payment status -type PaymentStatus struct { - TransactionID string `json:"transactionId"` - Status PaymentStatusType `json:"status"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - FailureReason *string `json:"failureReason,omitempty"` - Refunds []RefundInfo `json:"refunds,omitempty"` -} - -// RefundInfo represents refund information -type RefundInfo struct { - RefundID string `json:"refundId"` - Amount float64 `json:"amount"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` -} - -// CreatePaymentMethodRequest represents a payment method creation request -type CreatePaymentMethodRequest struct { - Type PaymentMethodType `json:"type"` - Token string `json:"token"` - CustomerID string `json:"customerId"` - IsDefault bool `json:"isDefault"` - Metadata map[string]string `json:"metadata"` -} - -// PaymentMethod represents a payment method -type PaymentMethod struct { - ID string `json:"id"` - Type PaymentMethodType `json:"type"` - CustomerID string `json:"customerId"` - Last4 string `json:"last4"` - Brand string `json:"brand"` - ExpiryMonth int `json:"expiryMonth"` - ExpiryYear int `json:"expiryYear"` - IsDefault bool `json:"isDefault"` - CreatedAt time.Time `json:"createdAt"` - Metadata map[string]string `json:"metadata"` -} - -// PaymentMethodDetails represents payment method details retrieved from token -type PaymentMethodDetails struct { - PaymentMethodID string `json:"paymentMethodId"` - Type string `json:"type"` - Last4 string `json:"last4"` - Brand string `json:"brand"` - ExpiryMonth int `json:"expiryMonth"` - ExpiryYear int `json:"expiryYear"` - Fingerprint string `json:"fingerprint"` - CreatedAt time.Time `json:"createdAt"` -} - -// PaymentStatusType represents payment status -type PaymentStatusType string - -const ( - PaymentStatusPending PaymentStatusType = "PENDING" - PaymentStatusProcessing PaymentStatusType = "PROCESSING" - PaymentStatusCompleted PaymentStatusType = "COMPLETED" - PaymentStatusFailed PaymentStatusType = "FAILED" - PaymentStatusRefunded PaymentStatusType = "REFUNDED" - PaymentStatusCancelled PaymentStatusType = "CANCELLED" -) - -// PaymentMethodType represents payment method type -type PaymentMethodType string - -const ( - PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD" - PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT" -) - -// StripeGateway implements PaymentGateway for Stripe -type StripeGateway struct { - apiKey string - webhookSecret string - client *http.Client - config *config.PaymentConfig -} - -// NewStripeGateway creates a new Stripe payment gateway -func NewStripeGateway(cfg *config.PaymentConfig) *StripeGateway { - return &StripeGateway{ - apiKey: cfg.StripeAPIKey, - webhookSecret: cfg.StripeWebhookSecret, - client: &http.Client{Timeout: 30 * time.Second}, - config: cfg, - } -} - -// ProcessPayment processes a payment via Stripe -func (s *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) { - // Create payment intent - intentData := map[string]any{ - "amount": int64(req.Amount * 100), // Stripe uses cents - "currency": strings.ToLower(req.Currency), - "description": req.Description, - "customer": req.CustomerID, - "payment_method": req.PaymentMethodID, - "confirmation_method": "manual", - "confirm": true, - "metadata": req.Metadata, - } - - if req.WebhookURL != "" { - intentData["transfer_data"] = map[string]string{ - "destination": req.WebhookURL, - } - } - - intentResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_intents", intentData) - if err != nil { - return nil, fmt.Errorf("failed to create payment intent: %w", err) - } - - intent, ok := intentResp.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid payment intent response") - } - - status := s.mapStripeStatus(intent["status"].(string)) - - var failureReason *string - if status == PaymentStatusFailed { - if lastPaymentError, ok := intent["last_payment_error"].(map[string]interface{}); ok { - if message, ok := lastPaymentError["message"].(string); ok { - failureReason = &message - } - } - } - - var redirectURL *string - if status == PaymentStatusPending { - if nextAction, ok := intent["next_action"].(map[string]interface{}); ok { - if url, ok := nextAction["redirect_to_url"].(map[string]interface{}); ok { - if redirect, ok := url["url"].(string); ok { - redirectURL = &redirect - } - } - } - } - - return &PaymentResponse{ - TransactionID: intent["id"].(string), - Status: status, - Amount: req.Amount, - Currency: req.Currency, - ProcessedAt: time.Now(), - FailureReason: failureReason, - Metadata: req.Metadata, - RedirectURL: redirectURL, - }, nil -} - -// RefundPayment refunds a payment via Stripe -func (s *StripeGateway) RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) { - refundData := map[string]interface{}{ - "payment_intent": transactionID, - "amount": int64(amount * 100), // Stripe uses cents - } - - refundResp, err := s.makeStripeRequest(ctx, "POST", "/v1/refunds", refundData) - if err != nil { - return nil, fmt.Errorf("failed to create refund: %w", err) - } - - refund, ok := refundResp.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid refund response") - } - - status := s.mapStripeStatus(refund["status"].(string)) - - var failureReason *string - if status == PaymentStatusFailed { - if failureMsg, ok := refund["failure_reason"].(string); ok && failureMsg != "" { - failureReason = &failureMsg - } - } - - return &RefundResponse{ - RefundID: refund["id"].(string), - TransactionID: transactionID, - Amount: amount, - Currency: "USD", // Default currency, should be configurable - Status: status, - ProcessedAt: time.Now(), - FailureReason: failureReason, - }, nil -} - -// GetPaymentStatus gets payment status from Stripe -func (s *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { - intentResp, err := s.makeStripeRequest(ctx, "GET", "/v1/payment_intents/"+transactionID, nil) - if err != nil { - return nil, fmt.Errorf("failed to get payment intent: %w", err) - } - - intent, ok := intentResp.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid payment intent response") - } - - // Get refunds for this payment intent - refundsResp, err := s.makeStripeRequest(ctx, "GET", "/v1/refunds?payment_intent="+transactionID, nil) - if err != nil { - return nil, fmt.Errorf("failed to get refunds: %w", err) - } - - refundsData, _ := refundsResp.(map[string]interface{}) - refundsList, _ := refundsData["data"].([]interface{}) - - refunds := make([]RefundInfo, len(refundsList)) - for i, refundInterface := range refundsList { - refund, _ := refundInterface.(map[string]interface{}) - createdAt, _ := time.Parse(time.RFC3339, refund["created"].(string)) - refunds[i] = RefundInfo{ - RefundID: refund["id"].(string), - Amount: float64(refund["amount"].(int64)) / 100, - Status: refund["status"].(string), - CreatedAt: createdAt, - } - } - - createdAt, _ := time.Parse(time.RFC3339, intent["created"].(string)) - - var failureReason *string - if lastPaymentError, ok := intent["last_payment_error"].(map[string]interface{}); ok { - if message, ok := lastPaymentError["message"].(string); ok { - failureReason = &message - } - } - - return &PaymentStatus{ - TransactionID: transactionID, - Status: s.mapStripeStatus(intent["status"].(string)), - Amount: float64(intent["amount"].(int64)) / 100, - Currency: strings.ToUpper(intent["currency"].(string)), - CreatedAt: createdAt, - UpdatedAt: time.Now(), - FailureReason: failureReason, - Refunds: refunds, - }, nil -} - -// CreatePaymentMethod creates a payment method in Stripe -func (s *StripeGateway) CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { - pmData := map[string]interface{}{ - "type": s.mapPaymentMethodType(req.Type), - "card": map[string]string{ - "token": req.Token, - }, - "metadata": req.Metadata, - } - - pmResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_methods", pmData) - if err != nil { - return nil, fmt.Errorf("failed to create payment method: %w", err) - } - - pm, ok := pmResp.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid payment method response") - } - - // Attach payment method to customer - attachData := map[string]interface{}{ - "customer": req.CustomerID, - } - _, err = s.makeStripeRequest(ctx, "POST", "/v1/payment_methods/"+pm["id"].(string)+"/attach", attachData) - if err != nil { - return nil, fmt.Errorf("failed to attach payment method: %w", err) - } - - // Set as default if requested - if req.IsDefault { - customerData := map[string]interface{}{ - "invoice_settings": map[string]string{ - "default_payment_method": pm["id"].(string), - }, - } - _, err = s.makeStripeRequest(ctx, "POST", "/v1/customers/"+req.CustomerID, customerData) - if err != nil { - return nil, fmt.Errorf("failed to set default payment method: %w", err) - } - } - - card, _ := pm["card"].(map[string]interface{}) - createdAt, _ := time.Parse(time.RFC3339, pm["created"].(string)) - - return &PaymentMethod{ - ID: pm["id"].(string), - Type: req.Type, - CustomerID: req.CustomerID, - Last4: card["last4"].(string), - Brand: card["brand"].(string), - ExpiryMonth: int(card["exp_month"].(float64)), - ExpiryYear: int(card["exp_year"].(float64)), - IsDefault: req.IsDefault, - CreatedAt: createdAt, - Metadata: req.Metadata, - }, nil -} - -// DeletePaymentMethod deletes a payment method from Stripe -func (s *StripeGateway) DeletePaymentMethod(ctx context.Context, paymentMethodID string) error { - _, err := s.makeStripeRequest(ctx, "DELETE", "/v1/payment_methods/"+paymentMethodID, nil) - if err != nil { - return fmt.Errorf("failed to delete payment method: %w", err) - } - return nil -} - -// ValidateWebhook validates a Stripe webhook -func (s *StripeGateway) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { - if s.webhookSecret == "" { - return false, fmt.Errorf("webhook secret not configured") - } - - // Parse signature - parts := strings.Split(signature, ",") - var timestamp, v1Signature string - for _, part := range parts { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "t=") { - timestamp = strings.TrimPrefix(part, "t=") - } else if strings.HasPrefix(part, "v1=") { - v1Signature = strings.TrimPrefix(part, "v1=") - } - } - - if timestamp == "" || v1Signature == "" { - return false, fmt.Errorf("invalid signature format") - } - - // Create expected signature - expectedSignature := s.computeHMACSHA256(timestamp, string(body), s.webhookSecret) - - return hmac.Equal([]byte(v1Signature), []byte(expectedSignature)), nil -} - -// Helper methods -func (s *StripeGateway) makeStripeRequest(ctx context.Context, method, path string, data interface{}) (interface{}, error) { - url := "https://api.stripe.com" + path - - var body io.Reader - if data != nil { - jsonData, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("failed to marshal request data: %w", err) - } - body = strings.NewReader(string(jsonData)) - } - - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+s.apiKey) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Stripe-Version", "2023-10-16") - - resp, err := s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode >= 400 { - var errorResp map[string]interface{} - if err := json.Unmarshal(respBody, &errorResp); err == nil { - if errorMsg, ok := errorResp["error"].(map[string]interface{}); ok { - if message, ok := errorMsg["message"].(string); ok { - return nil, fmt.Errorf("stripe error: %s", message) - } - } - } - return nil, fmt.Errorf("stripe error: %s", string(respBody)) - } - - var result interface{} - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return result, nil -} - -func (s *StripeGateway) mapStripeStatus(status string) PaymentStatusType { - switch status { - case "requires_payment_method", "requires_confirmation", "requires_action": - return PaymentStatusPending - case "processing": - return PaymentStatusProcessing - case "succeeded": - return PaymentStatusCompleted - case "canceled": - return PaymentStatusCancelled - default: - return PaymentStatusFailed - } -} - -func (s *StripeGateway) mapPaymentMethodType(pmt PaymentMethodType) string { - switch pmt { - case PaymentMethodTypeCreditCard: - return "card" - case PaymentMethodTypeBankAccount: - return "bank_account" - default: - return "card" - } -} - -func (s *StripeGateway) computeHMACSHA256(timestamp, payload, secret string) string { - data := timestamp + "." + payload - h := hmac.New(sha256.New, []byte(secret)) - h.Write([]byte(data)) - return hex.EncodeToString(h.Sum(nil)) -} - -// PaymentService handles payment operations -type PaymentService struct { - gateway PaymentGateway - db *database.Database - config *config.PaymentConfig -} - -// NewPaymentService creates a new payment service -func NewPaymentService(db *database.Database, cfg *config.PaymentConfig) *PaymentService { - var gateway PaymentGateway - - switch cfg.Provider { - case "stripe": - gateway = NewStripeGateway(cfg) - default: - gateway = NewStripeGateway(cfg) // Default to Stripe - } - - return &PaymentService{ - gateway: gateway, - db: db, - config: cfg, - } -} - -// ProcessTopUp processes a top-up payment -func (ps *PaymentService) ProcessTopUp(ctx context.Context, subscriberID int, amount float64, paymentMethodID string) (*models.Transaction, error) { - // Get subscriber - subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) - if err != nil { - return nil, fmt.Errorf("failed to get subscriber: %w", err) - } - - // Create payment request - req := &PaymentRequest{ - Amount: amount, - Currency: "USD", - Description: fmt.Sprintf("Top-up for subscriber %s", subscriber.MSISDN), - CustomerID: subscriber.MSISDN, - PaymentMethodID: paymentMethodID, - Metadata: map[string]string{ - "subscriber_id": fmt.Sprintf("%d", subscriberID), - "type": "top_up", - }, - } - - // Process payment - resp, err := ps.gateway.ProcessPayment(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to process payment: %w", err) - } - - // Create transaction record - transaction := &models.Transaction{ - SubscriberID: uint(subscriberID), - TransactionID: resp.TransactionID, - Type: "TOP_UP", - Amount: amount, - Currency: resp.Currency, - Status: string(resp.Status), - Description: req.Description, - CreatedAt: resp.ProcessedAt, - UpdatedAt: resp.ProcessedAt, - } - - // Save transaction - err = ps.db.CreateTransaction(ctx, transaction) - if err != nil { - return nil, fmt.Errorf("failed to save transaction: %w", err) - } - - // If payment was successful, update subscriber balance - if resp.Status == PaymentStatusCompleted { - err = ps.db.UpdateSubscriberBalance(ctx, uint(subscriberID), amount) - if err != nil { - return nil, fmt.Errorf("failed to update subscriber balance: %w", err) - } - } - - return transaction, nil -} - -// GetPaymentStatus gets payment status -func (ps *PaymentService) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { - return ps.gateway.GetPaymentStatus(ctx, transactionID) -} - -// CreatePaymentMethod creates a payment method -func (ps *PaymentService) CreatePaymentMethod(ctx context.Context, subscriberID int, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { - subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) - if err != nil { - return nil, fmt.Errorf("failed to get subscriber: %w", err) - } - - req.CustomerID = subscriber.MSISDN - - pm, err := ps.gateway.CreatePaymentMethod(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to create payment method: %w", err) - } - - // Save payment method to database - dbPM := &models.PaymentMethod{ - SubscriberID: uint(subscriberID), - GatewayID: pm.ID, - Type: models.PaymentMethodType(pm.Type), - Last4: pm.Last4, - Brand: pm.Brand, - ExpiryMonth: pm.ExpiryMonth, - ExpiryYear: pm.ExpiryYear, - IsDefault: pm.IsDefault, - CreatedAt: pm.CreatedAt, - } - - err = ps.db.CreatePaymentMethod(ctx, dbPM) - if err != nil { - return nil, fmt.Errorf("failed to save payment method: %w", err) - } - - pm.ID = fmt.Sprintf("%s", dbPM.ID) // Return database ID - return pm, nil -} - -// ValidateWebhook validates a webhook -func (ps *PaymentService) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { - return ps.gateway.ValidateWebhook(ctx, signature, body) -} diff --git a/apps/api-server/internal/payment/gateways/stripe.go b/apps/api-server/internal/payment/gateways/stripe.go deleted file mode 100644 index dbdab0c..0000000 --- a/apps/api-server/internal/payment/gateways/stripe.go +++ /dev/null @@ -1,288 +0,0 @@ -package gateways - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/stripe/stripe-go/v82" - "github.com/stripe/stripe-go/v82/customer" - "github.com/stripe/stripe-go/v82/invoice" - "github.com/stripe/stripe-go/v82/invoiceitem" - "github.com/stripe/stripe-go/v82/paymentintent" - "github.com/stripe/stripe-go/v82/paymentmethod" - "github.com/stripe/stripe-go/v82/refund" - "github.com/stripe/stripe-go/v82/webhook" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/payment" -) - -// StripeGateway implements payment processing via Stripe -type StripeGateway struct { - secretKey string - webhookSecret string - client *http.Client -} - -// NewStripeGateway creates a new Stripe payment gateway -func NewStripeGateway(secretKey, webhookSecret string) *StripeGateway { - stripe.Key = secretKey - - return &StripeGateway{ - secretKey: secretKey, - webhookSecret: webhookSecret, - client: &http.Client{Timeout: 30 * time.Second}, - } -} - -// ProcessPayment processes a payment via Stripe -func (g *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) { - // Create payment intent - params := &stripe.PaymentIntentParams{ - Amount: stripe.Int64(int64(req.Amount * 100)), // Convert to cents - Currency: stripe.String(string(req.Currency)), - Metadata: map[string]string{ - "subscriber_id": fmt.Sprintf("%d", req.SubscriberID), - "invoice_id": fmt.Sprintf("%d", req.InvoiceID), - }, - } - - if req.CustomerID != "" { - params.Customer = stripe.String(req.CustomerID) - } - - pi, err := paymentintent.New(params) - if err != nil { - return nil, fmt.Errorf("failed to create payment intent: %w", err) - } - - return &PaymentResponse{ - TransactionID: pi.ID, - Status: string(pi.Status), - ClientSecret: pi.ClientSecret, - Amount: req.Amount, - Currency: req.Currency, - CreatedAt: time.Unix(pi.Created, 0), - }, nil -} - -// RetrievePaymentMethodFromToken retrieves payment method details from a Stripe token -func (g *StripeGateway) RetrievePaymentMethodFromToken(ctx context.Context, token string) (*payment.PaymentMethodDetails, error) { - // Create payment method from token - params := &stripe.PaymentMethodParams{ - Type: stripe.String("card"), - Card: &stripe.PaymentMethodCardParams{ - Token: stripe.String(token), - }, - } - - pm, err := paymentmethod.New(params) - if err != nil { - return nil, fmt.Errorf("failed to create payment method from token: %w", err) - } - - // Extract card details - if pm.Card == nil { - return nil, fmt.Errorf("no card details found in payment method") - } - - return &payment.PaymentMethodDetails{ - PaymentMethodID: pm.ID, - Type: string(pm.Type), - Last4: pm.Card.Last4, - Brand: string(pm.Card.Brand), - ExpiryMonth: int(pm.Card.ExpMonth), - ExpiryYear: int(pm.Card.ExpYear), - Fingerprint: pm.Card.Fingerprint, - CreatedAt: time.Unix(pm.Created, 0), - }, nil -} - -// CreateCustomer creates a Stripe customer -func (g *StripeGateway) CreateCustomer(ctx context.Context, subscriber *models.Subscriber) (*CustomerResponse, error) { - params := &stripe.CustomerParams{ - Email: stripe.String(subscriber.Email), - Name: stripe.String(fmt.Sprintf("%s %s", subscriber.FirstName, subscriber.LastName)), - Metadata: map[string]string{ - "subscriber_id": fmt.Sprintf("%d", subscriber.ID), - "imsi": string(subscriber.IMSI), - }, - } - - cust, err := customer.New(params) - if err != nil { - return nil, fmt.Errorf("failed to create customer: %w", err) - } - - return &CustomerResponse{ - CustomerID: cust.ID, - Email: cust.Email, - Name: cust.Name, - CreatedAt: time.Unix(cust.Created, 0), - }, nil -} - -// ProcessRefund processes a refund via Stripe -func (g *StripeGateway) ProcessRefund(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) { - params := &stripe.RefundParams{ - PaymentIntent: stripe.String(transactionID), - } - - if amount > 0 { - params.Amount = stripe.Int64(int64(amount * 100)) // Convert to cents - } - - refund, err := refund.New(params) - if err != nil { - return nil, fmt.Errorf("failed to process refund: %w", err) - } - - return &RefundResponse{ - RefundID: refund.ID, - Amount: float64(refund.Amount) / 100.0, - Status: string(refund.Status), - CreatedAt: time.Unix(refund.Created, 0), - }, nil -} - -// GetPaymentStatus retrieves payment status from Stripe -func (g *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatusResponse, error) { - pi, err := paymentintent.Get(transactionID, nil) - if err != nil { - return nil, fmt.Errorf("failed to get payment intent: %w", err) - } - - return &PaymentStatusResponse{ - TransactionID: pi.ID, - Status: string(pi.Status), - Amount: float64(pi.Amount) / 100.0, - Currency: string(pi.Currency), - CreatedAt: time.Unix(pi.Created, 0), - }, nil -} - -// ValidateWebhook validates and processes Stripe webhooks -func (g *StripeGateway) ValidateWebhook(ctx context.Context, signatureHeader string, body []byte) (*WebhookEvent, error) { - event, err := webhook.ConstructEvent(body, signatureHeader, g.webhookSecret) - if err != nil { - return nil, fmt.Errorf("webhook validation failed: %w", err) - } - - return &WebhookEvent{ - Type: string(event.Type), - EventID: event.ID, - Created: time.Unix(event.Created, 0), - Data: event.Data.Raw, - }, nil -} - -// CreateInvoice creates a Stripe invoice -func (g *StripeGateway) CreateInvoice(ctx context.Context, customerID string, items []InvoiceItem) (*InvoiceResponse, error) { - // Create invoice items first - for _, item := range items { - itemParams := &stripe.InvoiceItemParams{ - Customer: stripe.String(customerID), - Amount: stripe.Int64(int64(item.Amount * 100)), - Currency: stripe.String(string(item.Currency)), - Description: stripe.String(item.Description), - Quantity: stripe.Int64(int64(item.Quantity)), - } - - _, err := invoiceitem.New(itemParams) - if err != nil { - return nil, fmt.Errorf("failed to create invoice item: %w", err) - } - } - - // Create the invoice - params := &stripe.InvoiceParams{ - Customer: stripe.String(customerID), - AutoAdvance: stripe.Bool(true), - } - - inv, err := invoice.New(params) - if err != nil { - return nil, fmt.Errorf("failed to create invoice: %w", err) - } - - return &InvoiceResponse{ - InvoiceID: inv.ID, - Status: string(inv.Status), - Amount: float64(inv.Total) / 100.0, - Currency: string(inv.Currency), - CreatedAt: time.Unix(inv.Created, 0), - }, nil -} - -// PaymentRequest represents a payment request -type PaymentRequest struct { - SubscriberID uint `json:"subscriber_id"` - InvoiceID uint `json:"invoice_id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CustomerID string `json:"customer_id,omitempty"` - Description string `json:"description,omitempty"` -} - -// PaymentResponse represents a payment response -type PaymentResponse struct { - TransactionID string `json:"transaction_id"` - Status string `json:"status"` - ClientSecret string `json:"client_secret"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` -} - -// CustomerResponse represents a customer creation response -type CustomerResponse struct { - CustomerID string `json:"customer_id"` - Email string `json:"email"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` -} - -// RefundResponse represents a refund response -type RefundResponse struct { - RefundID string `json:"refund_id"` - Amount float64 `json:"amount"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` -} - -// PaymentStatusResponse represents payment status response -type PaymentStatusResponse struct { - TransactionID string `json:"transaction_id"` - Status string `json:"status"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` -} - -// WebhookEvent represents a webhook event -type WebhookEvent struct { - Type string `json:"type"` - EventID string `json:"event_id"` - Created time.Time `json:"created"` - Data json.RawMessage `json:"data"` -} - -// InvoiceItem represents an invoice line item -type InvoiceItem struct { - Description string `json:"description"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Quantity int `json:"quantity"` -} - -// InvoiceResponse represents an invoice response -type InvoiceResponse struct { - InvoiceID string `json:"invoice_id"` - Status string `json:"status"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` -} diff --git a/apps/api-server/internal/payment/webhook.go b/apps/api-server/internal/payment/webhook.go deleted file mode 100644 index 2780c53..0000000 --- a/apps/api-server/internal/payment/webhook.go +++ /dev/null @@ -1,594 +0,0 @@ -package payment - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// WebhookEvent represents a payment webhook event -type WebhookEvent struct { - ID string `json:"id"` - Type string `json:"type"` - Object string `json:"object"` - Created int64 `json:"created"` - Livemode bool `json:"livemode"` - Data map[string]interface{} `json:"data"` -} - -// WebhookHandler handles payment webhooks -type WebhookHandler struct { - paymentService *PaymentService - db *database.Database -} - -// NewWebhookHandler creates a new webhook handler -func NewWebhookHandler(paymentService *PaymentService, db *database.Database) *WebhookHandler { - return &WebhookHandler{ - paymentService: paymentService, - db: db, - } -} - -// HandleStripeWebhook handles Stripe webhooks -func (wh *WebhookHandler) HandleStripeWebhook(c *gin.Context) { - body, err := c.GetRawData() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) - return - } - - signature := c.GetHeader("Stripe-Signature") - if signature == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing signature"}) - return - } - - // Validate webhook - valid, err := wh.paymentService.ValidateWebhook(c.Request.Context(), signature, body) - if err != nil { - log.Printf("Webhook validation error: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook"}) - return - } - - if !valid { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) - return - } - - // Parse event - var event WebhookEvent - if err := json.Unmarshal(body, &event); err != nil { - log.Printf("Failed to parse webhook event: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event format"}) - return - } - - // Handle different event types - switch event.Type { - case "payment_intent.succeeded": - err = wh.handlePaymentSucceeded(c.Request.Context(), event) - case "payment_intent.payment_failed": - err = wh.handlePaymentFailed(c.Request.Context(), event) - case "payment_intent.canceled": - err = wh.handlePaymentCanceled(c.Request.Context(), event) - case "charge.dispute.created": - err = wh.handleDisputeCreated(c.Request.Context(), event) - default: - log.Printf("Unhandled webhook event type: %s", event.Type) - } - - if err != nil { - log.Printf("Error handling webhook event %s: %v", event.Type, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process webhook"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "received"}) -} - -// handlePaymentSucceeded handles successful payment events -func (wh *WebhookHandler) handlePaymentSucceeded(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]interface{}) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - amount := float64(paymentIntent["amount"].(int64)) / 100 - currency := paymentIntent["currency"].(string) - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "COMPLETED" - transaction.UpdatedAt = time.Now() - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - // If it's a top-up, update subscriber balance - if transaction.Type == "TOP_UP" { - err = wh.db.UpdateSubscriberBalance(ctx, transaction.SubscriberID, amount) - if err != nil { - return fmt.Errorf("failed to update subscriber balance: %w", err) - } - - // Send notification to subscriber - err = wh.sendTopUpNotification(ctx, transaction.SubscriberID, amount, currency) - if err != nil { - log.Printf("Failed to send top-up notification: %v", err) - } - } - - log.Printf("Payment succeeded: %s, amount: %.2f %s", transactionID, amount, currency) - return nil -} - -// handlePaymentFailed handles failed payment events -func (wh *WebhookHandler) handlePaymentFailed(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]interface{}) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "FAILED" - transaction.UpdatedAt = time.Now() - - // Add failure reason if available - if lastPaymentError, ok := paymentIntent["last_payment_error"].(map[string]interface{}); ok { - if message, ok := lastPaymentError["message"].(string); ok { - transaction.Description += " - Failed: " + message - } - } - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - // Create alert for failed payment - subID := int(transaction.SubscriberID) - alert := &models.Alert{ - SubscriberID: &subID, - Type: models.AlertTypePaymentFailed, - Severity: models.AlertSeverityMedium, - Message: fmt.Sprintf("Payment of %.2f %s failed", transaction.Amount, transaction.Currency), - Resolved: false, - Timestamp: time.Now(), - } - - err = wh.db.CreateAlert(ctx, alert) - if err != nil { - log.Printf("Failed to create payment failure alert: %v", err) - } - - log.Printf("Payment failed: %s", transactionID) - return nil -} - -// handlePaymentCanceled handles canceled payment events -func (wh *WebhookHandler) handlePaymentCanceled(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]interface{}) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "CANCELLED" - transaction.UpdatedAt = time.Now() - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - log.Printf("Payment canceled: %s", transactionID) - return nil -} - -// handleDisputeCreated handles dispute events -func (wh *WebhookHandler) handleDisputeCreated(ctx context.Context, event WebhookEvent) error { - dispute, ok := event.Data["object"].(map[string]interface{}) - if !ok { - return fmt.Errorf("invalid dispute object") - } - - chargeID, ok := dispute["charge"].(string) - if !ok { - return fmt.Errorf("missing charge ID") - } - - amount := float64(dispute["amount"].(int64)) / 100 - reason, _ := dispute["reason"].(string) - - // Find transaction by charge ID - transaction, err := wh.db.GetTransactionByChargeID(ctx, chargeID) - if err != nil { - return fmt.Errorf("failed to find transaction for charge %s: %w", chargeID, err) - } - - // Create high-priority alert for dispute - dispSubID := int(transaction.SubscriberID) - alert := &models.Alert{ - SubscriberID: &dispSubID, - Type: models.AlertTypePaymentFailed, - Severity: models.AlertSeverityHigh, - Message: fmt.Sprintf("Payment dispute created for %.2f %s. Reason: %s", amount, transaction.Currency, reason), - Resolved: false, - Timestamp: time.Now(), - } - - err = wh.db.CreateAlert(ctx, alert) - if err != nil { - return fmt.Errorf("failed to create dispute alert: %w", err) - } - - log.Printf("Dispute created for charge %s, amount: %.2f", chargeID, amount) - return nil -} - -// sendTopUpNotification sends a notification for successful top-up -func (wh *WebhookHandler) sendTopUpNotification(ctx context.Context, subscriberID uint, amount float64, currency string) error { - // Get subscriber details - subscriber, err := wh.db.GetSubscriber(ctx, subscriberID) - if err != nil { - return fmt.Errorf("failed to get subscriber: %w", err) - } - - // Create notification - notification := &models.Notification{ - SubscriberID: subscriberID, - Type: "PAYMENT_SUCCESS", - Title: "Top-up Successful", - Message: fmt.Sprintf("Your account has been topped up with %.2f %s. New balance: %.2f %s", - amount, currency, subscriber.Balance, currency), - Read: false, - CreatedAt: time.Now(), - } - - err = wh.db.CreateNotification(ctx, notification) - if err != nil { - return fmt.Errorf("failed to create notification: %w", err) - } - - // Send email notification (implementation would depend on email service) - // This is a placeholder for email sending logic - log.Printf("Top-up notification sent to subscriber %d: %.2f %s", subscriberID, amount, currency) - - return nil -} - -// PaymentController handles payment-related HTTP endpoints -type PaymentController struct { - paymentService *PaymentService - db *database.Database -} - -// NewPaymentController creates a new payment controller -func NewPaymentController(paymentService *PaymentService, db *database.Database) *PaymentController { - return &PaymentController{ - paymentService: paymentService, - db: db, - } -} - -// SetupRoutes sets up payment routes -func (pc *PaymentController) SetupRoutes(router *gin.RouterGroup) { - payments := router.Group("/payments") - { - payments.POST("/top-up", pc.ProcessTopUp) - payments.POST("/payment-methods", pc.CreatePaymentMethod) - payments.DELETE("/payment-methods/:id", pc.DeletePaymentMethod) - payments.GET("/payment-methods", pc.ListPaymentMethods) - payments.GET("/transactions", pc.ListTransactions) - payments.GET("/transactions/:id", pc.GetTransaction) - payments.POST("/transactions/:id/refund", pc.RefundTransaction) - } - - webhook := router.Group("/webhooks") - { - webhook.POST("/stripe", pc.HandleStripeWebhook) - } -} - -// ProcessTopUp processes a top-up payment -func (pc *PaymentController) ProcessTopUp(c *gin.Context) { - var req struct { - Amount float64 `json:"amount" binding:"required,min=0.01"` - PaymentMethodID string `json:"paymentMethodId" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get subscriber ID from context (would be set by auth middleware) - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transaction, err := pc.paymentService.ProcessTopUp(c.Request.Context(), subscriberID, req.Amount, req.PaymentMethodID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "transaction": transaction, - "message": "Top-up processed successfully", - }) -} - -// CreatePaymentMethod creates a new payment method -func (pc *PaymentController) CreatePaymentMethod(c *gin.Context) { - var req struct { - Type string `json:"type" binding:"required"` - Token string `json:"token" binding:"required"` - IsDefault bool `json:"isDefault"` - Metadata map[string]string `json:"metadata"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - createReq := &CreatePaymentMethodRequest{ - Type: PaymentMethodType(req.Type), - Token: req.Token, - IsDefault: req.IsDefault, - Metadata: req.Metadata, - } - - paymentMethod, err := pc.paymentService.CreatePaymentMethod(c.Request.Context(), subscriberID, createReq) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "paymentMethod": paymentMethod, - "message": "Payment method created successfully", - }) -} - -// DeletePaymentMethod deletes a payment method -func (pc *PaymentController) DeletePaymentMethod(c *gin.Context) { - paymentMethodID := c.Param("id") - if paymentMethodID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Payment method ID required"}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - // Verify payment method belongs to subscriber - pm, err := pc.db.GetPaymentMethod(c.Request.Context(), paymentMethodID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Payment method not found"}) - return - } - - if pm.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Payment method does not belong to subscriber"}) - return - } - - err = pc.paymentService.gateway.DeletePaymentMethod(c.Request.Context(), pm.GatewayID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Delete from database - err = pc.db.DeletePaymentMethod(c.Request.Context(), paymentMethodID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Payment method deleted successfully"}) -} - -// ListPaymentMethods lists payment methods for a subscriber -func (pc *PaymentController) ListPaymentMethods(c *gin.Context) { - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - paymentMethods, err := pc.db.ListPaymentMethods(c.Request.Context(), uint(subscriberID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"paymentMethods": paymentMethods}) -} - -// ListTransactions lists transactions for a subscriber -func (pc *PaymentController) ListTransactions(c *gin.Context) { - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transactions, err := pc.db.ListTransactions(c.Request.Context(), uint(subscriberID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"transactions": transactions}) -} - -// GetTransaction gets a specific transaction -func (pc *PaymentController) GetTransaction(c *gin.Context) { - transactionID := c.Param("id") - if transactionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) - return - } - - if transaction.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) - return - } - - c.JSON(http.StatusOK, gin.H{"transaction": transaction}) -} - -// RefundTransaction refunds a transaction -func (pc *PaymentController) RefundTransaction(c *gin.Context) { - transactionID := c.Param("id") - if transactionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) - return - } - - var req struct { - Amount float64 `json:"amount" binding:"required,min=0.01"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - // Get transaction - transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) - return - } - - if transaction.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) - return - } - - if transaction.Status != "COMPLETED" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot refund non-completed transaction"}) - return - } - - // Process refund - refundResp, err := pc.paymentService.gateway.RefundPayment(c.Request.Context(), transaction.TransactionID, req.Amount) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Create refund record - refund := &models.Transaction{ - SubscriberID: transaction.SubscriberID, - TransactionID: refundResp.RefundID, - Type: "REFUND", - Amount: req.Amount, - Currency: refundResp.Currency, - Status: string(refundResp.Status), - Description: fmt.Sprintf("Refund for transaction %s", transactionID), - ParentID: &transaction.ID, - CreatedAt: refundResp.ProcessedAt, - UpdatedAt: refundResp.ProcessedAt, - } - - err = pc.db.CreateTransaction(c.Request.Context(), refund) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save refund"}) - return - } - - // Update subscriber balance - err = pc.db.UpdateSubscriberBalance(c.Request.Context(), transaction.SubscriberID, -req.Amount) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "refund": refund, - "message": "Refund processed successfully", - }) -} - -// HandleStripeWebhook handles Stripe webhooks (delegates to webhook handler) -func (pc *PaymentController) HandleStripeWebhook(c *gin.Context) { - webhookHandler := NewWebhookHandler(pc.paymentService, pc.db) - webhookHandler.HandleStripeWebhook(c) -} diff --git a/apps/api-server/internal/services/chaos.go b/apps/api-server/internal/services/chaos.go deleted file mode 100644 index 3afac2e..0000000 --- a/apps/api-server/internal/services/chaos.go +++ /dev/null @@ -1,259 +0,0 @@ -package services - -import ( - "context" - "fmt" - "math/rand" - "net/http" - "sync" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" -) - -// ChaosService implements chaos engineering experiments -type ChaosService struct { - db *database.Database - active map[string]*Experiment - mu sync.RWMutex -} - -type Experiment struct { - ID string - Name string - Target string - Type ExperimentType - Config ExperimentConfig - Status ExperimentStatus - Started time.Time - Finished *time.Time - Error string -} - -type ExperimentType string - -const ( - ExperimentTypeLatency ExperimentType = "latency" - ExperimentTypeKill ExperimentType = "kill" - ExperimentTypeCrash ExperimentType = "crash" - ExperimentTypePartition ExperimentType = "partition" - ExperimentTypeFlush ExperimentType = "flush" - ExperimentTypeLoad ExperimentType = "load" -) - -type ExperimentStatus string - -const ( - StatusIdle ExperimentStatus = "idle" - StatusRunning ExperimentStatus = "running" - StatusCompleted ExperimentStatus = "completed" - StatusFailed ExperimentStatus = "failed" -) - -type ExperimentConfig struct { - Duration time.Duration - Probability float64 - Amount int - Target string -} - -type ChaosMiddleware struct { - service *ChaosService -} - -// NewChaosService creates a new chaos engineering service -func NewChaosService(db *database.Database) *ChaosService { - return &ChaosService{ - db: db, - active: make(map[string]*Experiment), - } -} - -// RunExperiment starts a chaos experiment -func (cs *ChaosService) RunExperiment(ctx context.Context, expType ExperimentType, config ExperimentConfig) (*Experiment, error) { - cs.mu.Lock() - defer cs.mu.Unlock() - - id := fmt.Sprintf("exp_%d", time.Now().Unix()) - exp := &Experiment{ - ID: id, - Name: fmt.Sprintf("%s Experiment", expType), - Target: config.Target, - Type: expType, - Config: config, - Status: StatusRunning, - Started: time.Now(), - } - - cs.active[id] = exp - - // Run experiment in background - go cs.executeExperiment(ctx, exp) - - return exp, nil -} - -func (cs *ChaosService) executeExperiment(ctx context.Context, exp *Experiment) { - defer func() { - cs.mu.Lock() - finished := time.Now() - exp.Finished = &finished - if exp.Status == StatusRunning { - exp.Status = StatusCompleted - } - delete(cs.active, exp.ID) - cs.mu.Unlock() - }() - - switch exp.Type { - case ExperimentTypeLatency: - cs.injectLatency(ctx, exp) - case ExperimentTypeKill: - cs.killConnections(ctx, exp) - case ExperimentTypeCrash: - cs.crashService(ctx, exp) - case ExperimentTypePartition: - cs.networkPartition(ctx, exp) - case ExperimentTypeFlush: - cs.flushCache(ctx, exp) - case ExperimentTypeLoad: - cs.simulateLoad(ctx, exp) - default: - cs.mu.Lock() - exp.Status = StatusFailed - exp.Error = "unknown experiment type" - cs.mu.Unlock() - } -} - -func (cs *ChaosService) injectLatency(_ context.Context, exp *Experiment) { - // Simulate latency injection - delay := time.Duration(rand.Intn(1000)+100) * time.Millisecond - time.Sleep(delay) - - cs.mu.Lock() - exp.Status = StatusCompleted - cs.mu.Unlock() -} - -func (cs *ChaosService) killConnections(_ context.Context, exp *Experiment) { - // Simulate database connection killing - time.Sleep(exp.Config.Duration) - - cs.mu.Lock() - exp.Status = StatusCompleted - cs.mu.Unlock() -} - -func (cs *ChaosService) crashService(_ context.Context, exp *Experiment) { - // Simulate service crash - time.Sleep(exp.Config.Duration) - - cs.mu.Lock() - if rand.Float64() > 0.2 { - exp.Status = StatusCompleted - } else { - exp.Status = StatusFailed - exp.Error = "service crashed irrecoverably" - } - cs.mu.Unlock() -} - -func (cs *ChaosService) networkPartition(_ context.Context, exp *Experiment) { - // Simulate network partition - time.Sleep(exp.Config.Duration) - - cs.mu.Lock() - exp.Status = StatusCompleted - cs.mu.Unlock() -} - -func (cs *ChaosService) flushCache(_ context.Context, exp *Experiment) { - // Simulate cache flush - time.Sleep(exp.Config.Duration) - - cs.mu.Lock() - exp.Status = StatusCompleted - cs.mu.Unlock() -} - -func (cs *ChaosService) simulateLoad(ctx context.Context, exp *Experiment) { - // Simulate high load - start := time.Now() - for time.Since(start) < exp.Config.Duration { - select { - case <-ctx.Done(): - return - default: - // Simulate work - time.Sleep(time.Millisecond * 10) - } - } - - cs.mu.Lock() - exp.Status = StatusCompleted - cs.mu.Unlock() -} - -// GetActiveExperiments returns all currently running experiments -func (cs *ChaosService) GetActiveExperiments() []*Experiment { - cs.mu.RLock() - defer cs.mu.RUnlock() - - experiments := make([]*Experiment, 0, len(cs.active)) - for _, exp := range cs.active { - experiments = append(experiments, exp) - } - return experiments -} - -// GetExperimentStatus returns the status of a specific experiment -func (cs *ChaosService) GetExperimentStatus(id string) (*Experiment, bool) { - cs.mu.RLock() - defer cs.mu.RUnlock() - - exp, exists := cs.active[id] - return exp, exists -} - -// Middleware function to inject chaos into HTTP requests -func (cm *ChaosMiddleware) InjectChaos(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cm.service.mu.RLock() - for _, exp := range cm.service.active { - if exp.Type == ExperimentTypeLatency && exp.Status == StatusRunning { - // Inject random latency - if rand.Float64() < exp.Config.Probability { - delay := time.Duration(rand.Intn(int(exp.Config.Duration.Milliseconds()))) * time.Millisecond - time.Sleep(delay) - } - } - } - cm.service.mu.RUnlock() - - next.ServeHTTP(w, r) - }) -} - -// GetExperimentHistory returns a summary of past experiments (placeholder) -func (cs *ChaosService) GetExperimentHistory() ([]*Experiment, error) { - // In a real implementation, this would query the database for experiment history - return []*Experiment{}, nil -} - -// StopExperiment stops a running experiment -func (cs *ChaosService) StopExperiment(id string) error { - cs.mu.Lock() - defer cs.mu.Unlock() - - if exp, exists := cs.active[id]; exists { - if exp.Status == StatusRunning { - exp.Status = StatusCompleted - finished := time.Now() - exp.Finished = &finished - delete(cs.active, id) - return nil - } - } - return fmt.Errorf("experiment %s not found or not running", id) -} diff --git a/apps/api-server/internal/services/charging.go b/apps/api-server/internal/services/charging.go deleted file mode 100644 index bc7c8de..0000000 --- a/apps/api-server/internal/services/charging.go +++ /dev/null @@ -1,396 +0,0 @@ -package services - -import ( - "context" - "fmt" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// ChargingService handles charging and billing operations -type ChargingService struct { - db *database.Database - engine *ChargingEngineClient -} - -// NewChargingService creates a new charging service -func NewChargingService(db *database.Database, cfg *config.Config) *ChargingService { - return &ChargingService{ - db: db, - engine: NewChargingEngineClient(&cfg.ChargingEngine), - } -} - -// GetSystemStats gets system statistics -func (cs *ChargingService) GetSystemStats(ctx context.Context) (*models.SystemStats, error) { - var stats models.SystemStats - - // Count active sessions - var activeSessions int64 - if err := cs.db.DB.WithContext(ctx).Model(&models.Session{}). - Where("status IN ?", []string{"active", "connected", "established"}). - Count(&activeSessions).Error; err != nil { - return nil, fmt.Errorf("failed to count active sessions: %w", err) - } - stats.ActiveSessions = int(activeSessions) - - // Count total accounts - var totalAccounts int64 - if err := cs.db.DB.WithContext(ctx).Model(&models.SubscriberAccount{}). - Count(&totalAccounts).Error; err != nil { - return nil, fmt.Errorf("failed to count total accounts: %w", err) - } - stats.TotalAccounts = int(totalAccounts) - - // Count blocked users - var blockedUsers int64 - if err := cs.db.DB.WithContext(ctx).Model(&models.Subscriber{}). - Where("status IN ?", []string{"suspended", "deactivated", "blocked"}). - Count(&blockedUsers).Error; err != nil { - return nil, fmt.Errorf("failed to count blocked users: %w", err) - } - stats.BlockedUsers = int(blockedUsers) - - // Count low balance alerts - var lowBalanceAlerts int64 - if err := cs.db.DB.WithContext(ctx).Model(&models.Alert{}). - Where("type = ? AND resolved = ?", models.AlertTypeLowBalance, false). - Count(&lowBalanceAlerts).Error; err != nil { - return nil, fmt.Errorf("failed to count low balance alerts: %w", err) - } - stats.LowBalanceAlerts = int(lowBalanceAlerts) - - // Calculate uptime (simplified - in real implementation would track service start time) - stats.Uptime = time.Since(time.Now().AddDate(0, 0, -7)).Seconds() // Assume 7 days uptime - - return &stats, nil -} - -// GetHealthStatus gets health status -func (cs *ChargingService) GetHealthStatus(ctx context.Context) (*models.HealthStatus, error) { - status := &models.HealthStatus{ - RedisConnected: true, // In real implementation, would check Redis connection - ActiveSync: true, // In real implementation, would check sync status - LastSync: time.Now(), - MemoryUsage: 50000000, // In real implementation, would get actual memory usage - } - - // Check database connectivity - if err := cs.db.DB.WithContext(ctx).Exec("SELECT 1").Error; err != nil { - status.ActiveSync = false - } - - return status, nil -} - -// GetUsageStats gets usage statistics for a subscriber -func (cs *ChargingService) GetUsageStats(ctx context.Context, imsi string, period string) (*UsageStats, error) { - var dataUsage, voiceUsage, smsUsage, totalCost float64 - - // Calculate date range based on period - var startTime time.Time - switch period { - case "daily": - startTime = time.Now().AddDate(0, 0, -1) - case "weekly": - startTime = time.Now().AddDate(0, 0, -7) - case "monthly": - startTime = time.Now().AddDate(0, -1, 0) - default: - startTime = time.Now().AddDate(0, -1, 0) // Default to monthly - } - - // Get usage statistics from database - if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}). - Where("imsi = ? AND created_at >= ?", imsi, startTime). - Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost", - models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS). - Row().Scan(&dataUsage, &voiceUsage, &smsUsage, &totalCost); err != nil { - return nil, fmt.Errorf("failed to get usage stats: %w", err) - } - - // Calculate trend (compare with previous period) - var prevDataUsage, prevVoiceUsage, prevSmsUsage float64 - prevStartTime := startTime.AddDate(0, 0, -int(time.Since(startTime).Hours()/24)) - - if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}). - Where("imsi = ? AND created_at >= ? AND created_at < ?", imsi, prevStartTime, startTime). - Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage", - models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS). - Row().Scan(&prevDataUsage, &prevVoiceUsage, &prevSmsUsage); err != nil { - // If no previous data, set trend to zero - prevDataUsage, prevVoiceUsage, prevSmsUsage = 0, 0, 0 - } - - // Calculate trend percentage - totalCurrentUsage := dataUsage + voiceUsage + smsUsage - totalPrevUsage := prevDataUsage + prevVoiceUsage + prevSmsUsage - var trendPercentage float64 - if totalPrevUsage > 0 { - trendPercentage = ((totalCurrentUsage - totalPrevUsage) / totalPrevUsage) * 100 - } - - direction := "STABLE" - if trendPercentage > 5 { - direction = "UP" - } else if trendPercentage < -5 { - direction = "DOWN" - } - - stats := &UsageStats{ - DataUsage: dataUsage, - VoiceUsage: voiceUsage, - SmsUsage: smsUsage, - Cost: totalCost, - Period: period, - Trend: &UsageTrend{ - Direction: direction, - Percentage: trendPercentage, - PeriodOverPeriod: trendPercentage, - }, - } - return stats, nil -} - -// GetRealTimeUsage gets real-time usage for a subscriber -func (cs *ChargingService) GetRealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) { - // Get current active session - var session models.Session - var currentSession *CurrentSession - - if err := cs.db.DB.WithContext(ctx). - Where("imsi = ? AND status IN ?", imsi, []string{"active", "connected", "established"}). - First(&session).Error; err == nil { - // Get usage for current session - var dataUsed, voiceUsed, smsUsed, sessionCost float64 - if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}). - Where("session_id = ?", session.SessionID). - Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost", - models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS). - Row().Scan(&dataUsed, &voiceUsed, &smsUsed, &sessionCost); err != nil { - dataUsed, voiceUsed, smsUsed, sessionCost = 0, 0, 0, 0 - } - - currentSession = &CurrentSession{ - SessionID: session.SessionID, - StartTime: session.CreatedAt, - DataUsed: dataUsed, - VoiceUsed: voiceUsed, - SmsUsed: smsUsed, - Cost: sessionCost, - } - } - - // Get today's usage - today := time.Now().Truncate(24 * time.Hour) - var dataUsed, voiceUsed, smsUsed, todayCost float64 - - if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}). - Where("imsi = ? AND created_at >= ?", imsi, today). - Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost", - models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS). - Row().Scan(&dataUsed, &voiceUsed, &smsUsed, &todayCost); err != nil { - dataUsed, voiceUsed, smsUsed, todayCost = 0, 0, 0, 0 - } - - usage := &RealTimeUsage{ - CurrentSession: currentSession, - TodayUsage: &TodayUsage{ - DataUsed: dataUsed, - VoiceUsed: voiceUsed, - SmsUsed: smsUsed, - Cost: todayCost, - }, - } - return usage, nil -} - -// ListUsageEvents lists usage events -func (cs *ChargingService) ListUsageEvents(ctx context.Context, limit int, offset int, where string) ([]*models.UsageEvent, int64, error) { - query := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}) - - // Apply where clause if provided - if where != "" { - query = query.Where(where) - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count usage events: %w", err) - } - - // Get usage events with pagination - var events []*models.UsageEvent - if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("timestamp DESC").Find(&events).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list usage events: %w", err) - } - - return events, total, nil -} - -// SearchUsageEvents searches usage events -func (cs *ChargingService) SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) { - var events []*models.UsageEvent - - // Search usage events by IMSI, session ID, or usage type - searchPattern := "%" + query + "%" - if err := cs.db.DB.WithContext(ctx). - Where("imsi ILIKE ? OR session_id ILIKE ? OR usage_type ILIKE ?", - searchPattern, searchPattern, searchPattern). - Preload("Subscriber"). - Limit(limit). - Order("timestamp DESC"). - Find(&events).Error; err != nil { - return nil, fmt.Errorf("failed to search usage events: %w", err) - } - - return events, nil -} - -// SubscribeToUsageUpdates subscribes to usage updates -func (cs *ChargingService) SubscribeToUsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) { - ch := make(chan *models.UsageEvent, 20) // Buffered channel for high-frequency updates - - go func() { - defer close(ch) - - // Poll for new usage events every 1 second (high frequency for real-time usage) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - var lastEventID uint = 0 - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // Query for new usage events since last check - var events []models.UsageEvent - if err := cs.db.DB.WithContext(ctx). - Where("imsi = ? AND id > ?", imsi, lastEventID). - Order("id ASC"). - Limit(20). - Find(&events).Error; err != nil { - // Log error but continue polling - continue - } - - // Send new events to channel - for _, event := range events { - select { - case ch <- &event: - lastEventID = event.ID - case <-ctx.Done(): - return - } - } - } - } - }() - - return ch, nil -} - -// SubscribeToSystemStatsUpdates subscribes to system stats updates -func (cs *ChargingService) SubscribeToSystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) { - ch := make(chan *models.SystemStats, 5) // Buffered channel to prevent blocking - - go func() { - defer close(ch) - - // Poll for system stats updates every 5 seconds (lower frequency for system stats) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - var lastStats models.SystemStats - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // Get current system stats - stats, err := cs.GetSystemStats(ctx) - if err != nil { - // Log error but continue polling - continue - } - - // Only send if stats have changed significantly - if lastStats.ActiveSessions != stats.ActiveSessions || - lastStats.TotalAccounts != stats.TotalAccounts || - lastStats.BlockedUsers != stats.BlockedUsers || - lastStats.LowBalanceAlerts != stats.LowBalanceAlerts { - - select { - case ch <- stats: - lastStats = *stats - case <-ctx.Done(): - return - } - } - } - } - }() - - return ch, nil -} - -// TriggerMaintenance triggers system maintenance -func (cs *ChargingService) TriggerMaintenance(ctx context.Context) (bool, error) { - // In a real implementation, this would trigger maintenance tasks like: - // - Database cleanup - // - Cache refresh - // - Log rotation - // - Health checks - - // For now, simulate maintenance by updating a maintenance timestamp - if err := cs.db.DB.WithContext(ctx).Exec("UPDATE system_config SET last_maintenance = ? WHERE id = 1", time.Now()).Error; err != nil { - // If table doesn't exist, just return success - return true, nil - } - - return true, nil -} - -// Helper types for charging service -type UsageStats struct { - DataUsage float64 `json:"data_usage"` - VoiceUsage float64 `json:"voice_usage"` - SmsUsage float64 `json:"sms_usage"` - Cost float64 `json:"cost"` - Period string `json:"period"` - Trend *UsageTrend `json:"trend"` -} - -type UsageTrend struct { - Direction string `json:"direction"` - Percentage float64 `json:"percentage"` - PeriodOverPeriod float64 `json:"period_over_period"` -} - -type RealTimeUsage struct { - CurrentSession *CurrentSession `json:"current_session"` - TodayUsage *TodayUsage `json:"today_usage"` -} - -type CurrentSession struct { - SessionID string `json:"session_id"` - StartTime time.Time `json:"start_time"` - DataUsed float64 `json:"data_used"` - VoiceUsed float64 `json:"voice_used"` - SmsUsed float64 `json:"sms_used"` - Cost float64 `json:"cost"` -} - -type TodayUsage struct { - DataUsed float64 `json:"data_used"` - VoiceUsed float64 `json:"voice_used"` - SmsUsed float64 `json:"sms_used"` - Cost float64 `json:"cost"` -} diff --git a/apps/api-server/internal/services/charging_client.go b/apps/api-server/internal/services/charging_client.go deleted file mode 100644 index 27e9dee..0000000 --- a/apps/api-server/internal/services/charging_client.go +++ /dev/null @@ -1,187 +0,0 @@ -package services - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" -) - -// ChargingEngineClient is an HTTP client for the Rust charging engine -type ChargingEngineClient struct { - baseURL string - httpClient *http.Client -} - -// NewChargingEngineClient creates a new client for the Rust charging engine -func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineClient { - return &ChargingEngineClient{ - baseURL: cfg.BaseURL, - httpClient: &http.Client{ - Timeout: cfg.Timeout, - }, - } -} - -type CreditCheckRequest struct { - BytesRequested uint64 `json:"bytes_requested"` -} - -type CreditCheckResponse struct { - Allowed bool `json:"allowed"` - RemainingBytes int64 `json:"remaining_bytes"` -} - -type DeductRequest struct { - BytesUsed uint64 `json:"bytes_used"` -} - -type AddCreditRequest struct { - BytesToAdd uint64 `json:"bytes_to_add"` -} - -type BalanceResponse struct { - IP string `json:"ip"` - BalanceBytes int64 `json:"balance_bytes"` -} - -type EngineHealthResponse struct { - Status string `json:"status"` - Timestamp string `json:"timestamp"` -} - -// CheckCredit checks if a subscriber (by IP) has enough credit -func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytesRequested uint64) (*CreditCheckResponse, error) { - body, err := json.Marshal(CreditCheckRequest{BytesRequested: bytesRequested}) - if err != nil { - return nil, fmt.Errorf("marshal credit check request: %w", err) - } - - url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("charging engine request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) - } - - var result CreditCheckResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - return &result, nil -} - -// DeductCredit deducts bytes from a subscriber's credit balance -func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, bytesUsed uint64) error { - body, err := json.Marshal(DeductRequest{BytesUsed: bytesUsed}) - if err != nil { - return fmt.Errorf("marshal deduct request: %w", err) - } - - url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("charging engine request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("charging engine returned status %d", resp.StatusCode) - } - return nil -} - -// AddCredit adds bytes to a subscriber's credit balance -func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesToAdd uint64) error { - body, err := json.Marshal(AddCreditRequest{BytesToAdd: bytesToAdd}) - if err != nil { - return fmt.Errorf("marshal add credit request: %w", err) - } - - url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("charging engine request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("charging engine returned status %d", resp.StatusCode) - } - return nil -} - -// GetBalance gets the current credit balance for a subscriber -func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) { - url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("charging engine request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) - } - - var result BalanceResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - return &result, nil -} - -// HealthCheck checks if the Rust charging engine is healthy -func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) { - url := fmt.Sprintf("%s/health", c.baseURL) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("charging engine health check failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode) - } - - var result EngineHealthResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - return &result, nil -} diff --git a/apps/api-server/internal/services/charging_client_test.go b/apps/api-server/internal/services/charging_client_test.go deleted file mode 100644 index e26f0e1..0000000 --- a/apps/api-server/internal/services/charging_client_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" -) - -func newTestServer(handler http.HandlerFunc) (*httptest.Server, *ChargingEngineClient) { - srv := httptest.NewServer(handler) - client := NewChargingEngineClient(&config.ChargingEngineConfig{ - BaseURL: srv.URL, - Timeout: 5 * time.Second, - }) - return srv, client -} - -func TestCheckCredit_Allowed(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/credit/10.0.0.1/check" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - var req CreditCheckRequest - json.NewDecoder(r.Body).Decode(&req) - if req.BytesRequested != 1000 { - t.Errorf("expected 1000 bytes, got %d", req.BytesRequested) - } - json.NewEncoder(w).Encode(CreditCheckResponse{Allowed: true, RemainingBytes: 5000}) - }) - defer srv.Close() - - resp, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !resp.Allowed { - t.Error("expected allowed=true") - } - if resp.RemainingBytes != 5000 { - t.Errorf("expected remaining=5000, got %d", resp.RemainingBytes) - } -} - -func TestCheckCredit_ServerError(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }) - defer srv.Close() - - _, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000) - if err == nil { - t.Fatal("expected error for 500 response") - } -} - -func TestDeductCredit(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/credit/10.0.0.2/deduct" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - var req DeductRequest - json.NewDecoder(r.Body).Decode(&req) - if req.BytesUsed != 500 { - t.Errorf("expected 500, got %d", req.BytesUsed) - } - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - err := client.DeductCredit(context.Background(), "10.0.0.2", 500) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestAddCredit(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/credit/10.0.0.3/add" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - var req AddCreditRequest - json.NewDecoder(r.Body).Decode(&req) - if req.BytesToAdd != 2000 { - t.Errorf("expected 2000, got %d", req.BytesToAdd) - } - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - err := client.AddCredit(context.Background(), "10.0.0.3", 2000) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestGetBalance(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - t.Errorf("expected GET, got %s", r.Method) - } - json.NewEncoder(w).Encode(BalanceResponse{IP: "10.0.0.4", BalanceBytes: 9999}) - }) - defer srv.Close() - - resp, err := client.GetBalance(context.Background(), "10.0.0.4") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.BalanceBytes != 9999 { - t.Errorf("expected 9999, got %d", resp.BalanceBytes) - } -} - -func TestHealthCheck(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/health" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - json.NewEncoder(w).Encode(EngineHealthResponse{Status: "ok", Timestamp: "2026-04-14T12:00:00Z"}) - }) - defer srv.Close() - - resp, err := client.HealthCheck(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Status != "ok" { - t.Errorf("expected status=ok, got %s", resp.Status) - } -} - -func TestContextCancellation(t *testing.T) { - srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) - w.WriteHeader(http.StatusOK) - }) - defer srv.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - _, err := client.GetBalance(ctx, "10.0.0.1") - if err == nil { - t.Fatal("expected timeout error") - } -} diff --git a/apps/api-server/internal/services/charging_test.go b/apps/api-server/internal/services/charging_test.go deleted file mode 100644 index 7b94787..0000000 --- a/apps/api-server/internal/services/charging_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package services - -import ( - "context" - "testing" -) - -func TestGetSystemStats(t *testing.T) { - cs := &ChargingService{db: nil} - stats, err := cs.GetSystemStats(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if stats == nil { - t.Fatal("expected non-nil stats") - } - if stats.ActiveSessions != 10 { - t.Errorf("expected 10 active sessions, got %d", stats.ActiveSessions) - } - if stats.TotalAccounts != 100 { - t.Errorf("expected 100 total accounts, got %d", stats.TotalAccounts) - } -} - -func TestGetHealthStatus(t *testing.T) { - cs := &ChargingService{db: nil} - health, err := cs.GetHealthStatus(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if health == nil { - t.Fatal("expected non-nil health") - } - if !health.RedisConnected { - t.Error("expected redis_connected=true") - } -} - -func TestGetUsageStats(t *testing.T) { - cs := &ChargingService{db: nil} - stats, err := cs.GetUsageStats(context.Background(), "208930000000001", "DAILY") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if stats.Period != "DAILY" { - t.Errorf("expected period=DAILY, got %s", stats.Period) - } - if stats.Trend == nil { - t.Fatal("expected non-nil trend") - } -} - -func TestGetRealTimeUsage(t *testing.T) { - cs := &ChargingService{db: nil} - usage, err := cs.GetRealTimeUsage(context.Background(), "208930000000001") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if usage.CurrentSession == nil { - t.Fatal("expected non-nil current session") - } - if usage.TodayUsage == nil { - t.Fatal("expected non-nil today usage") - } -} - -func TestListUsageEvents(t *testing.T) { - cs := &ChargingService{db: nil} - events, total, err := cs.ListUsageEvents(context.Background(), 10, 0, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if total < 1 { - t.Error("expected at least 1 event") - } - if len(events) < 1 { - t.Error("expected at least 1 event in slice") - } -} - -func TestSearchUsageEvents(t *testing.T) { - cs := &ChargingService{db: nil} - events, err := cs.SearchUsageEvents(context.Background(), "data", 10) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(events) < 1 { - t.Error("expected at least 1 event") - } -} - -func TestTriggerMaintenance(t *testing.T) { - cs := &ChargingService{db: nil} - ok, err := cs.TriggerMaintenance(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !ok { - t.Error("expected maintenance trigger to succeed") - } -} diff --git a/apps/api-server/internal/services/invoice.go b/apps/api-server/internal/services/invoice.go deleted file mode 100644 index 69cf8b8..0000000 --- a/apps/api-server/internal/services/invoice.go +++ /dev/null @@ -1,198 +0,0 @@ -package services - -import ( - "context" - "fmt" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// InvoiceService handles invoice generation and management -type InvoiceService struct { - db *database.Database -} - -// NewInvoiceService creates a new invoice service -func NewInvoiceService(db *database.Database) *InvoiceService { - return &InvoiceService{db: db} -} - -// GenerateMonthlyInvoice generates a monthly invoice for a subscriber -func (s *InvoiceService) GenerateMonthlyInvoice(ctx context.Context, subscriberID uint, billingPeriod string) (*models.Invoice, error) { - // Get subscriber - var subscriber models.Subscriber - if err := s.db.DB.First(&subscriber, subscriberID).Error; err != nil { - return nil, fmt.Errorf("subscriber not found: %w", err) - } - - // Get usage events for the billing period - startDate, err := parseBillingPeriod(billingPeriod) - if err != nil { - return nil, fmt.Errorf("invalid billing period: %w", err) - } - endDate := startDate.AddDate(0, 1, 0).Add(-time.Second) - - var usageEvents []models.UsageEvent - if err := s.db.DB.Where("subscriber_id = ? AND timestamp BETWEEN ? AND ?", - subscriberID, startDate, endDate).Find(&usageEvents).Error; err != nil { - return nil, fmt.Errorf("failed to fetch usage events: %w", err) - } - - // Create invoice with existing model structure - invoice := &models.Invoice{ - SubscriberID: subscriberID, - DueDate: time.Now().AddDate(0, 0, 30), // Due in 30 days - Status: models.InvoiceStatusDraft, - Currency: "USD", - CreatedAt: time.Now(), - } - - // Add plan fee - planFee := 25.00 // Default monthly fee - invoice.LineItems = append(invoice.LineItems, models.InvoiceLineItem{ - Description: "Monthly Plan Fee", - Quantity: 1, - UnitPrice: planFee, - Amount: planFee, - }) - - // Add usage charges - totalUsageCost := 0.0 - for _, event := range usageEvents { - totalUsageCost += event.Cost - } - - if totalUsageCost > 0 { - invoice.LineItems = append(invoice.LineItems, models.InvoiceLineItem{ - Description: "Usage Charges", - Quantity: 1, - UnitPrice: totalUsageCost, - Amount: totalUsageCost, - }) - } - - // Calculate total amount - invoice.Amount = planFee + totalUsageCost - - // Save invoice - if err := s.db.DB.Create(invoice).Error; err != nil { - return nil, fmt.Errorf("failed to create invoice: %w", err) - } - - // Load subscriber relationship - s.db.DB.Preload("Subscriber").First(invoice, invoice.ID) - - return invoice, nil -} - -// SendInvoice sends an invoice to the subscriber -func (s *InvoiceService) SendInvoice(ctx context.Context, invoiceID uint) error { - var invoice models.Invoice - if err := s.db.DB.First(&invoice, invoiceID).Error; err != nil { - return fmt.Errorf("invoice not found: %w", err) - } - - // Update status to pending - invoice.Status = models.InvoiceStatusPending - if err := s.db.DB.Save(&invoice).Error; err != nil { - return fmt.Errorf("failed to update invoice status: %w", err) - } - - // TODO: Send email notification - // This would integrate with an email service - - return nil -} - -// MarkInvoiceAsPaid marks an invoice as paid -func (s *InvoiceService) MarkInvoiceAsPaid(ctx context.Context, invoiceID uint, paymentMethod string) error { - var invoice models.Invoice - if err := s.db.DB.First(&invoice, invoiceID).Error; err != nil { - return fmt.Errorf("invoice not found: %w", err) - } - - invoice.Status = models.InvoiceStatusPaid - // Update payment date if needed - - return s.db.DB.Save(&invoice).Error -} - -// GetOverdueInvoices returns all overdue invoices -func (s *InvoiceService) GetOverdueInvoices(ctx context.Context) ([]models.Invoice, error) { - var invoices []models.Invoice - err := s.db.DB.Where("status = ? AND due_date < ?", - models.InvoiceStatusPending, time.Now()).Find(&invoices).Error - return invoices, err -} - -// GetSubscriberInvoices returns all invoices for a subscriber -func (s *InvoiceService) GetSubscriberInvoices(ctx context.Context, subscriberID uint) ([]models.Invoice, error) { - var invoices []models.Invoice - err := s.db.DB.Where("subscriber_id = ?", subscriberID). - Preload("LineItems"). - Order("created_at DESC"). - Find(&invoices).Error - return invoices, err -} - -// parseBillingPeriod parses a billing period string like "2024-01" into a time.Time -func parseBillingPeriod(period string) (time.Time, error) { - if len(period) != 7 || period[4] != '-' { - return time.Time{}, fmt.Errorf("invalid billing period format, expected YYYY-MM") - } - - year := period[:4] - month := period[5:] - - layout := "2006-01" - return time.Parse(layout, fmt.Sprintf("%s-%s", year, month)) -} - -// InvoiceGenerator handles automated invoice generation -type InvoiceGenerator struct { - invoiceService *InvoiceService -} - -// NewInvoiceGenerator creates a new invoice generator -func NewInvoiceGenerator(invoiceService *InvoiceService) *InvoiceGenerator { - return &InvoiceGenerator{invoiceService: invoiceService} -} - -// GenerateAllMonthlyInvoices generates monthly invoices for all active subscribers -func (g *InvoiceGenerator) GenerateAllMonthlyInvoices(ctx context.Context, billingPeriod string) error { - // Get all active subscribers - var subscribers []models.Subscriber - if err := g.invoiceService.db.DB.Where("status = ?", "active").Find(&subscribers).Error; err != nil { - return fmt.Errorf("failed to fetch subscribers: %w", err) - } - - var errors []error - for _, subscriber := range subscribers { - // Check if invoice already exists for this period by checking creation date - startDate, _ := parseBillingPeriod(billingPeriod) - endDate := startDate.AddDate(0, 1, 0).Add(-time.Second) - - var existingInvoice models.Invoice - err := g.invoiceService.db.DB.Where("subscriber_id = ? AND created_at BETWEEN ? AND ?", - subscriber.ID, startDate, endDate).First(&existingInvoice).Error - - if err == nil { - // Invoice already exists, skip - continue - } - - // Generate new invoice - _, err = g.invoiceService.GenerateMonthlyInvoice(ctx, subscriber.ID, billingPeriod) - if err != nil { - errors = append(errors, fmt.Errorf("failed to generate invoice for subscriber %d: %w", subscriber.ID, err)) - } - } - - if len(errors) > 0 { - return fmt.Errorf("encountered %d errors while generating invoices", len(errors)) - } - - return nil -} diff --git a/apps/api-server/internal/services/subscriber.go b/apps/api-server/internal/services/subscriber.go index d659fb7..b4c34c0 100644 --- a/apps/api-server/internal/services/subscriber.go +++ b/apps/api-server/internal/services/subscriber.go @@ -3,13 +3,11 @@ package services import ( "context" "fmt" - "strconv" "time" "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/payment/gateways" ) // SubscriberService handles subscriber management operations @@ -18,7 +16,6 @@ type SubscriberService struct { config *config.Config amfClient *AMFClient es2Service *ES2Service - stripeGW *gateways.StripeGateway } // NewSubscriberService creates a new subscriber service @@ -28,7 +25,6 @@ func NewSubscriberService(db *database.Database, cfg *config.Config) *Subscriber config: cfg, amfClient: NewAMFClient("http://localhost:8081"), // Default AMF URL es2Service: NewES2Service(&cfg.ES2), - stripeGW: gateways.NewStripeGateway(cfg.Payment.StripeAPIKey, cfg.Payment.StripeWebhookSecret), } } @@ -265,549 +261,3 @@ type ListSubscribersResponse struct { Page int `json:"page"` PageSize int `json:"page_size"` } - -// GetAccount gets subscriber account information -func (s *SubscriberService) GetAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) { - subscriber, err := s.db.GetSubscriberByIMSI(ctx, models.IMSI(imsi)) - if err != nil { - return nil, err - } - - // Use the subscriber's service plan for limits and get actual usage from database - var dataUsed, voiceUsed, smsUsed float64 - - // Get actual usage records from database - if err := s.db.DB.WithContext(ctx).Model(&models.UsageEvent{}). - Where("imsi = ? AND created_at >= ?", subscriber.IMSI, time.Now().AddDate(0, -1, 0)). - Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_used, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_used, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_used", - models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS). - Row().Scan(&dataUsed, &voiceUsed, &smsUsed); err != nil { - // If no usage records found, start with zero - dataUsed, voiceUsed, smsUsed = 0, 0, 0 - } - - // Get actual balance from billing system or database - var balance float64 - if err := s.db.DB.WithContext(ctx).Model(&models.SubscriberAccount{}). - Where("imsi = ?", subscriber.IMSI). - Select("balance").Scan(&balance).Error; err != nil { - // If no account exists, start with default balance - balance = 0.0 - } - - account := &models.SubscriberAccount{ - IMSI: string(subscriber.IMSI), - Balance: balance, - DataLimit: float64(subscriber.Plan.DataLimit), - DataUsed: dataUsed, - VoiceLimit: float64(subscriber.Plan.VoiceLimit), - VoiceUsed: voiceUsed, - SMSLimit: float64(subscriber.Plan.SMSLimit), - SMSUsed: smsUsed, - Status: string(subscriber.Status), - LastUpdated: time.Now(), - } - - return account, nil -} - -// ListInvoices lists invoices for a subscriber -func (s *SubscriberService) ListInvoices(ctx context.Context, limit int, offset int, imsi *string, status *models.InvoiceStatus) ([]*models.Invoice, int64, error) { - query := s.db.DB.WithContext(ctx).Model(&models.Invoice{}) - - // Apply filters - if imsi != nil { - // Get subscriber ID from IMSI - var subscriber models.Subscriber - if err := s.db.DB.WithContext(ctx).Where("imsi = ?", *imsi).First(&subscriber).Error; err != nil { - return nil, 0, fmt.Errorf("subscriber not found: %w", err) - } - query = query.Where("subscriber_id = ?", subscriber.ID) - } - - if status != nil { - query = query.Where("status = ?", *status) - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Get invoices with pagination - var invoices []*models.Invoice - if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("created_at DESC").Find(&invoices).Error; err != nil { - return nil, 0, err - } - - return invoices, total, nil -} - -// GetInvoice gets a specific invoice -func (s *SubscriberService) GetInvoice(ctx context.Context, id string) (*models.Invoice, error) { - var invoice models.Invoice - - // Parse ID to integer - invoiceID, err := strconv.ParseUint(id, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid invoice ID: %w", err) - } - - // Get invoice from database with line items and subscriber - if err := s.db.DB.WithContext(ctx). - Preload("LineItems"). - Preload("Subscriber"). - First(&invoice, invoiceID).Error; err != nil { - return nil, fmt.Errorf("invoice not found: %w", err) - } - - return &invoice, nil -} - -// ListRatingPlans lists available rating plans -func (s *SubscriberService) ListRatingPlans(ctx context.Context) ([]*models.RatingPlan, error) { - var plans []*models.RatingPlan - - // Get all active rating plans from database - if err := s.db.DB.WithContext(ctx). - Where("is_active = ?", true). - Order("monthly_fee ASC"). - Find(&plans).Error; err != nil { - return nil, fmt.Errorf("failed to list rating plans: %w", err) - } - - return plans, nil -} - -// GetRatingPlan gets a specific rating plan -func (s *SubscriberService) GetRatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) { - var plan models.RatingPlan - - // Get rating plan from database - if err := s.db.DB.WithContext(ctx). - Where("plan_id = ? AND is_active = ?", planId, true). - First(&plan).Error; err != nil { - return nil, fmt.Errorf("rating plan not found: %w", err) - } - - return &plan, nil -} - -// ListAlerts lists alerts -func (s *SubscriberService) ListAlerts(ctx context.Context, limit int, offset int, subscriberId *int, severity *models.AlertSeverity, resolved *bool) ([]*models.Alert, int64, error) { - query := s.db.DB.WithContext(ctx).Model(&models.Alert{}) - - // Apply filters - if subscriberId != nil { - query = query.Where("subscriber_id = ?", *subscriberId) - } - - if severity != nil { - query = query.Where("severity = ?", *severity) - } - - if resolved != nil { - query = query.Where("resolved = ?", *resolved) - } - - // Get total count - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - // Get alerts with pagination - var alerts []*models.Alert - if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("timestamp DESC").Find(&alerts).Error; err != nil { - return nil, 0, err - } - - return alerts, total, nil -} - -// SearchSubscribers searches subscribers -func (s *SubscriberService) SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) { - var subscribers []*models.Subscriber - - // Search subscribers by name, MSISDN, or IMSI - searchPattern := "%" + query + "%" - if err := s.db.DB.WithContext(ctx). - Where("first_name ILIKE ? OR last_name ILIKE ? OR msisdn ILIKE ? OR imsi ILIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern). - Limit(limit). - Find(&subscribers).Error; err != nil { - return nil, fmt.Errorf("failed to search subscribers: %w", err) - } - - return subscribers, nil -} - -// ResolveAlert resolves an alert -func (s *SubscriberService) ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) { - var alert models.Alert - - // Parse ID to integer - alertID, err := strconv.ParseUint(alertId, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid alert ID: %w", err) - } - - // Update alert as resolved - if err := s.db.DB.WithContext(ctx). - Model(&alert). - Where("id = ?", alertID). - Update("resolved", true). - Update("resolved_at", time.Now()). - Error; err != nil { - return nil, fmt.Errorf("failed to resolve alert: %w", err) - } - - // Get updated alert - if err := s.db.DB.WithContext(ctx). - Preload("Subscriber"). - First(&alert, alertID).Error; err != nil { - return nil, fmt.Errorf("alert not found: %w", err) - } - - return &alert, nil -} - -// CreateAlert creates an alert -func (s *SubscriberService) CreateAlert(ctx context.Context, req *models.CreateAlertRequest) (*models.Alert, error) { - alert := &models.Alert{ - Type: req.Type, - Severity: req.Severity, - Message: req.Message, - SubscriberID: req.SubscriberID, - Timestamp: time.Now(), - Resolved: false, - } - - // Create alert in database - if err := s.db.DB.WithContext(ctx).Create(alert).Error; err != nil { - return nil, fmt.Errorf("failed to create alert: %w", err) - } - - // Get created alert with subscriber - if err := s.db.DB.WithContext(ctx). - Preload("Subscriber"). - First(alert, alert.ID).Error; err != nil { - return nil, fmt.Errorf("failed to retrieve created alert: %w", err) - } - - return alert, nil -} - -// SubscribeToSubscriberUpdates subscribes to subscriber updates -func (s *SubscriberService) SubscribeToSubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) { - ch := make(chan *models.Subscriber, 5) // Buffered channel to prevent blocking - - go func() { - defer close(ch) - - // Poll for subscriber updates every 3 seconds - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - - var lastUpdatedAt time.Time - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // Query for subscriber updates since last check - var subscriber models.Subscriber - query := s.db.DB.WithContext(ctx).Where("imsi = ?", subscriberId) - if !lastUpdatedAt.IsZero() { - query = query.Where("updated_at > ?", lastUpdatedAt) - } - - if err := query.First(&subscriber).Error; err != nil { - // Subscriber not found or error, continue polling - continue - } - - // Send updated subscriber to channel - select { - case ch <- &subscriber: - lastUpdatedAt = subscriber.UpdatedAt - case <-ctx.Done(): - return - } - } - } - }() - - return ch, nil -} - -// SubscribeToAlertUpdates subscribes to alert updates -func (s *SubscriberService) SubscribeToAlertUpdates(ctx context.Context, subscriberId *string) (<-chan *models.Alert, error) { - ch := make(chan *models.Alert, 10) // Buffered channel to prevent blocking - - go func() { - defer close(ch) - - // Poll for new alerts every 2 seconds - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - var lastAlertID uint = 0 - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // Query for new alerts since last check - var alerts []models.Alert - query := s.db.DB.WithContext(ctx).Where("id > ?", lastAlertID) - - // Filter by subscriber if specified - if subscriberId != nil { - // Convert subscriberId string to uint for database query - if subID, err := strconv.ParseUint(*subscriberId, 10, 32); err == nil { - query = query.Where("subscriber_id = ?", subID) - } - } - - if err := query.Order("id ASC").Limit(10).Find(&alerts).Error; err != nil { - // Log error but continue polling - continue - } - - // Send new alerts to channel - for _, alert := range alerts { - select { - case ch <- &alert: - lastAlertID = alert.ID - case <-ctx.Done(): - return - } - } - } - } - }() - - return ch, nil -} - -// AddPaymentMethod adds a payment method -func (s *SubscriberService) AddPaymentMethod(ctx context.Context, subscriberId int, req *models.AddPaymentMethodRequest) (*models.PaymentMethod, error) { - // Generate unique payment method ID - paymentMethodID := fmt.Sprintf("pm_%d_%d", subscriberId, time.Now().Unix()) - - // Process payment token using real Stripe API to extract card details - last4, brand, expiryMonth, expiryYear, err := s.processPaymentToken(ctx, req.Token) - if err != nil { - return nil, fmt.Errorf("failed to process payment token: %w", err) - } - - paymentMethod := &models.PaymentMethod{ - ID: paymentMethodID, - SubscriberID: uint(subscriberId), - GatewayID: "default_gateway", - Type: req.Type, - CustomerID: fmt.Sprintf("cus_%d", subscriberId), - Last4: last4, - Brand: brand, - ExpiryMonth: expiryMonth, - ExpiryYear: expiryYear, - IsDefault: req.IsDefault, - CreatedAt: time.Now(), - } - - // Create payment method in database - if err := s.db.DB.WithContext(ctx).Create(paymentMethod).Error; err != nil { - return nil, fmt.Errorf("failed to add payment method: %w", err) - } - - return paymentMethod, nil -} - -// processPaymentToken processes payment gateway token using real Stripe API -func (s *SubscriberService) processPaymentToken(ctx context.Context, token string) (last4, brand string, expiryMonth, expiryYear int, err error) { - // Call Stripe API to retrieve payment method details from token - details, err := s.stripeGW.RetrievePaymentMethodFromToken(ctx, token) - if err != nil { - return "", "", 0, 0, fmt.Errorf("failed to retrieve payment method from token: %w", err) - } - - // Return the actual card details from Stripe - return details.Last4, details.Brand, details.ExpiryMonth, details.ExpiryYear, nil -} - -// RemovePaymentMethod removes a payment method -func (s *SubscriberService) RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) { - // Check if payment method exists - var paymentMethod models.PaymentMethod - if err := s.db.DB.WithContext(ctx).First(&paymentMethod, "id = ?", paymentMethodId).Error; err != nil { - return false, fmt.Errorf("payment method not found: %w", err) - } - - // Don't allow removal if it's the only payment method and has default status - var count int64 - if err := s.db.DB.WithContext(ctx). - Model(&models.PaymentMethod{}). - Where("subscriber_id = ?", paymentMethod.SubscriberID). - Count(&count).Error; err != nil { - return false, fmt.Errorf("failed to check payment methods: %w", err) - } - - if count == 1 { - return false, fmt.Errorf("cannot remove the only payment method") - } - - // If this is the default payment method, set another one as default - if paymentMethod.IsDefault { - var newDefault models.PaymentMethod - if err := s.db.DB.WithContext(ctx). - Where("subscriber_id = ? AND id != ?", paymentMethod.SubscriberID, paymentMethodId). - First(&newDefault).Error; err != nil { - return false, fmt.Errorf("failed to find alternative payment method: %w", err) - } - - if err := s.db.DB.WithContext(ctx). - Model(&newDefault). - Update("is_default", true).Error; err != nil { - return false, fmt.Errorf("failed to set new default payment method: %w", err) - } - } - - // Delete the payment method - if err := s.db.DB.WithContext(ctx).Delete(&paymentMethod).Error; err != nil { - return false, fmt.Errorf("failed to delete payment method: %w", err) - } - - return true, nil -} - -// SetDefaultPaymentMethod sets default payment method -func (s *SubscriberService) SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) { - var paymentMethod models.PaymentMethod - - // Start transaction - tx := s.db.DB.WithContext(ctx).Begin() - - // Unset all existing default payment methods for this subscriber - if err := tx.Model(&models.PaymentMethod{}). - Where("id = ?", paymentMethodId). - First(&paymentMethod).Error; err != nil { - tx.Rollback() - return nil, fmt.Errorf("payment method not found: %w", err) - } - - // Set all other payment methods as non-default - if err := tx.Model(&models.PaymentMethod{}). - Where("subscriber_id = ? AND id != ?", paymentMethod.SubscriberID, paymentMethodId). - Update("is_default", false).Error; err != nil { - tx.Rollback() - return nil, fmt.Errorf("failed to update other payment methods: %w", err) - } - - // Set this payment method as default - if err := tx.Model(&paymentMethod). - Update("is_default", true).Error; err != nil { - tx.Rollback() - return nil, fmt.Errorf("failed to set default payment method: %w", err) - } - - // Commit transaction - if err := tx.Commit().Error; err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) - } - - return &paymentMethod, nil -} - -// TopUpBalance tops up subscriber balance -func (s *SubscriberService) TopUpBalance(ctx context.Context, imsi string, req *models.TopUpRequest) (*models.SubscriberAccount, error) { - var account models.SubscriberAccount - - // Get subscriber account from database - if err := s.db.DB.WithContext(ctx). - Where("imsi = ?", imsi). - First(&account).Error; err != nil { - return nil, fmt.Errorf("subscriber account not found: %w", err) - } - - // Update balance - if err := s.db.DB.WithContext(ctx). - Model(&account). - Where("imsi = ?", imsi). - Update("balance", account.Balance+req.Amount). - Update("last_updated", time.Now()).Error; err != nil { - return nil, fmt.Errorf("failed to top up balance: %w", err) - } - - // Get updated account - if err := s.db.DB.WithContext(ctx). - Where("imsi = ?", imsi). - First(&account).Error; err != nil { - return nil, fmt.Errorf("failed to retrieve updated account: %w", err) - } - - return &account, nil -} - -// DeleteSubscriber deletes a subscriber -func (s *SubscriberService) DeleteSubscriber(ctx context.Context, subscriberId uint) (bool, error) { - // Start transaction to delete all related data - tx := s.db.DB.WithContext(ctx).Begin() - - // Delete subscriber's payment methods - if err := tx.Where("subscriber_id = ?", subscriberId).Delete(&models.PaymentMethod{}).Error; err != nil { - tx.Rollback() - return false, fmt.Errorf("failed to delete payment methods: %w", err) - } - - // Delete subscriber's alerts - if err := tx.Where("subscriber_id = ?", subscriberId).Delete(&models.Alert{}).Error; err != nil { - tx.Rollback() - return false, fmt.Errorf("failed to delete alerts: %w", err) - } - - // Delete subscriber's account - if err := tx.Where("imsi IN (SELECT imsi FROM subscribers WHERE id = ?))", subscriberId).Delete(&models.SubscriberAccount{}).Error; err != nil { - tx.Rollback() - return false, fmt.Errorf("failed to delete account: %w", err) - } - - // Delete subscriber - if err := tx.Delete(&models.Subscriber{}, subscriberId).Error; err != nil { - tx.Rollback() - return false, fmt.Errorf("failed to delete subscriber: %w", err) - } - - // Commit transaction - if err := tx.Commit().Error; err != nil { - return false, fmt.Errorf("failed to commit deletion: %w", err) - } - - return true, nil -} - -// ActivateSubscriber activates a subscriber -func (s *SubscriberService) ActivateSubscriber(ctx context.Context, subscriberId uint) (*models.Subscriber, error) { - var subscriber models.Subscriber - - // Update subscriber status to active - if err := s.db.DB.WithContext(ctx). - Model(&subscriber). - Where("id = ?", subscriberId). - Update("status", models.SubscriberStatusActive). - Update("activated_at", time.Now()).Error; err != nil { - return nil, fmt.Errorf("failed to activate subscriber: %w", err) - } - - // Get updated subscriber - if err := s.db.DB.WithContext(ctx). - Preload("Plan"). - First(&subscriber, subscriberId).Error; err != nil { - return nil, fmt.Errorf("failed to retrieve activated subscriber: %w", err) - } - - return &subscriber, nil -}