mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 02:52:53 +00:00
Revert "Revert "App layer"" (#5)
This commit is contained in:
@@ -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
|
||||
}
|
||||
+20
-1
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user