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:
nutcas3
2026-05-03 11:21:14 +03:00
parent f4780a5562
commit 42d7f4ac86
@@ -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
}
}