From 1b2b9d2412d78c0b556858cf3043a84b8a0aca90 Mon Sep 17 00:00:00 2001 From: Nutcase <143586703+nutcas3@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:08:27 +0300 Subject: [PATCH] Revert "Revert "App layer"" (#5) --- apps/api-server/cmd/server.go | 385 +++++++++++ apps/api-server/go.mod | 21 +- apps/api-server/go.sum | 36 + apps/api-server/internal/config/config.go | 64 +- apps/api-server/internal/database/database.go | 85 ++- apps/api-server/internal/graphql/resolvers.go | 15 +- .../api-server/internal/metrics/prometheus.go | 333 ++++++++++ .../api-server/internal/metrics/timeseries.go | 387 +++++++++++ apps/api-server/internal/models/account.go | 235 +++++++ apps/api-server/internal/models/subscriber.go | 5 +- apps/api-server/internal/payment/gateway.go | 620 ++++++++++++++++++ .../internal/payment/gateways/stripe.go | 288 ++++++++ apps/api-server/internal/payment/webhook.go | 594 +++++++++++++++++ apps/api-server/internal/services/chaos.go | 259 ++++++++ apps/api-server/internal/services/charging.go | 396 +++++++++++ .../internal/services/charging_client.go | 187 ++++++ .../internal/services/charging_client_test.go | 154 +++++ .../internal/services/charging_test.go | 101 +++ apps/api-server/internal/services/invoice.go | 198 ++++++ .../internal/services/subscriber.go | 550 ++++++++++++++++ 20 files changed, 4881 insertions(+), 32 deletions(-) create mode 100644 apps/api-server/cmd/server.go create mode 100644 apps/api-server/internal/metrics/prometheus.go create mode 100644 apps/api-server/internal/metrics/timeseries.go create mode 100644 apps/api-server/internal/models/account.go create mode 100644 apps/api-server/internal/payment/gateway.go create mode 100644 apps/api-server/internal/payment/gateways/stripe.go create mode 100644 apps/api-server/internal/payment/webhook.go create mode 100644 apps/api-server/internal/services/chaos.go create mode 100644 apps/api-server/internal/services/charging.go create mode 100644 apps/api-server/internal/services/charging_client.go create mode 100644 apps/api-server/internal/services/charging_client_test.go create mode 100644 apps/api-server/internal/services/charging_test.go create mode 100644 apps/api-server/internal/services/invoice.go diff --git a/apps/api-server/cmd/server.go b/apps/api-server/cmd/server.go new file mode 100644 index 0000000..177de8c --- /dev/null +++ b/apps/api-server/cmd/server.go @@ -0,0 +1,385 @@ +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 457775d..a9864d7 100644 --- a/apps/api-server/go.mod +++ b/apps/api-server/go.mod @@ -13,6 +13,22 @@ 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 @@ -25,6 +41,7 @@ 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 @@ -41,15 +58,17 @@ 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.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.1 // 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 4c521e6..f5d1272 100644 --- a/apps/api-server/go.sum +++ b/apps/api-server/go.sum @@ -1,9 +1,19 @@ +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= @@ -34,6 +44,10 @@ 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= @@ -50,6 +64,7 @@ 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= @@ -67,16 +82,31 @@ 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= @@ -89,10 +119,14 @@ 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= @@ -108,6 +142,8 @@ 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 10d412d..268fae5 100644 --- a/apps/api-server/internal/config/config.go +++ b/apps/api-server/internal/config/config.go @@ -5,15 +5,30 @@ import ( ) type Config struct { - Server ServerConfig `json:"server"` - Database DatabaseConfig `json:"database"` - Redis RedisConfig `json:"redis"` - IMSI IMSIConfig `json:"imsi"` - ES2 ES2Config `json:"es2"` + 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"` } type ServerConfig struct { Port string `json:"port"` + MetricsPort string `json:"metrics_port"` ReadTimeout time.Duration `json:"read_timeout"` WriteTimeout time.Duration `json:"write_timeout"` } @@ -35,15 +50,22 @@ 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"` } @@ -51,6 +73,7 @@ func LoadConfig() *Config { return &Config{ Server: ServerConfig{ Port: "8000", + MetricsPort: "9090", ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, }, @@ -69,15 +92,30 @@ 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 0728d69..b1feed7 100644 --- a/apps/api-server/internal/database/database.go +++ b/apps/api-server/internal/database/database.go @@ -188,11 +188,88 @@ 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 e084b73..493f7e9 100644 --- a/apps/api-server/internal/graphql/resolvers.go +++ b/apps/api-server/internal/graphql/resolvers.go @@ -298,8 +298,6 @@ 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, @@ -443,8 +441,6 @@ 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) } @@ -454,15 +450,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) { - return r.subscriber.SubscribeToAlertUpdates(ctx, severity) + // Convert severity to string for the service call + severityStr := string(severity) + return r.subscriber.SubscribeToAlertUpdates(ctx, &severityStr) } 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 { @@ -491,13 +487,10 @@ 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{}, @@ -511,8 +504,6 @@ 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 new file mode 100644 index 0000000..d5681d4 --- /dev/null +++ b/apps/api-server/internal/metrics/prometheus.go @@ -0,0 +1,333 @@ +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 new file mode 100644 index 0000000..8199d68 --- /dev/null +++ b/apps/api-server/internal/metrics/timeseries.go @@ -0,0 +1,387 @@ +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 new file mode 100644 index 0000000..8e23660 --- /dev/null +++ b/apps/api-server/internal/models/account.go @@ -0,0 +1,235 @@ +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 a1d38ab..2ad0d34 100644 --- a/apps/api-server/internal/models/subscriber.go +++ b/apps/api-server/internal/models/subscriber.go @@ -19,8 +19,9 @@ 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"` + Status SubscriberStatus `json:"status" gorm:"default:'active'"` + PlanID uint `json:"plan_id"` + Balance float64 `json:"balance" gorm:"default:0"` 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 new file mode 100644 index 0000000..ac4e5f8 --- /dev/null +++ b/apps/api-server/internal/payment/gateway.go @@ -0,0 +1,620 @@ +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 new file mode 100644 index 0000000..dbdab0c --- /dev/null +++ b/apps/api-server/internal/payment/gateways/stripe.go @@ -0,0 +1,288 @@ +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 new file mode 100644 index 0000000..2780c53 --- /dev/null +++ b/apps/api-server/internal/payment/webhook.go @@ -0,0 +1,594 @@ +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 new file mode 100644 index 0000000..3afac2e --- /dev/null +++ b/apps/api-server/internal/services/chaos.go @@ -0,0 +1,259 @@ +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 new file mode 100644 index 0000000..bc7c8de --- /dev/null +++ b/apps/api-server/internal/services/charging.go @@ -0,0 +1,396 @@ +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 new file mode 100644 index 0000000..27e9dee --- /dev/null +++ b/apps/api-server/internal/services/charging_client.go @@ -0,0 +1,187 @@ +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 new file mode 100644 index 0000000..e26f0e1 --- /dev/null +++ b/apps/api-server/internal/services/charging_client_test.go @@ -0,0 +1,154 @@ +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 new file mode 100644 index 0000000..7b94787 --- /dev/null +++ b/apps/api-server/internal/services/charging_test.go @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..69cf8b8 --- /dev/null +++ b/apps/api-server/internal/services/invoice.go @@ -0,0 +1,198 @@ +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 b4c34c0..d659fb7 100644 --- a/apps/api-server/internal/services/subscriber.go +++ b/apps/api-server/internal/services/subscriber.go @@ -3,11 +3,13 @@ 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 @@ -16,6 +18,7 @@ type SubscriberService struct { config *config.Config amfClient *AMFClient es2Service *ES2Service + stripeGW *gateways.StripeGateway } // NewSubscriberService creates a new subscriber service @@ -25,6 +28,7 @@ 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), } } @@ -261,3 +265,549 @@ 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 +}