mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-26 22:52:16 +00:00
Revert "App layer"
This commit is contained in:
@@ -1,259 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
)
|
||||
|
||||
// ChaosService implements chaos engineering experiments
|
||||
type ChaosService struct {
|
||||
db *database.Database
|
||||
active map[string]*Experiment
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Experiment struct {
|
||||
ID string
|
||||
Name string
|
||||
Target string
|
||||
Type ExperimentType
|
||||
Config ExperimentConfig
|
||||
Status ExperimentStatus
|
||||
Started time.Time
|
||||
Finished *time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
type ExperimentType string
|
||||
|
||||
const (
|
||||
ExperimentTypeLatency ExperimentType = "latency"
|
||||
ExperimentTypeKill ExperimentType = "kill"
|
||||
ExperimentTypeCrash ExperimentType = "crash"
|
||||
ExperimentTypePartition ExperimentType = "partition"
|
||||
ExperimentTypeFlush ExperimentType = "flush"
|
||||
ExperimentTypeLoad ExperimentType = "load"
|
||||
)
|
||||
|
||||
type ExperimentStatus string
|
||||
|
||||
const (
|
||||
StatusIdle ExperimentStatus = "idle"
|
||||
StatusRunning ExperimentStatus = "running"
|
||||
StatusCompleted ExperimentStatus = "completed"
|
||||
StatusFailed ExperimentStatus = "failed"
|
||||
)
|
||||
|
||||
type ExperimentConfig struct {
|
||||
Duration time.Duration
|
||||
Probability float64
|
||||
Amount int
|
||||
Target string
|
||||
}
|
||||
|
||||
type ChaosMiddleware struct {
|
||||
service *ChaosService
|
||||
}
|
||||
|
||||
// NewChaosService creates a new chaos engineering service
|
||||
func NewChaosService(db *database.Database) *ChaosService {
|
||||
return &ChaosService{
|
||||
db: db,
|
||||
active: make(map[string]*Experiment),
|
||||
}
|
||||
}
|
||||
|
||||
// RunExperiment starts a chaos experiment
|
||||
func (cs *ChaosService) RunExperiment(ctx context.Context, expType ExperimentType, config ExperimentConfig) (*Experiment, error) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
id := fmt.Sprintf("exp_%d", time.Now().Unix())
|
||||
exp := &Experiment{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("%s Experiment", expType),
|
||||
Target: config.Target,
|
||||
Type: expType,
|
||||
Config: config,
|
||||
Status: StatusRunning,
|
||||
Started: time.Now(),
|
||||
}
|
||||
|
||||
cs.active[id] = exp
|
||||
|
||||
// Run experiment in background
|
||||
go cs.executeExperiment(ctx, exp)
|
||||
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
func (cs *ChaosService) executeExperiment(ctx context.Context, exp *Experiment) {
|
||||
defer func() {
|
||||
cs.mu.Lock()
|
||||
finished := time.Now()
|
||||
exp.Finished = &finished
|
||||
if exp.Status == StatusRunning {
|
||||
exp.Status = StatusCompleted
|
||||
}
|
||||
delete(cs.active, exp.ID)
|
||||
cs.mu.Unlock()
|
||||
}()
|
||||
|
||||
switch exp.Type {
|
||||
case ExperimentTypeLatency:
|
||||
cs.injectLatency(ctx, exp)
|
||||
case ExperimentTypeKill:
|
||||
cs.killConnections(ctx, exp)
|
||||
case ExperimentTypeCrash:
|
||||
cs.crashService(ctx, exp)
|
||||
case ExperimentTypePartition:
|
||||
cs.networkPartition(ctx, exp)
|
||||
case ExperimentTypeFlush:
|
||||
cs.flushCache(ctx, exp)
|
||||
case ExperimentTypeLoad:
|
||||
cs.simulateLoad(ctx, exp)
|
||||
default:
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusFailed
|
||||
exp.Error = "unknown experiment type"
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *ChaosService) injectLatency(_ context.Context, exp *Experiment) {
|
||||
// Simulate latency injection
|
||||
delay := time.Duration(rand.Intn(1000)+100) * time.Millisecond
|
||||
time.Sleep(delay)
|
||||
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusCompleted
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ChaosService) killConnections(_ context.Context, exp *Experiment) {
|
||||
// Simulate database connection killing
|
||||
time.Sleep(exp.Config.Duration)
|
||||
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusCompleted
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ChaosService) crashService(_ context.Context, exp *Experiment) {
|
||||
// Simulate service crash
|
||||
time.Sleep(exp.Config.Duration)
|
||||
|
||||
cs.mu.Lock()
|
||||
if rand.Float64() > 0.2 {
|
||||
exp.Status = StatusCompleted
|
||||
} else {
|
||||
exp.Status = StatusFailed
|
||||
exp.Error = "service crashed irrecoverably"
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ChaosService) networkPartition(_ context.Context, exp *Experiment) {
|
||||
// Simulate network partition
|
||||
time.Sleep(exp.Config.Duration)
|
||||
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusCompleted
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ChaosService) flushCache(_ context.Context, exp *Experiment) {
|
||||
// Simulate cache flush
|
||||
time.Sleep(exp.Config.Duration)
|
||||
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusCompleted
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *ChaosService) simulateLoad(ctx context.Context, exp *Experiment) {
|
||||
// Simulate high load
|
||||
start := time.Now()
|
||||
for time.Since(start) < exp.Config.Duration {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// Simulate work
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
}
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
exp.Status = StatusCompleted
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetActiveExperiments returns all currently running experiments
|
||||
func (cs *ChaosService) GetActiveExperiments() []*Experiment {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
experiments := make([]*Experiment, 0, len(cs.active))
|
||||
for _, exp := range cs.active {
|
||||
experiments = append(experiments, exp)
|
||||
}
|
||||
return experiments
|
||||
}
|
||||
|
||||
// GetExperimentStatus returns the status of a specific experiment
|
||||
func (cs *ChaosService) GetExperimentStatus(id string) (*Experiment, bool) {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
exp, exists := cs.active[id]
|
||||
return exp, exists
|
||||
}
|
||||
|
||||
// Middleware function to inject chaos into HTTP requests
|
||||
func (cm *ChaosMiddleware) InjectChaos(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cm.service.mu.RLock()
|
||||
for _, exp := range cm.service.active {
|
||||
if exp.Type == ExperimentTypeLatency && exp.Status == StatusRunning {
|
||||
// Inject random latency
|
||||
if rand.Float64() < exp.Config.Probability {
|
||||
delay := time.Duration(rand.Intn(int(exp.Config.Duration.Milliseconds()))) * time.Millisecond
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
cm.service.mu.RUnlock()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetExperimentHistory returns a summary of past experiments (placeholder)
|
||||
func (cs *ChaosService) GetExperimentHistory() ([]*Experiment, error) {
|
||||
// In a real implementation, this would query the database for experiment history
|
||||
return []*Experiment{}, nil
|
||||
}
|
||||
|
||||
// StopExperiment stops a running experiment
|
||||
func (cs *ChaosService) StopExperiment(id string) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if exp, exists := cs.active[id]; exists {
|
||||
if exp.Status == StatusRunning {
|
||||
exp.Status = StatusCompleted
|
||||
finished := time.Now()
|
||||
exp.Finished = &finished
|
||||
delete(cs.active, id)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("experiment %s not found or not running", id)
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// ChargingService handles charging and billing operations
|
||||
type ChargingService struct {
|
||||
db *database.Database
|
||||
engine *ChargingEngineClient
|
||||
}
|
||||
|
||||
// NewChargingService creates a new charging service
|
||||
func NewChargingService(db *database.Database, cfg *config.Config) *ChargingService {
|
||||
return &ChargingService{
|
||||
db: db,
|
||||
engine: NewChargingEngineClient(&cfg.ChargingEngine),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSystemStats gets system statistics
|
||||
func (cs *ChargingService) GetSystemStats(ctx context.Context) (*models.SystemStats, error) {
|
||||
var stats models.SystemStats
|
||||
|
||||
// Count active sessions
|
||||
var activeSessions int64
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.Session{}).
|
||||
Where("status IN ?", []string{"active", "connected", "established"}).
|
||||
Count(&activeSessions).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count active sessions: %w", err)
|
||||
}
|
||||
stats.ActiveSessions = int(activeSessions)
|
||||
|
||||
// Count total accounts
|
||||
var totalAccounts int64
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.SubscriberAccount{}).
|
||||
Count(&totalAccounts).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count total accounts: %w", err)
|
||||
}
|
||||
stats.TotalAccounts = int(totalAccounts)
|
||||
|
||||
// Count blocked users
|
||||
var blockedUsers int64
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.Subscriber{}).
|
||||
Where("status IN ?", []string{"suspended", "deactivated", "blocked"}).
|
||||
Count(&blockedUsers).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count blocked users: %w", err)
|
||||
}
|
||||
stats.BlockedUsers = int(blockedUsers)
|
||||
|
||||
// Count low balance alerts
|
||||
var lowBalanceAlerts int64
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.Alert{}).
|
||||
Where("type = ? AND resolved = ?", models.AlertTypeLowBalance, false).
|
||||
Count(&lowBalanceAlerts).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count low balance alerts: %w", err)
|
||||
}
|
||||
stats.LowBalanceAlerts = int(lowBalanceAlerts)
|
||||
|
||||
// Calculate uptime (simplified - in real implementation would track service start time)
|
||||
stats.Uptime = time.Since(time.Now().AddDate(0, 0, -7)).Seconds() // Assume 7 days uptime
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetHealthStatus gets health status
|
||||
func (cs *ChargingService) GetHealthStatus(ctx context.Context) (*models.HealthStatus, error) {
|
||||
status := &models.HealthStatus{
|
||||
RedisConnected: true, // In real implementation, would check Redis connection
|
||||
ActiveSync: true, // In real implementation, would check sync status
|
||||
LastSync: time.Now(),
|
||||
MemoryUsage: 50000000, // In real implementation, would get actual memory usage
|
||||
}
|
||||
|
||||
// Check database connectivity
|
||||
if err := cs.db.DB.WithContext(ctx).Exec("SELECT 1").Error; err != nil {
|
||||
status.ActiveSync = false
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GetUsageStats gets usage statistics for a subscriber
|
||||
func (cs *ChargingService) GetUsageStats(ctx context.Context, imsi string, period string) (*UsageStats, error) {
|
||||
var dataUsage, voiceUsage, smsUsage, totalCost float64
|
||||
|
||||
// Calculate date range based on period
|
||||
var startTime time.Time
|
||||
switch period {
|
||||
case "daily":
|
||||
startTime = time.Now().AddDate(0, 0, -1)
|
||||
case "weekly":
|
||||
startTime = time.Now().AddDate(0, 0, -7)
|
||||
case "monthly":
|
||||
startTime = time.Now().AddDate(0, -1, 0)
|
||||
default:
|
||||
startTime = time.Now().AddDate(0, -1, 0) // Default to monthly
|
||||
}
|
||||
|
||||
// Get usage statistics from database
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}).
|
||||
Where("imsi = ? AND created_at >= ?", imsi, startTime).
|
||||
Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost",
|
||||
models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS).
|
||||
Row().Scan(&dataUsage, &voiceUsage, &smsUsage, &totalCost); err != nil {
|
||||
return nil, fmt.Errorf("failed to get usage stats: %w", err)
|
||||
}
|
||||
|
||||
// Calculate trend (compare with previous period)
|
||||
var prevDataUsage, prevVoiceUsage, prevSmsUsage float64
|
||||
prevStartTime := startTime.AddDate(0, 0, -int(time.Since(startTime).Hours()/24))
|
||||
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}).
|
||||
Where("imsi = ? AND created_at >= ? AND created_at < ?", imsi, prevStartTime, startTime).
|
||||
Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage",
|
||||
models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS).
|
||||
Row().Scan(&prevDataUsage, &prevVoiceUsage, &prevSmsUsage); err != nil {
|
||||
// If no previous data, set trend to zero
|
||||
prevDataUsage, prevVoiceUsage, prevSmsUsage = 0, 0, 0
|
||||
}
|
||||
|
||||
// Calculate trend percentage
|
||||
totalCurrentUsage := dataUsage + voiceUsage + smsUsage
|
||||
totalPrevUsage := prevDataUsage + prevVoiceUsage + prevSmsUsage
|
||||
var trendPercentage float64
|
||||
if totalPrevUsage > 0 {
|
||||
trendPercentage = ((totalCurrentUsage - totalPrevUsage) / totalPrevUsage) * 100
|
||||
}
|
||||
|
||||
direction := "STABLE"
|
||||
if trendPercentage > 5 {
|
||||
direction = "UP"
|
||||
} else if trendPercentage < -5 {
|
||||
direction = "DOWN"
|
||||
}
|
||||
|
||||
stats := &UsageStats{
|
||||
DataUsage: dataUsage,
|
||||
VoiceUsage: voiceUsage,
|
||||
SmsUsage: smsUsage,
|
||||
Cost: totalCost,
|
||||
Period: period,
|
||||
Trend: &UsageTrend{
|
||||
Direction: direction,
|
||||
Percentage: trendPercentage,
|
||||
PeriodOverPeriod: trendPercentage,
|
||||
},
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetRealTimeUsage gets real-time usage for a subscriber
|
||||
func (cs *ChargingService) GetRealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) {
|
||||
// Get current active session
|
||||
var session models.Session
|
||||
var currentSession *CurrentSession
|
||||
|
||||
if err := cs.db.DB.WithContext(ctx).
|
||||
Where("imsi = ? AND status IN ?", imsi, []string{"active", "connected", "established"}).
|
||||
First(&session).Error; err == nil {
|
||||
// Get usage for current session
|
||||
var dataUsed, voiceUsed, smsUsed, sessionCost float64
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}).
|
||||
Where("session_id = ?", session.SessionID).
|
||||
Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost",
|
||||
models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS).
|
||||
Row().Scan(&dataUsed, &voiceUsed, &smsUsed, &sessionCost); err != nil {
|
||||
dataUsed, voiceUsed, smsUsed, sessionCost = 0, 0, 0, 0
|
||||
}
|
||||
|
||||
currentSession = &CurrentSession{
|
||||
SessionID: session.SessionID,
|
||||
StartTime: session.CreatedAt,
|
||||
DataUsed: dataUsed,
|
||||
VoiceUsed: voiceUsed,
|
||||
SmsUsed: smsUsed,
|
||||
Cost: sessionCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Get today's usage
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
var dataUsed, voiceUsed, smsUsed, todayCost float64
|
||||
|
||||
if err := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{}).
|
||||
Where("imsi = ? AND created_at >= ?", imsi, today).
|
||||
Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_usage, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_usage, SUM(cost) as total_cost",
|
||||
models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS).
|
||||
Row().Scan(&dataUsed, &voiceUsed, &smsUsed, &todayCost); err != nil {
|
||||
dataUsed, voiceUsed, smsUsed, todayCost = 0, 0, 0, 0
|
||||
}
|
||||
|
||||
usage := &RealTimeUsage{
|
||||
CurrentSession: currentSession,
|
||||
TodayUsage: &TodayUsage{
|
||||
DataUsed: dataUsed,
|
||||
VoiceUsed: voiceUsed,
|
||||
SmsUsed: smsUsed,
|
||||
Cost: todayCost,
|
||||
},
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// ListUsageEvents lists usage events
|
||||
func (cs *ChargingService) ListUsageEvents(ctx context.Context, limit int, offset int, where string) ([]*models.UsageEvent, int64, error) {
|
||||
query := cs.db.DB.WithContext(ctx).Model(&models.UsageEvent{})
|
||||
|
||||
// Apply where clause if provided
|
||||
if where != "" {
|
||||
query = query.Where(where)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count usage events: %w", err)
|
||||
}
|
||||
|
||||
// Get usage events with pagination
|
||||
var events []*models.UsageEvent
|
||||
if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("timestamp DESC").Find(&events).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list usage events: %w", err)
|
||||
}
|
||||
|
||||
return events, total, nil
|
||||
}
|
||||
|
||||
// SearchUsageEvents searches usage events
|
||||
func (cs *ChargingService) SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) {
|
||||
var events []*models.UsageEvent
|
||||
|
||||
// Search usage events by IMSI, session ID, or usage type
|
||||
searchPattern := "%" + query + "%"
|
||||
if err := cs.db.DB.WithContext(ctx).
|
||||
Where("imsi ILIKE ? OR session_id ILIKE ? OR usage_type ILIKE ?",
|
||||
searchPattern, searchPattern, searchPattern).
|
||||
Preload("Subscriber").
|
||||
Limit(limit).
|
||||
Order("timestamp DESC").
|
||||
Find(&events).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to search usage events: %w", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// SubscribeToUsageUpdates subscribes to usage updates
|
||||
func (cs *ChargingService) SubscribeToUsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) {
|
||||
ch := make(chan *models.UsageEvent, 20) // Buffered channel for high-frequency updates
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
// Poll for new usage events every 1 second (high frequency for real-time usage)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastEventID uint = 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Query for new usage events since last check
|
||||
var events []models.UsageEvent
|
||||
if err := cs.db.DB.WithContext(ctx).
|
||||
Where("imsi = ? AND id > ?", imsi, lastEventID).
|
||||
Order("id ASC").
|
||||
Limit(20).
|
||||
Find(&events).Error; err != nil {
|
||||
// Log error but continue polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Send new events to channel
|
||||
for _, event := range events {
|
||||
select {
|
||||
case ch <- &event:
|
||||
lastEventID = event.ID
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// SubscribeToSystemStatsUpdates subscribes to system stats updates
|
||||
func (cs *ChargingService) SubscribeToSystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) {
|
||||
ch := make(chan *models.SystemStats, 5) // Buffered channel to prevent blocking
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
// Poll for system stats updates every 5 seconds (lower frequency for system stats)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastStats models.SystemStats
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Get current system stats
|
||||
stats, err := cs.GetSystemStats(ctx)
|
||||
if err != nil {
|
||||
// Log error but continue polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Only send if stats have changed significantly
|
||||
if lastStats.ActiveSessions != stats.ActiveSessions ||
|
||||
lastStats.TotalAccounts != stats.TotalAccounts ||
|
||||
lastStats.BlockedUsers != stats.BlockedUsers ||
|
||||
lastStats.LowBalanceAlerts != stats.LowBalanceAlerts {
|
||||
|
||||
select {
|
||||
case ch <- stats:
|
||||
lastStats = *stats
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TriggerMaintenance triggers system maintenance
|
||||
func (cs *ChargingService) TriggerMaintenance(ctx context.Context) (bool, error) {
|
||||
// In a real implementation, this would trigger maintenance tasks like:
|
||||
// - Database cleanup
|
||||
// - Cache refresh
|
||||
// - Log rotation
|
||||
// - Health checks
|
||||
|
||||
// For now, simulate maintenance by updating a maintenance timestamp
|
||||
if err := cs.db.DB.WithContext(ctx).Exec("UPDATE system_config SET last_maintenance = ? WHERE id = 1", time.Now()).Error; err != nil {
|
||||
// If table doesn't exist, just return success
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Helper types for charging service
|
||||
type UsageStats struct {
|
||||
DataUsage float64 `json:"data_usage"`
|
||||
VoiceUsage float64 `json:"voice_usage"`
|
||||
SmsUsage float64 `json:"sms_usage"`
|
||||
Cost float64 `json:"cost"`
|
||||
Period string `json:"period"`
|
||||
Trend *UsageTrend `json:"trend"`
|
||||
}
|
||||
|
||||
type UsageTrend struct {
|
||||
Direction string `json:"direction"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
PeriodOverPeriod float64 `json:"period_over_period"`
|
||||
}
|
||||
|
||||
type RealTimeUsage struct {
|
||||
CurrentSession *CurrentSession `json:"current_session"`
|
||||
TodayUsage *TodayUsage `json:"today_usage"`
|
||||
}
|
||||
|
||||
type CurrentSession struct {
|
||||
SessionID string `json:"session_id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
DataUsed float64 `json:"data_used"`
|
||||
VoiceUsed float64 `json:"voice_used"`
|
||||
SmsUsed float64 `json:"sms_used"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
type TodayUsage struct {
|
||||
DataUsed float64 `json:"data_used"`
|
||||
VoiceUsed float64 `json:"voice_used"`
|
||||
SmsUsed float64 `json:"sms_used"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
)
|
||||
|
||||
// ChargingEngineClient is an HTTP client for the Rust charging engine
|
||||
type ChargingEngineClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewChargingEngineClient creates a new client for the Rust charging engine
|
||||
func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineClient {
|
||||
return &ChargingEngineClient{
|
||||
baseURL: cfg.BaseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CreditCheckRequest struct {
|
||||
BytesRequested uint64 `json:"bytes_requested"`
|
||||
}
|
||||
|
||||
type CreditCheckResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
RemainingBytes int64 `json:"remaining_bytes"`
|
||||
}
|
||||
|
||||
type DeductRequest struct {
|
||||
BytesUsed uint64 `json:"bytes_used"`
|
||||
}
|
||||
|
||||
type AddCreditRequest struct {
|
||||
BytesToAdd uint64 `json:"bytes_to_add"`
|
||||
}
|
||||
|
||||
type BalanceResponse struct {
|
||||
IP string `json:"ip"`
|
||||
BalanceBytes int64 `json:"balance_bytes"`
|
||||
}
|
||||
|
||||
type EngineHealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CheckCredit checks if a subscriber (by IP) has enough credit
|
||||
func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytesRequested uint64) (*CreditCheckResponse, error) {
|
||||
body, err := json.Marshal(CreditCheckRequest{BytesRequested: bytesRequested})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal credit check request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("charging engine request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result CreditCheckResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeductCredit deducts bytes from a subscriber's credit balance
|
||||
func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, bytesUsed uint64) error {
|
||||
body, err := json.Marshal(DeductRequest{BytesUsed: bytesUsed})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal deduct request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("charging engine request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("charging engine returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCredit adds bytes to a subscriber's credit balance
|
||||
func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesToAdd uint64) error {
|
||||
body, err := json.Marshal(AddCreditRequest{BytesToAdd: bytesToAdd})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal add credit request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("charging engine request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("charging engine returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBalance gets the current credit balance for a subscriber
|
||||
func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) {
|
||||
url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("charging engine request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result BalanceResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// HealthCheck checks if the Rust charging engine is healthy
|
||||
func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) {
|
||||
url := fmt.Sprintf("%s/health", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("charging engine health check failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("charging engine returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result EngineHealthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
)
|
||||
|
||||
func newTestServer(handler http.HandlerFunc) (*httptest.Server, *ChargingEngineClient) {
|
||||
srv := httptest.NewServer(handler)
|
||||
client := NewChargingEngineClient(&config.ChargingEngineConfig{
|
||||
BaseURL: srv.URL,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
return srv, client
|
||||
}
|
||||
|
||||
func TestCheckCredit_Allowed(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/v1/credit/10.0.0.1/check" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var req CreditCheckRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.BytesRequested != 1000 {
|
||||
t.Errorf("expected 1000 bytes, got %d", req.BytesRequested)
|
||||
}
|
||||
json.NewEncoder(w).Encode(CreditCheckResponse{Allowed: true, RemainingBytes: 5000})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.Allowed {
|
||||
t.Error("expected allowed=true")
|
||||
}
|
||||
if resp.RemainingBytes != 5000 {
|
||||
t.Errorf("expected remaining=5000, got %d", resp.RemainingBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCredit_ServerError(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
_, err := client.CheckCredit(context.Background(), "10.0.0.1", 1000)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeductCredit(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/credit/10.0.0.2/deduct" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var req DeductRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.BytesUsed != 500 {
|
||||
t.Errorf("expected 500, got %d", req.BytesUsed)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
err := client.DeductCredit(context.Background(), "10.0.0.2", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCredit(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/credit/10.0.0.3/add" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var req AddCreditRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.BytesToAdd != 2000 {
|
||||
t.Errorf("expected 2000, got %d", req.BytesToAdd)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
err := client.AddCredit(context.Background(), "10.0.0.3", 2000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBalance(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
json.NewEncoder(w).Encode(BalanceResponse{IP: "10.0.0.4", BalanceBytes: 9999})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := client.GetBalance(context.Background(), "10.0.0.4")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.BalanceBytes != 9999 {
|
||||
t.Errorf("expected 9999, got %d", resp.BalanceBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/health" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(EngineHealthResponse{Status: "ok", Timestamp: "2026-04-14T12:00:00Z"})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := client.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
t.Errorf("expected status=ok, got %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
srv, client := newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.GetBalance(ctx, "10.0.0.1")
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetSystemStats(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
stats, err := cs.GetSystemStats(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats == nil {
|
||||
t.Fatal("expected non-nil stats")
|
||||
}
|
||||
if stats.ActiveSessions != 10 {
|
||||
t.Errorf("expected 10 active sessions, got %d", stats.ActiveSessions)
|
||||
}
|
||||
if stats.TotalAccounts != 100 {
|
||||
t.Errorf("expected 100 total accounts, got %d", stats.TotalAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHealthStatus(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
health, err := cs.GetHealthStatus(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if health == nil {
|
||||
t.Fatal("expected non-nil health")
|
||||
}
|
||||
if !health.RedisConnected {
|
||||
t.Error("expected redis_connected=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsageStats(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
stats, err := cs.GetUsageStats(context.Background(), "208930000000001", "DAILY")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if stats.Period != "DAILY" {
|
||||
t.Errorf("expected period=DAILY, got %s", stats.Period)
|
||||
}
|
||||
if stats.Trend == nil {
|
||||
t.Fatal("expected non-nil trend")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealTimeUsage(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
usage, err := cs.GetRealTimeUsage(context.Background(), "208930000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if usage.CurrentSession == nil {
|
||||
t.Fatal("expected non-nil current session")
|
||||
}
|
||||
if usage.TodayUsage == nil {
|
||||
t.Fatal("expected non-nil today usage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsageEvents(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
events, total, err := cs.ListUsageEvents(context.Background(), 10, 0, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if total < 1 {
|
||||
t.Error("expected at least 1 event")
|
||||
}
|
||||
if len(events) < 1 {
|
||||
t.Error("expected at least 1 event in slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchUsageEvents(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
events, err := cs.SearchUsageEvents(context.Background(), "data", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(events) < 1 {
|
||||
t.Error("expected at least 1 event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerMaintenance(t *testing.T) {
|
||||
cs := &ChargingService{db: nil}
|
||||
ok, err := cs.TriggerMaintenance(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected maintenance trigger to succeed")
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
)
|
||||
|
||||
// InvoiceService handles invoice generation and management
|
||||
type InvoiceService struct {
|
||||
db *database.Database
|
||||
}
|
||||
|
||||
// NewInvoiceService creates a new invoice service
|
||||
func NewInvoiceService(db *database.Database) *InvoiceService {
|
||||
return &InvoiceService{db: db}
|
||||
}
|
||||
|
||||
// GenerateMonthlyInvoice generates a monthly invoice for a subscriber
|
||||
func (s *InvoiceService) GenerateMonthlyInvoice(ctx context.Context, subscriberID uint, billingPeriod string) (*models.Invoice, error) {
|
||||
// Get subscriber
|
||||
var subscriber models.Subscriber
|
||||
if err := s.db.DB.First(&subscriber, subscriberID).Error; err != nil {
|
||||
return nil, fmt.Errorf("subscriber not found: %w", err)
|
||||
}
|
||||
|
||||
// Get usage events for the billing period
|
||||
startDate, err := parseBillingPeriod(billingPeriod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid billing period: %w", err)
|
||||
}
|
||||
endDate := startDate.AddDate(0, 1, 0).Add(-time.Second)
|
||||
|
||||
var usageEvents []models.UsageEvent
|
||||
if err := s.db.DB.Where("subscriber_id = ? AND timestamp BETWEEN ? AND ?",
|
||||
subscriberID, startDate, endDate).Find(&usageEvents).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch usage events: %w", err)
|
||||
}
|
||||
|
||||
// Create invoice with existing model structure
|
||||
invoice := &models.Invoice{
|
||||
SubscriberID: subscriberID,
|
||||
DueDate: time.Now().AddDate(0, 0, 30), // Due in 30 days
|
||||
Status: models.InvoiceStatusDraft,
|
||||
Currency: "USD",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Add plan fee
|
||||
planFee := 25.00 // Default monthly fee
|
||||
invoice.LineItems = append(invoice.LineItems, models.InvoiceLineItem{
|
||||
Description: "Monthly Plan Fee",
|
||||
Quantity: 1,
|
||||
UnitPrice: planFee,
|
||||
Amount: planFee,
|
||||
})
|
||||
|
||||
// Add usage charges
|
||||
totalUsageCost := 0.0
|
||||
for _, event := range usageEvents {
|
||||
totalUsageCost += event.Cost
|
||||
}
|
||||
|
||||
if totalUsageCost > 0 {
|
||||
invoice.LineItems = append(invoice.LineItems, models.InvoiceLineItem{
|
||||
Description: "Usage Charges",
|
||||
Quantity: 1,
|
||||
UnitPrice: totalUsageCost,
|
||||
Amount: totalUsageCost,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate total amount
|
||||
invoice.Amount = planFee + totalUsageCost
|
||||
|
||||
// Save invoice
|
||||
if err := s.db.DB.Create(invoice).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create invoice: %w", err)
|
||||
}
|
||||
|
||||
// Load subscriber relationship
|
||||
s.db.DB.Preload("Subscriber").First(invoice, invoice.ID)
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
// SendInvoice sends an invoice to the subscriber
|
||||
func (s *InvoiceService) SendInvoice(ctx context.Context, invoiceID uint) error {
|
||||
var invoice models.Invoice
|
||||
if err := s.db.DB.First(&invoice, invoiceID).Error; err != nil {
|
||||
return fmt.Errorf("invoice not found: %w", err)
|
||||
}
|
||||
|
||||
// Update status to pending
|
||||
invoice.Status = models.InvoiceStatusPending
|
||||
if err := s.db.DB.Save(&invoice).Error; err != nil {
|
||||
return fmt.Errorf("failed to update invoice status: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Send email notification
|
||||
// This would integrate with an email service
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkInvoiceAsPaid marks an invoice as paid
|
||||
func (s *InvoiceService) MarkInvoiceAsPaid(ctx context.Context, invoiceID uint, paymentMethod string) error {
|
||||
var invoice models.Invoice
|
||||
if err := s.db.DB.First(&invoice, invoiceID).Error; err != nil {
|
||||
return fmt.Errorf("invoice not found: %w", err)
|
||||
}
|
||||
|
||||
invoice.Status = models.InvoiceStatusPaid
|
||||
// Update payment date if needed
|
||||
|
||||
return s.db.DB.Save(&invoice).Error
|
||||
}
|
||||
|
||||
// GetOverdueInvoices returns all overdue invoices
|
||||
func (s *InvoiceService) GetOverdueInvoices(ctx context.Context) ([]models.Invoice, error) {
|
||||
var invoices []models.Invoice
|
||||
err := s.db.DB.Where("status = ? AND due_date < ?",
|
||||
models.InvoiceStatusPending, time.Now()).Find(&invoices).Error
|
||||
return invoices, err
|
||||
}
|
||||
|
||||
// GetSubscriberInvoices returns all invoices for a subscriber
|
||||
func (s *InvoiceService) GetSubscriberInvoices(ctx context.Context, subscriberID uint) ([]models.Invoice, error) {
|
||||
var invoices []models.Invoice
|
||||
err := s.db.DB.Where("subscriber_id = ?", subscriberID).
|
||||
Preload("LineItems").
|
||||
Order("created_at DESC").
|
||||
Find(&invoices).Error
|
||||
return invoices, err
|
||||
}
|
||||
|
||||
// parseBillingPeriod parses a billing period string like "2024-01" into a time.Time
|
||||
func parseBillingPeriod(period string) (time.Time, error) {
|
||||
if len(period) != 7 || period[4] != '-' {
|
||||
return time.Time{}, fmt.Errorf("invalid billing period format, expected YYYY-MM")
|
||||
}
|
||||
|
||||
year := period[:4]
|
||||
month := period[5:]
|
||||
|
||||
layout := "2006-01"
|
||||
return time.Parse(layout, fmt.Sprintf("%s-%s", year, month))
|
||||
}
|
||||
|
||||
// InvoiceGenerator handles automated invoice generation
|
||||
type InvoiceGenerator struct {
|
||||
invoiceService *InvoiceService
|
||||
}
|
||||
|
||||
// NewInvoiceGenerator creates a new invoice generator
|
||||
func NewInvoiceGenerator(invoiceService *InvoiceService) *InvoiceGenerator {
|
||||
return &InvoiceGenerator{invoiceService: invoiceService}
|
||||
}
|
||||
|
||||
// GenerateAllMonthlyInvoices generates monthly invoices for all active subscribers
|
||||
func (g *InvoiceGenerator) GenerateAllMonthlyInvoices(ctx context.Context, billingPeriod string) error {
|
||||
// Get all active subscribers
|
||||
var subscribers []models.Subscriber
|
||||
if err := g.invoiceService.db.DB.Where("status = ?", "active").Find(&subscribers).Error; err != nil {
|
||||
return fmt.Errorf("failed to fetch subscribers: %w", err)
|
||||
}
|
||||
|
||||
var errors []error
|
||||
for _, subscriber := range subscribers {
|
||||
// Check if invoice already exists for this period by checking creation date
|
||||
startDate, _ := parseBillingPeriod(billingPeriod)
|
||||
endDate := startDate.AddDate(0, 1, 0).Add(-time.Second)
|
||||
|
||||
var existingInvoice models.Invoice
|
||||
err := g.invoiceService.db.DB.Where("subscriber_id = ? AND created_at BETWEEN ? AND ?",
|
||||
subscriber.ID, startDate, endDate).First(&existingInvoice).Error
|
||||
|
||||
if err == nil {
|
||||
// Invoice already exists, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate new invoice
|
||||
_, err = g.invoiceService.GenerateMonthlyInvoice(ctx, subscriber.ID, billingPeriod)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Errorf("failed to generate invoice for subscriber %d: %w", subscriber.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("encountered %d errors while generating invoices", len(errors))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,13 +3,11 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
|
||||
"github.com/nutcas3/telecom-platform/apps/api-server/internal/payment/gateways"
|
||||
)
|
||||
|
||||
// SubscriberService handles subscriber management operations
|
||||
@@ -18,7 +16,6 @@ type SubscriberService struct {
|
||||
config *config.Config
|
||||
amfClient *AMFClient
|
||||
es2Service *ES2Service
|
||||
stripeGW *gateways.StripeGateway
|
||||
}
|
||||
|
||||
// NewSubscriberService creates a new subscriber service
|
||||
@@ -28,7 +25,6 @@ func NewSubscriberService(db *database.Database, cfg *config.Config) *Subscriber
|
||||
config: cfg,
|
||||
amfClient: NewAMFClient("http://localhost:8081"), // Default AMF URL
|
||||
es2Service: NewES2Service(&cfg.ES2),
|
||||
stripeGW: gateways.NewStripeGateway(cfg.Payment.StripeAPIKey, cfg.Payment.StripeWebhookSecret),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,549 +261,3 @@ type ListSubscribersResponse struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GetAccount gets subscriber account information
|
||||
func (s *SubscriberService) GetAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) {
|
||||
subscriber, err := s.db.GetSubscriberByIMSI(ctx, models.IMSI(imsi))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the subscriber's service plan for limits and get actual usage from database
|
||||
var dataUsed, voiceUsed, smsUsed float64
|
||||
|
||||
// Get actual usage records from database
|
||||
if err := s.db.DB.WithContext(ctx).Model(&models.UsageEvent{}).
|
||||
Where("imsi = ? AND created_at >= ?", subscriber.IMSI, time.Now().AddDate(0, -1, 0)).
|
||||
Select("SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as data_used, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as voice_used, SUM(CASE WHEN usage_type = ? THEN volume ELSE 0 END) as sms_used",
|
||||
models.UsageTypeData, models.UsageTypeVoice, models.UsageTypeSMS).
|
||||
Row().Scan(&dataUsed, &voiceUsed, &smsUsed); err != nil {
|
||||
// If no usage records found, start with zero
|
||||
dataUsed, voiceUsed, smsUsed = 0, 0, 0
|
||||
}
|
||||
|
||||
// Get actual balance from billing system or database
|
||||
var balance float64
|
||||
if err := s.db.DB.WithContext(ctx).Model(&models.SubscriberAccount{}).
|
||||
Where("imsi = ?", subscriber.IMSI).
|
||||
Select("balance").Scan(&balance).Error; err != nil {
|
||||
// If no account exists, start with default balance
|
||||
balance = 0.0
|
||||
}
|
||||
|
||||
account := &models.SubscriberAccount{
|
||||
IMSI: string(subscriber.IMSI),
|
||||
Balance: balance,
|
||||
DataLimit: float64(subscriber.Plan.DataLimit),
|
||||
DataUsed: dataUsed,
|
||||
VoiceLimit: float64(subscriber.Plan.VoiceLimit),
|
||||
VoiceUsed: voiceUsed,
|
||||
SMSLimit: float64(subscriber.Plan.SMSLimit),
|
||||
SMSUsed: smsUsed,
|
||||
Status: string(subscriber.Status),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ListInvoices lists invoices for a subscriber
|
||||
func (s *SubscriberService) ListInvoices(ctx context.Context, limit int, offset int, imsi *string, status *models.InvoiceStatus) ([]*models.Invoice, int64, error) {
|
||||
query := s.db.DB.WithContext(ctx).Model(&models.Invoice{})
|
||||
|
||||
// Apply filters
|
||||
if imsi != nil {
|
||||
// Get subscriber ID from IMSI
|
||||
var subscriber models.Subscriber
|
||||
if err := s.db.DB.WithContext(ctx).Where("imsi = ?", *imsi).First(&subscriber).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("subscriber not found: %w", err)
|
||||
}
|
||||
query = query.Where("subscriber_id = ?", subscriber.ID)
|
||||
}
|
||||
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get invoices with pagination
|
||||
var invoices []*models.Invoice
|
||||
if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("created_at DESC").Find(&invoices).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return invoices, total, nil
|
||||
}
|
||||
|
||||
// GetInvoice gets a specific invoice
|
||||
func (s *SubscriberService) GetInvoice(ctx context.Context, id string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
|
||||
// Parse ID to integer
|
||||
invoiceID, err := strconv.ParseUint(id, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid invoice ID: %w", err)
|
||||
}
|
||||
|
||||
// Get invoice from database with line items and subscriber
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Preload("LineItems").
|
||||
Preload("Subscriber").
|
||||
First(&invoice, invoiceID).Error; err != nil {
|
||||
return nil, fmt.Errorf("invoice not found: %w", err)
|
||||
}
|
||||
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
// ListRatingPlans lists available rating plans
|
||||
func (s *SubscriberService) ListRatingPlans(ctx context.Context) ([]*models.RatingPlan, error) {
|
||||
var plans []*models.RatingPlan
|
||||
|
||||
// Get all active rating plans from database
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("is_active = ?", true).
|
||||
Order("monthly_fee ASC").
|
||||
Find(&plans).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to list rating plans: %w", err)
|
||||
}
|
||||
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// GetRatingPlan gets a specific rating plan
|
||||
func (s *SubscriberService) GetRatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) {
|
||||
var plan models.RatingPlan
|
||||
|
||||
// Get rating plan from database
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("plan_id = ? AND is_active = ?", planId, true).
|
||||
First(&plan).Error; err != nil {
|
||||
return nil, fmt.Errorf("rating plan not found: %w", err)
|
||||
}
|
||||
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// ListAlerts lists alerts
|
||||
func (s *SubscriberService) ListAlerts(ctx context.Context, limit int, offset int, subscriberId *int, severity *models.AlertSeverity, resolved *bool) ([]*models.Alert, int64, error) {
|
||||
query := s.db.DB.WithContext(ctx).Model(&models.Alert{})
|
||||
|
||||
// Apply filters
|
||||
if subscriberId != nil {
|
||||
query = query.Where("subscriber_id = ?", *subscriberId)
|
||||
}
|
||||
|
||||
if severity != nil {
|
||||
query = query.Where("severity = ?", *severity)
|
||||
}
|
||||
|
||||
if resolved != nil {
|
||||
query = query.Where("resolved = ?", *resolved)
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get alerts with pagination
|
||||
var alerts []*models.Alert
|
||||
if err := query.Preload("Subscriber").Limit(limit).Offset(offset).Order("timestamp DESC").Find(&alerts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return alerts, total, nil
|
||||
}
|
||||
|
||||
// SearchSubscribers searches subscribers
|
||||
func (s *SubscriberService) SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) {
|
||||
var subscribers []*models.Subscriber
|
||||
|
||||
// Search subscribers by name, MSISDN, or IMSI
|
||||
searchPattern := "%" + query + "%"
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("first_name ILIKE ? OR last_name ILIKE ? OR msisdn ILIKE ? OR imsi ILIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern).
|
||||
Limit(limit).
|
||||
Find(&subscribers).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to search subscribers: %w", err)
|
||||
}
|
||||
|
||||
return subscribers, nil
|
||||
}
|
||||
|
||||
// ResolveAlert resolves an alert
|
||||
func (s *SubscriberService) ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) {
|
||||
var alert models.Alert
|
||||
|
||||
// Parse ID to integer
|
||||
alertID, err := strconv.ParseUint(alertId, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid alert ID: %w", err)
|
||||
}
|
||||
|
||||
// Update alert as resolved
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Model(&alert).
|
||||
Where("id = ?", alertID).
|
||||
Update("resolved", true).
|
||||
Update("resolved_at", time.Now()).
|
||||
Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve alert: %w", err)
|
||||
}
|
||||
|
||||
// Get updated alert
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Preload("Subscriber").
|
||||
First(&alert, alertID).Error; err != nil {
|
||||
return nil, fmt.Errorf("alert not found: %w", err)
|
||||
}
|
||||
|
||||
return &alert, nil
|
||||
}
|
||||
|
||||
// CreateAlert creates an alert
|
||||
func (s *SubscriberService) CreateAlert(ctx context.Context, req *models.CreateAlertRequest) (*models.Alert, error) {
|
||||
alert := &models.Alert{
|
||||
Type: req.Type,
|
||||
Severity: req.Severity,
|
||||
Message: req.Message,
|
||||
SubscriberID: req.SubscriberID,
|
||||
Timestamp: time.Now(),
|
||||
Resolved: false,
|
||||
}
|
||||
|
||||
// Create alert in database
|
||||
if err := s.db.DB.WithContext(ctx).Create(alert).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create alert: %w", err)
|
||||
}
|
||||
|
||||
// Get created alert with subscriber
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Preload("Subscriber").
|
||||
First(alert, alert.ID).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created alert: %w", err)
|
||||
}
|
||||
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
// SubscribeToSubscriberUpdates subscribes to subscriber updates
|
||||
func (s *SubscriberService) SubscribeToSubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) {
|
||||
ch := make(chan *models.Subscriber, 5) // Buffered channel to prevent blocking
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
// Poll for subscriber updates every 3 seconds
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastUpdatedAt time.Time
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Query for subscriber updates since last check
|
||||
var subscriber models.Subscriber
|
||||
query := s.db.DB.WithContext(ctx).Where("imsi = ?", subscriberId)
|
||||
if !lastUpdatedAt.IsZero() {
|
||||
query = query.Where("updated_at > ?", lastUpdatedAt)
|
||||
}
|
||||
|
||||
if err := query.First(&subscriber).Error; err != nil {
|
||||
// Subscriber not found or error, continue polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Send updated subscriber to channel
|
||||
select {
|
||||
case ch <- &subscriber:
|
||||
lastUpdatedAt = subscriber.UpdatedAt
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// SubscribeToAlertUpdates subscribes to alert updates
|
||||
func (s *SubscriberService) SubscribeToAlertUpdates(ctx context.Context, subscriberId *string) (<-chan *models.Alert, error) {
|
||||
ch := make(chan *models.Alert, 10) // Buffered channel to prevent blocking
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
// Poll for new alerts every 2 seconds
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastAlertID uint = 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Query for new alerts since last check
|
||||
var alerts []models.Alert
|
||||
query := s.db.DB.WithContext(ctx).Where("id > ?", lastAlertID)
|
||||
|
||||
// Filter by subscriber if specified
|
||||
if subscriberId != nil {
|
||||
// Convert subscriberId string to uint for database query
|
||||
if subID, err := strconv.ParseUint(*subscriberId, 10, 32); err == nil {
|
||||
query = query.Where("subscriber_id = ?", subID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := query.Order("id ASC").Limit(10).Find(&alerts).Error; err != nil {
|
||||
// Log error but continue polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Send new alerts to channel
|
||||
for _, alert := range alerts {
|
||||
select {
|
||||
case ch <- &alert:
|
||||
lastAlertID = alert.ID
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// AddPaymentMethod adds a payment method
|
||||
func (s *SubscriberService) AddPaymentMethod(ctx context.Context, subscriberId int, req *models.AddPaymentMethodRequest) (*models.PaymentMethod, error) {
|
||||
// Generate unique payment method ID
|
||||
paymentMethodID := fmt.Sprintf("pm_%d_%d", subscriberId, time.Now().Unix())
|
||||
|
||||
// Process payment token using real Stripe API to extract card details
|
||||
last4, brand, expiryMonth, expiryYear, err := s.processPaymentToken(ctx, req.Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process payment token: %w", err)
|
||||
}
|
||||
|
||||
paymentMethod := &models.PaymentMethod{
|
||||
ID: paymentMethodID,
|
||||
SubscriberID: uint(subscriberId),
|
||||
GatewayID: "default_gateway",
|
||||
Type: req.Type,
|
||||
CustomerID: fmt.Sprintf("cus_%d", subscriberId),
|
||||
Last4: last4,
|
||||
Brand: brand,
|
||||
ExpiryMonth: expiryMonth,
|
||||
ExpiryYear: expiryYear,
|
||||
IsDefault: req.IsDefault,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Create payment method in database
|
||||
if err := s.db.DB.WithContext(ctx).Create(paymentMethod).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to add payment method: %w", err)
|
||||
}
|
||||
|
||||
return paymentMethod, nil
|
||||
}
|
||||
|
||||
// processPaymentToken processes payment gateway token using real Stripe API
|
||||
func (s *SubscriberService) processPaymentToken(ctx context.Context, token string) (last4, brand string, expiryMonth, expiryYear int, err error) {
|
||||
// Call Stripe API to retrieve payment method details from token
|
||||
details, err := s.stripeGW.RetrievePaymentMethodFromToken(ctx, token)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("failed to retrieve payment method from token: %w", err)
|
||||
}
|
||||
|
||||
// Return the actual card details from Stripe
|
||||
return details.Last4, details.Brand, details.ExpiryMonth, details.ExpiryYear, nil
|
||||
}
|
||||
|
||||
// RemovePaymentMethod removes a payment method
|
||||
func (s *SubscriberService) RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) {
|
||||
// Check if payment method exists
|
||||
var paymentMethod models.PaymentMethod
|
||||
if err := s.db.DB.WithContext(ctx).First(&paymentMethod, "id = ?", paymentMethodId).Error; err != nil {
|
||||
return false, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
|
||||
// Don't allow removal if it's the only payment method and has default status
|
||||
var count int64
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Model(&models.PaymentMethod{}).
|
||||
Where("subscriber_id = ?", paymentMethod.SubscriberID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("failed to check payment methods: %w", err)
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
return false, fmt.Errorf("cannot remove the only payment method")
|
||||
}
|
||||
|
||||
// If this is the default payment method, set another one as default
|
||||
if paymentMethod.IsDefault {
|
||||
var newDefault models.PaymentMethod
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("subscriber_id = ? AND id != ?", paymentMethod.SubscriberID, paymentMethodId).
|
||||
First(&newDefault).Error; err != nil {
|
||||
return false, fmt.Errorf("failed to find alternative payment method: %w", err)
|
||||
}
|
||||
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Model(&newDefault).
|
||||
Update("is_default", true).Error; err != nil {
|
||||
return false, fmt.Errorf("failed to set new default payment method: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the payment method
|
||||
if err := s.db.DB.WithContext(ctx).Delete(&paymentMethod).Error; err != nil {
|
||||
return false, fmt.Errorf("failed to delete payment method: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetDefaultPaymentMethod sets default payment method
|
||||
func (s *SubscriberService) SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) {
|
||||
var paymentMethod models.PaymentMethod
|
||||
|
||||
// Start transaction
|
||||
tx := s.db.DB.WithContext(ctx).Begin()
|
||||
|
||||
// Unset all existing default payment methods for this subscriber
|
||||
if err := tx.Model(&models.PaymentMethod{}).
|
||||
Where("id = ?", paymentMethodId).
|
||||
First(&paymentMethod).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
|
||||
// Set all other payment methods as non-default
|
||||
if err := tx.Model(&models.PaymentMethod{}).
|
||||
Where("subscriber_id = ? AND id != ?", paymentMethod.SubscriberID, paymentMethodId).
|
||||
Update("is_default", false).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("failed to update other payment methods: %w", err)
|
||||
}
|
||||
|
||||
// Set this payment method as default
|
||||
if err := tx.Model(&paymentMethod).
|
||||
Update("is_default", true).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, fmt.Errorf("failed to set default payment method: %w", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return &paymentMethod, nil
|
||||
}
|
||||
|
||||
// TopUpBalance tops up subscriber balance
|
||||
func (s *SubscriberService) TopUpBalance(ctx context.Context, imsi string, req *models.TopUpRequest) (*models.SubscriberAccount, error) {
|
||||
var account models.SubscriberAccount
|
||||
|
||||
// Get subscriber account from database
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("imsi = ?", imsi).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, fmt.Errorf("subscriber account not found: %w", err)
|
||||
}
|
||||
|
||||
// Update balance
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Model(&account).
|
||||
Where("imsi = ?", imsi).
|
||||
Update("balance", account.Balance+req.Amount).
|
||||
Update("last_updated", time.Now()).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to top up balance: %w", err)
|
||||
}
|
||||
|
||||
// Get updated account
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Where("imsi = ?", imsi).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve updated account: %w", err)
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// DeleteSubscriber deletes a subscriber
|
||||
func (s *SubscriberService) DeleteSubscriber(ctx context.Context, subscriberId uint) (bool, error) {
|
||||
// Start transaction to delete all related data
|
||||
tx := s.db.DB.WithContext(ctx).Begin()
|
||||
|
||||
// Delete subscriber's payment methods
|
||||
if err := tx.Where("subscriber_id = ?", subscriberId).Delete(&models.PaymentMethod{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return false, fmt.Errorf("failed to delete payment methods: %w", err)
|
||||
}
|
||||
|
||||
// Delete subscriber's alerts
|
||||
if err := tx.Where("subscriber_id = ?", subscriberId).Delete(&models.Alert{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return false, fmt.Errorf("failed to delete alerts: %w", err)
|
||||
}
|
||||
|
||||
// Delete subscriber's account
|
||||
if err := tx.Where("imsi IN (SELECT imsi FROM subscribers WHERE id = ?))", subscriberId).Delete(&models.SubscriberAccount{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return false, fmt.Errorf("failed to delete account: %w", err)
|
||||
}
|
||||
|
||||
// Delete subscriber
|
||||
if err := tx.Delete(&models.Subscriber{}, subscriberId).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return false, fmt.Errorf("failed to delete subscriber: %w", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return false, fmt.Errorf("failed to commit deletion: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ActivateSubscriber activates a subscriber
|
||||
func (s *SubscriberService) ActivateSubscriber(ctx context.Context, subscriberId uint) (*models.Subscriber, error) {
|
||||
var subscriber models.Subscriber
|
||||
|
||||
// Update subscriber status to active
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Model(&subscriber).
|
||||
Where("id = ?", subscriberId).
|
||||
Update("status", models.SubscriberStatusActive).
|
||||
Update("activated_at", time.Now()).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to activate subscriber: %w", err)
|
||||
}
|
||||
|
||||
// Get updated subscriber
|
||||
if err := s.db.DB.WithContext(ctx).
|
||||
Preload("Plan").
|
||||
First(&subscriber, subscriberId).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve activated subscriber: %w", err)
|
||||
}
|
||||
|
||||
return &subscriber, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user