feat: Refactor churn analysis service to simplify logic and extract reusable components

- Remove ChurnRiskLevel, ChurnPrediction, ChurnMetrics, and ChurnFactor type definitions from churn_service.go
- Simplify NewChurnAnalysisService constructor to single-line initialization
- Refactor PredictChurn to use analyzeFactors, calculateScore, RiskLevelFromScore, generateReasons, and RecommendationsForRisk helper functions
- Simplify GetChurnMetrics by using periodDates helper and inline database queries
- Replace GetChurnFactors implementation with DefaultChurnFactors helper
This commit is contained in:
nutcas3
2026-05-03 11:50:12 +03:00
parent 4a834a175a
commit 7618bb27d4
@@ -9,48 +9,6 @@ import (
"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
@@ -59,38 +17,26 @@ type ChurnAnalysisService struct {
// NewChurnAnalysisService creates a new churn analysis service
func NewChurnAnalysisService(db *gorm.DB, logger *logrus.Logger) *ChurnAnalysisService {
return &ChurnAnalysisService{
db: db,
logger: logger,
}
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
factors := s.analyzeFactors(ctx, profileID)
score := s.calculateScore(factors)
prediction := &ChurnPrediction{
ProfileID: profileID,
LastUpdated: time.Now(),
ProfileID: profileID,
RiskScore: score,
RiskLevel: RiskLevelFromScore(score),
Reasons: s.generateReasons(factors),
Recommendations: RecommendationsForRisk(RiskLevelFromScore(score)),
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
date := time.Now().AddDate(0, 0, int((100-score)*3))
prediction.PredictedChurnDate = &date
}
return prediction, nil
@@ -98,380 +44,154 @@ func (s *ChurnAnalysisService) PredictChurn(ctx context.Context, profileID strin
// 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(),
}
start, end := periodDates(period)
metrics := &ChurnMetrics{Period: period, RiskDistribution: make(map[ChurnRiskLevel]int64), GeneratedAt: time.Now()}
// Calculate churn rates based on period
startDate, endDate := s.getPeriodDates(period)
s.db.WithContext(ctx).Table("profiles").Where("created_at < ?", start).Count(&metrics.TotalSubscribers)
s.db.WithContext(ctx).Table("rate_plan_subscriptions").Where("ended_at BETWEEN ? AND ?", start, end).Count(&metrics.ChurnedSubscribers)
// 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
if metrics.TotalSubscribers > 0 {
metrics.ChurnRate = float64(metrics.ChurnedSubscribers) / float64(metrics.TotalSubscribers) * 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
s.db.WithContext(ctx).Table("rate_plan_subscriptions").Where("status = ?", "cancelled").
Select("AVG(EXTRACT(EPOCH FROM (ended_at - started_at))/86400)").Scan(&metrics.AverageTenure)
// 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)
// Estimated distribution
metrics.RiskDistribution[ChurnRiskLow] = int64(float64(metrics.TotalSubscribers) * 0.6)
metrics.RiskDistribution[ChurnRiskMedium] = int64(float64(metrics.TotalSubscribers) * 0.25)
metrics.RiskDistribution[ChurnRiskHigh] = int64(float64(metrics.TotalSubscribers) * 0.12)
metrics.RiskDistribution[ChurnRiskCritical] = int64(float64(metrics.TotalSubscribers) * 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
func (s *ChurnAnalysisService) GetChurnFactors(_ context.Context) ([]ChurnFactor, error) {
return DefaultChurnFactors(), 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 subs []struct{ ID string }
s.db.WithContext(ctx).Table("profiles").Where("status = ?", "active").Limit(limit).Find(&subs)
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)
for _, sub := range subs {
pred, 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)
if pred.RiskLevel == riskLevel || (riskLevel == ChurnRiskHigh && pred.RiskLevel == ChurnRiskCritical) {
predictions = append(predictions, pred)
}
}
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)
func (s *ChurnAnalysisService) analyzeFactors(ctx context.Context, profileID string) []ChurnFactor {
factors := DefaultChurnFactors()
result := make([]ChurnFactor, 0, len(factors))
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,
})
for _, f := range factors {
impact := s.factorImpact(ctx, profileID, f.Factor)
if impact > 0.1 {
result = append(result, ChurnFactor{Factor: f.Factor, Impact: impact, Description: f.Description, Weight: f.Weight})
}
}
return profileFactors
return result
}
// 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
func (s *ChurnAnalysisService) factorImpact(ctx context.Context, profileID, factor string) float64 {
switch factor {
case "Low Usage":
return s.usageImpact(ctx, profileID)
case "Payment Issues":
return s.paymentImpact(ctx, profileID)
case "Price Sensitivity":
return s.priceImpact(ctx, profileID)
default:
return 0.4
}
}
if totalWeight == 0 {
func (s *ChurnAnalysisService) usageImpact(ctx context.Context, profileID string) float64 {
var total 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 + voice_used + sms_used), 0)").Scan(&total)
switch {
case total < 100:
return 0.8
case total < 500:
return 0.6
case total < 1000:
return 0.3
default:
return 0.1
}
}
func (s *ChurnAnalysisService) paymentImpact(ctx context.Context, profileID string) float64 {
var createdAt time.Time
s.db.WithContext(ctx).Table("profiles").Where("id = ?", profileID).Select("created_at").Scan(&createdAt)
days := time.Since(createdAt).Hours() / 24
if days < 30 {
return 0.3
} else if days < 90 {
return 0.2
}
return 0.1
}
func (s *ChurnAnalysisService) priceImpact(ctx context.Context, profileID string) float64 {
var price 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(&price)
switch {
case price > 50:
return 0.6
case price > 30:
return 0.4
case price > 15:
return 0.2
default:
return 0.1
}
}
func (s *ChurnAnalysisService) calculateScore(factors []ChurnFactor) float64 {
var total, weight float64
for _, f := range factors {
total += f.Impact * f.Weight
weight += f.Weight
}
if weight == 0 {
return 0
}
return (totalScore / totalWeight) * 100
return (total / weight) * 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))
func (s *ChurnAnalysisService) generateReasons(factors []ChurnFactor) []string {
var reasons []string
for _, f := range factors {
if f.Impact > 0.7 {
reasons = append(reasons, fmt.Sprintf("High %s detected", f.Factor))
} else if f.Impact > 0.5 {
reasons = append(reasons, fmt.Sprintf("Moderate %s detected", f.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) {
func periodDates(period string) (time.Time, time.Time) {
now := time.Now()
switch period {
case "daily":
return now.Truncate(24 * time.Hour), now
@@ -481,9 +201,7 @@ func (s *ChurnAnalysisService) getPeriodDates(period string) (time.Time, time.Ti
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
return now.AddDate(0, -1, 0), now
}
}