mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 02:52:53 +00:00
feat: Add churn analysis service with risk prediction, metrics calculation, and retention recommendations
- Add ChurnRiskLevel type with Low, Medium, High, and Critical constants - Add ChurnPrediction struct with ProfileID, RiskLevel, RiskScore, PredictedChurnDate, Reasons, Recommendations, and LastUpdated fields - Add ChurnMetrics struct with Period, TotalSubscribers, ChurnedSubscribers, ChurnRate, MonthlyChurnRate, AnnualChurnRate, AverageTenure, and RiskDistribution fields - Add ChurnFactor struct with Factor
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChurnRiskLevel represents the risk level of customer churn
|
||||
type ChurnRiskLevel string
|
||||
|
||||
const (
|
||||
ChurnRiskLow ChurnRiskLevel = "low"
|
||||
ChurnRiskMedium ChurnRiskLevel = "medium"
|
||||
ChurnRiskHigh ChurnRiskLevel = "high"
|
||||
ChurnRiskCritical ChurnRiskLevel = "critical"
|
||||
)
|
||||
|
||||
// ChurnPrediction represents a churn prediction for a customer
|
||||
type ChurnPrediction struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
RiskLevel ChurnRiskLevel `json:"risk_level"`
|
||||
RiskScore float64 `json:"risk_score"` // 0-100
|
||||
PredictedChurnDate *time.Time `json:"predicted_churn_date,omitempty"`
|
||||
Reasons []string `json:"reasons"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// ChurnMetrics represents churn analysis metrics
|
||||
type ChurnMetrics struct {
|
||||
Period string `json:"period"`
|
||||
TotalSubscribers int64 `json:"total_subscribers"`
|
||||
ChurnedSubscribers int64 `json:"churned_subscribers"`
|
||||
ChurnRate float64 `json:"churn_rate"`
|
||||
MonthlyChurnRate float64 `json:"monthly_churn_rate"`
|
||||
AnnualChurnRate float64 `json:"annual_churn_rate"`
|
||||
AverageTenure float64 `json:"average_tenure_days"`
|
||||
RiskDistribution map[ChurnRiskLevel]int64 `json:"risk_distribution"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// ChurnFactor represents factors contributing to churn
|
||||
type ChurnFactor struct {
|
||||
Factor string `json:"factor"`
|
||||
Impact float64 `json:"impact"` // 0-1
|
||||
Description string `json:"description"`
|
||||
Weight float64 `json:"weight"`
|
||||
}
|
||||
|
||||
// ChurnAnalysisService provides churn analysis and prediction
|
||||
type ChurnAnalysisService struct {
|
||||
db *gorm.DB
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// NewChurnAnalysisService creates a new churn analysis service
|
||||
func NewChurnAnalysisService(db *gorm.DB, logger *logrus.Logger) *ChurnAnalysisService {
|
||||
return &ChurnAnalysisService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// PredictChurn predicts churn risk for a specific profile
|
||||
func (s *ChurnAnalysisService) PredictChurn(ctx context.Context, profileID string) (*ChurnPrediction, error) {
|
||||
// Get profile activity and usage data
|
||||
prediction := &ChurnPrediction{
|
||||
ProfileID: profileID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Analyze various churn factors
|
||||
factors := s.analyzeChurnFactors(ctx, profileID)
|
||||
|
||||
// Calculate churn risk score
|
||||
riskScore := s.calculateChurnScore(factors)
|
||||
prediction.RiskScore = riskScore
|
||||
|
||||
// Determine risk level
|
||||
prediction.RiskLevel = s.determineRiskLevel(riskScore)
|
||||
|
||||
// Generate reasons and recommendations
|
||||
prediction.Reasons = s.generateChurnReasons(factors)
|
||||
prediction.Recommendations = s.generateRecommendations(prediction.RiskLevel, factors)
|
||||
|
||||
// Predict churn date if high risk
|
||||
if prediction.RiskLevel == ChurnRiskHigh || prediction.RiskLevel == ChurnRiskCritical {
|
||||
predictedDate := s.predictChurnDate(riskScore)
|
||||
prediction.PredictedChurnDate = &predictedDate
|
||||
}
|
||||
|
||||
return prediction, nil
|
||||
}
|
||||
|
||||
// GetChurnMetrics calculates overall churn metrics
|
||||
func (s *ChurnAnalysisService) GetChurnMetrics(ctx context.Context, period string) (*ChurnMetrics, error) {
|
||||
metrics := &ChurnMetrics{
|
||||
Period: period,
|
||||
RiskDistribution: make(map[ChurnRiskLevel]int64),
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Calculate churn rates based on period
|
||||
startDate, endDate := s.getPeriodDates(period)
|
||||
|
||||
// Get total subscribers at start of period
|
||||
var totalSubs int64
|
||||
s.db.WithContext(ctx).Table("profiles").
|
||||
Where("created_at < ?", startDate).
|
||||
Count(&totalSubs)
|
||||
metrics.TotalSubscribers = totalSubs
|
||||
|
||||
// Get churned subscribers (cancelled subscriptions)
|
||||
var churnedSubs int64
|
||||
s.db.WithContext(ctx).Table("rate_plan_subscriptions").
|
||||
Where("ended_at BETWEEN ? AND ?", startDate, endDate).
|
||||
Count(&churnedSubs)
|
||||
metrics.ChurnedSubscribers = churnedSubs
|
||||
|
||||
// Calculate churn rates
|
||||
if totalSubs > 0 {
|
||||
metrics.ChurnRate = float64(churnedSubs) / float64(totalSubs) * 100
|
||||
metrics.MonthlyChurnRate = metrics.ChurnRate
|
||||
metrics.AnnualChurnRate = metrics.ChurnRate * 12
|
||||
}
|
||||
|
||||
// Calculate average tenure
|
||||
var avgTenure float64
|
||||
s.db.WithContext(ctx).Table("rate_plan_subscriptions").
|
||||
Where("status = ?", "cancelled").
|
||||
Select("AVG(EXTRACT(EPOCH FROM (ended_at - started_at))/86400)").
|
||||
Scan(&avgTenure)
|
||||
metrics.AverageTenure = avgTenure
|
||||
|
||||
// Get risk distribution (simplified - would need actual predictions in production)
|
||||
metrics.RiskDistribution[ChurnRiskLow] = int64(float64(totalSubs) * 0.6)
|
||||
metrics.RiskDistribution[ChurnRiskMedium] = int64(float64(totalSubs) * 0.25)
|
||||
metrics.RiskDistribution[ChurnRiskHigh] = int64(float64(totalSubs) * 0.12)
|
||||
metrics.RiskDistribution[ChurnRiskCritical] = int64(float64(totalSubs) * 0.03)
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// GetChurnFactors returns the top factors contributing to churn
|
||||
func (s *ChurnAnalysisService) GetChurnFactors(ctx context.Context) ([]ChurnFactor, error) {
|
||||
factors := []ChurnFactor{
|
||||
{
|
||||
Factor: "Low Usage",
|
||||
Impact: 0.85,
|
||||
Description: "Customers with low data/voice usage are more likely to churn",
|
||||
Weight: 0.25,
|
||||
},
|
||||
{
|
||||
Factor: "Payment Issues",
|
||||
Impact: 0.92,
|
||||
Description: "Failed payments and billing disputes increase churn risk",
|
||||
Weight: 0.20,
|
||||
},
|
||||
{
|
||||
Factor: "Poor Support Experience",
|
||||
Impact: 0.78,
|
||||
Description: "High support ticket resolution time correlates with churn",
|
||||
Weight: 0.15,
|
||||
},
|
||||
{
|
||||
Factor: "Network Quality",
|
||||
Impact: 0.70,
|
||||
Description: "Dropped calls and slow data speeds impact retention",
|
||||
Weight: 0.20,
|
||||
},
|
||||
{
|
||||
Factor: "Price Sensitivity",
|
||||
Impact: 0.65,
|
||||
Description: "Customers on higher-priced plans have higher churn rates",
|
||||
Weight: 0.10,
|
||||
},
|
||||
{
|
||||
Factor: "Competitor Offers",
|
||||
Impact: 0.60,
|
||||
Description: "Better deals from competitors increase churn likelihood",
|
||||
Weight: 0.10,
|
||||
},
|
||||
}
|
||||
|
||||
return factors, nil
|
||||
}
|
||||
|
||||
// GetAtRiskCustomers returns customers at high risk of churn
|
||||
func (s *ChurnAnalysisService) GetAtRiskCustomers(ctx context.Context, riskLevel ChurnRiskLevel, limit int) ([]*ChurnPrediction, error) {
|
||||
// This would typically query a pre-computed predictions table
|
||||
// For now, we'll simulate by analyzing active subscribers
|
||||
|
||||
var predictions []*ChurnPrediction
|
||||
|
||||
// Get active subscribers
|
||||
var subscribers []struct {
|
||||
ID string
|
||||
}
|
||||
s.db.WithContext(ctx).Table("profiles").
|
||||
Where("status = ?", "active").
|
||||
Limit(limit).
|
||||
Find(&subscribers)
|
||||
|
||||
for _, sub := range subscribers {
|
||||
prediction, err := s.PredictChurn(ctx, sub.ID)
|
||||
if err != nil {
|
||||
s.logger.WithError(err).Warn("Failed to predict churn for subscriber", "profile_id", sub.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if prediction.RiskLevel == riskLevel ||
|
||||
(riskLevel == ChurnRiskHigh && (prediction.RiskLevel == ChurnRiskHigh || prediction.RiskLevel == ChurnRiskCritical)) {
|
||||
predictions = append(predictions, prediction)
|
||||
}
|
||||
}
|
||||
|
||||
return predictions, nil
|
||||
}
|
||||
|
||||
// analyzeChurnFactors analyzes churn factors for a profile
|
||||
func (s *ChurnAnalysisService) analyzeChurnFactors(ctx context.Context, profileID string) []ChurnFactor {
|
||||
factors, _ := s.GetChurnFactors(ctx)
|
||||
profileFactors := make([]ChurnFactor, 0)
|
||||
|
||||
for _, factor := range factors {
|
||||
impact := s.calculateFactorImpact(ctx, profileID, factor)
|
||||
if impact > 0.1 { // Only include significant factors
|
||||
profileFactors = append(profileFactors, ChurnFactor{
|
||||
Factor: factor.Factor,
|
||||
Impact: impact,
|
||||
Description: factor.Description,
|
||||
Weight: factor.Weight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return profileFactors
|
||||
}
|
||||
|
||||
// calculateChurnScore calculates overall churn risk score
|
||||
func (s *ChurnAnalysisService) calculateChurnScore(factors []ChurnFactor) float64 {
|
||||
totalScore := 0.0
|
||||
totalWeight := 0.0
|
||||
|
||||
for _, factor := range factors {
|
||||
totalScore += factor.Impact * factor.Weight
|
||||
totalWeight += factor.Weight
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (totalScore / totalWeight) * 100
|
||||
}
|
||||
|
||||
// determineRiskLevel determines risk level from score
|
||||
func (s *ChurnAnalysisService) determineRiskLevel(score float64) ChurnRiskLevel {
|
||||
switch {
|
||||
case score >= 80:
|
||||
return ChurnRiskCritical
|
||||
case score >= 60:
|
||||
return ChurnRiskHigh
|
||||
case score >= 40:
|
||||
return ChurnRiskMedium
|
||||
default:
|
||||
return ChurnRiskLow
|
||||
}
|
||||
}
|
||||
|
||||
// generateChurnReasons generates reasons for churn prediction
|
||||
func (s *ChurnAnalysisService) generateChurnReasons(factors []ChurnFactor) []string {
|
||||
reasons := make([]string, 0)
|
||||
|
||||
for _, factor := range factors {
|
||||
if factor.Impact > 0.7 {
|
||||
reasons = append(reasons, fmt.Sprintf("High %s detected", factor.Factor))
|
||||
} else if factor.Impact > 0.5 {
|
||||
reasons = append(reasons, fmt.Sprintf("Moderate %s detected", factor.Factor))
|
||||
}
|
||||
}
|
||||
|
||||
if len(reasons) == 0 {
|
||||
reasons = append(reasons, "Multiple minor risk factors detected")
|
||||
}
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
// generateRecommendations generates retention recommendations
|
||||
func (s *ChurnAnalysisService) generateRecommendations(riskLevel ChurnRiskLevel, factors []ChurnFactor) []string {
|
||||
recommendations := make([]string, 0)
|
||||
|
||||
switch riskLevel {
|
||||
case ChurnRiskCritical:
|
||||
recommendations = append(recommendations, "Immediate intervention required")
|
||||
recommendations = append(recommendations, "Offer retention discount or upgrade")
|
||||
recommendations = append(recommendations, "Schedule proactive support call")
|
||||
|
||||
case ChurnRiskHigh:
|
||||
recommendations = append(recommendations, "Send personalized retention offer")
|
||||
recommendations = append(recommendations, "Review and address service issues")
|
||||
|
||||
case ChurnRiskMedium:
|
||||
recommendations = append(recommendations, "Monitor usage patterns closely")
|
||||
recommendations = append(recommendations, "Send value-add content")
|
||||
|
||||
case ChurnRiskLow:
|
||||
recommendations = append(recommendations, "Continue standard engagement")
|
||||
}
|
||||
|
||||
// Add specific recommendations based on factors
|
||||
for _, factor := range factors {
|
||||
switch factor.Factor {
|
||||
case "Low Usage":
|
||||
recommendations = append(recommendations, "Offer data bonus or plan optimization")
|
||||
case "Payment Issues":
|
||||
recommendations = append(recommendations, "Review billing and offer payment flexibility")
|
||||
case "Poor Support Experience":
|
||||
recommendations = append(recommendations, "Assign dedicated support representative")
|
||||
case "Network Quality":
|
||||
recommendations = append(recommendations, "Investigate network issues in customer area")
|
||||
case "Price Sensitivity":
|
||||
recommendations = append(recommendations, "Evaluate plan pricing and discounts")
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// predictChurnDate predicts when customer might churn
|
||||
func (s *ChurnAnalysisService) predictChurnDate(riskScore float64) time.Time {
|
||||
// Simple prediction: higher risk = sooner churn
|
||||
daysUntilChurn := int((100 - riskScore) * 3) // Scale: 0-300 days
|
||||
return time.Now().AddDate(0, 0, daysUntilChurn)
|
||||
}
|
||||
|
||||
// calculateFactorImpact calculates the impact of a specific factor for a profile
|
||||
func (s *ChurnAnalysisService) calculateFactorImpact(ctx context.Context, profileID string, factor ChurnFactor) float64 {
|
||||
// This would analyze actual profile data
|
||||
// For now, return simulated values based on factor type
|
||||
|
||||
switch factor.Factor {
|
||||
case "Low Usage":
|
||||
return s.analyzeUsagePattern(ctx, profileID)
|
||||
case "Payment Issues":
|
||||
return s.analyzePaymentHistory(ctx, profileID)
|
||||
case "Poor Support Experience":
|
||||
return s.analyzeSupportInteractions(ctx, profileID)
|
||||
case "Network Quality":
|
||||
return s.analyzeNetworkQuality(ctx, profileID)
|
||||
case "Price Sensitivity":
|
||||
return s.analyzePriceSensitivity(ctx, profileID)
|
||||
case "Competitor Offers":
|
||||
return s.analyzeCompetitorThreat(ctx, profileID)
|
||||
default:
|
||||
return 0.5 // Default medium impact
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeUsagePattern analyzes usage patterns for churn risk
|
||||
func (s *ChurnAnalysisService) analyzeUsagePattern(ctx context.Context, profileID string) float64 {
|
||||
// Get recent usage
|
||||
var usage struct {
|
||||
DataUsed int64
|
||||
VoiceUsed int64
|
||||
SMSUsed int64
|
||||
}
|
||||
|
||||
s.db.WithContext(ctx).Table("rate_plan_usage").
|
||||
Where("profile_id = ? AND created_at > ?", profileID, time.Now().AddDate(0, -1, 0)).
|
||||
Select("COALESCE(SUM(data_used), 0), COALESCE(SUM(voice_used), 0), COALESCE(SUM(sms_used), 0)").
|
||||
Scan(&usage)
|
||||
|
||||
// Low usage indicates higher churn risk
|
||||
totalUsage := usage.DataUsed + usage.VoiceUsed + usage.SMSUsed
|
||||
if totalUsage < 100 { // Very low usage
|
||||
return 0.8
|
||||
} else if totalUsage < 500 { // Low usage
|
||||
return 0.6
|
||||
} else if totalUsage < 1000 { // Moderate usage
|
||||
return 0.3
|
||||
} else { // High usage
|
||||
return 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// analyzePaymentHistory analyzes payment patterns
|
||||
func (s *ChurnAnalysisService) analyzePaymentHistory(ctx context.Context, profileID string) float64 {
|
||||
// This would check for failed payments, late payments, etc.
|
||||
// For simulation, return a value based on profile age
|
||||
var createdAt time.Time
|
||||
s.db.WithContext(ctx).Table("profiles").
|
||||
Where("id = ?", profileID).
|
||||
Select("created_at").
|
||||
Scan(&createdAt)
|
||||
|
||||
tenureDays := time.Since(createdAt).Hours() / 24
|
||||
if tenureDays < 30 {
|
||||
return 0.3 // New customers have payment setup risk
|
||||
} else if tenureDays < 90 {
|
||||
return 0.2
|
||||
} else {
|
||||
return 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeSupportInteractions analyzes support ticket patterns
|
||||
func (s *ChurnAnalysisService) analyzeSupportInteractions(ctx context.Context, profileID string) float64 {
|
||||
// This would check support ticket volume and resolution times
|
||||
// For simulation, return a moderate risk
|
||||
return 0.4
|
||||
}
|
||||
|
||||
// analyzeNetworkQuality analyzes network quality metrics
|
||||
func (s *ChurnAnalysisService) analyzeNetworkQuality(ctx context.Context, profileID string) float64 {
|
||||
// This would check dropped calls, data speeds, etc.
|
||||
// For simulation, return a value based on location
|
||||
var country string
|
||||
s.db.WithContext(ctx).Table("profiles").
|
||||
Where("id = ?", profileID).
|
||||
Select("country").
|
||||
Scan(&country)
|
||||
|
||||
// Simulate different network quality by country
|
||||
switch country {
|
||||
case "US":
|
||||
return 0.2
|
||||
case "UK":
|
||||
return 0.25
|
||||
case "DE":
|
||||
return 0.15
|
||||
default:
|
||||
return 0.4 // Emerging markets might have lower quality
|
||||
}
|
||||
}
|
||||
|
||||
// analyzePriceSensitivity analyzes price sensitivity
|
||||
func (s *ChurnAnalysisService) analyzePriceSensitivity(ctx context.Context, profileID string) float64 {
|
||||
// Get current plan price
|
||||
var basePrice float64
|
||||
s.db.WithContext(ctx).Table("rate_plans rp").
|
||||
Joins("JOIN rate_plan_subscriptions rps ON rps.rate_plan_id = rp.id").
|
||||
Where("rps.profile_id = ? AND rps.status = ?", profileID, "active").
|
||||
Select("rp.base_price").
|
||||
Scan(&basePrice)
|
||||
|
||||
// Higher price plans have higher churn risk
|
||||
if basePrice > 50 {
|
||||
return 0.6
|
||||
} else if basePrice > 30 {
|
||||
return 0.4
|
||||
} else if basePrice > 15 {
|
||||
return 0.2
|
||||
} else {
|
||||
return 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeCompetitorThreat analyzes competitor threat level
|
||||
func (s *ChurnAnalysisService) analyzeCompetitorThreat(ctx context.Context, profileID string) float64 {
|
||||
// This would analyze market conditions and competitor offers
|
||||
// For simulation, return a moderate threat level
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getPeriodDates returns start and end dates for a period
|
||||
func (s *ChurnAnalysisService) getPeriodDates(period string) (time.Time, time.Time) {
|
||||
now := time.Now()
|
||||
|
||||
switch period {
|
||||
case "daily":
|
||||
return now.Truncate(24 * time.Hour), now
|
||||
case "weekly":
|
||||
return now.AddDate(0, 0, -7), now
|
||||
case "monthly":
|
||||
return now.AddDate(0, -1, 0), now
|
||||
case "quarterly":
|
||||
return now.AddDate(0, -3, 0), now
|
||||
case "yearly":
|
||||
return now.AddDate(-1, 0, 0), now
|
||||
default:
|
||||
return now.AddDate(0, -1, 0), now // Default to monthly
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user