diff --git a/apps/carrier-connector/internal/rateplan/pricing_engine.go b/apps/carrier-connector/internal/rateplan/pricing_engine.go new file mode 100644 index 0000000..f212433 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/pricing_engine.go @@ -0,0 +1,468 @@ +package rateplan + +import ( + "context" + "fmt" + "time" +) + +// PricingEngine handles rate plan pricing calculations and validations +type PricingEngine struct { + repo Repository + logger Logger +} + +// Logger interface for pricing engine +type Logger interface { + WithError(err error) Logger + WithField(key string, value interface{}) Logger + Error(msg string) + Info(msg string) + Warning(msg string) +} + +// NewPricingEngine creates a new pricing engine +func NewPricingEngine(repo Repository, logger Logger) *PricingEngine { + return &PricingEngine{ + repo: repo, + logger: logger, + } +} + +// ValidateRatePlan performs comprehensive validation of a rate plan +func (pe *PricingEngine) ValidateRatePlan(ctx context.Context, plan *RatePlan) error { + // Basic field validation + if err := pe.validateBasicFields(plan); err != nil { + return err + } + + // Validate dates + if err := pe.validateDates(plan); err != nil { + return err + } + + // Validate allowances + if err := pe.validateAllowances(plan); err != nil { + return err + } + + // Validate overage rates + if err := pe.validateOverageRates(plan); err != nil { + return err + } + + // Validate discounts + if err := pe.validateDiscounts(plan); err != nil { + return err + } + + // Validate early termination + if err := pe.validateEarlyTermination(plan); err != nil { + return err + } + + return nil +} + +// CalculateOptimalPrice calculates the optimal price for a rate plan based on market conditions +func (pe *PricingEngine) CalculateOptimalPrice(ctx context.Context, plan *RatePlan) (*PriceOptimization, error) { + // Get similar plans in the same region + filter := &RatePlanFilter{ + Region: plan.Region, + PlanType: plan.PlanType, + Status: PlanStatusActive, + IsActive: &[]bool{true}[0], + Limit: 10, + } + + similarPlans, err := pe.repo.ListRatePlans(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get similar plans: %w", err) + } + + // Calculate market average + var totalBasePrice float64 + for _, similarPlan := range similarPlans { + totalBasePrice += similarPlan.BasePrice + } + + var marketAverage float64 + if len(similarPlans) > 0 { + marketAverage = totalBasePrice / float64(len(similarPlans)) + } + + // Calculate recommended price + recommendedPrice := pe.calculateRecommendedPrice(plan, marketAverage) + + optimization := &PriceOptimization{ + CurrentPrice: plan.BasePrice, + MarketAverage: marketAverage, + RecommendedPrice: recommendedPrice, + PriceDifference: recommendedPrice - plan.BasePrice, + CompetitorCount: len(similarPlans), + OptimizedAt: time.Now(), + } + + return optimization, nil +} + +// ValidateSubscription validates a subscription request +func (pe *PricingEngine) ValidateSubscription(ctx context.Context, req *SubscribeRequest) error { + // Get the rate plan + plan, err := pe.repo.GetRatePlan(ctx, req.RatePlanID) + if err != nil { + return fmt.Errorf("rate plan not found: %w", err) + } + + // Check if plan is available for subscription + if !plan.IsActive || plan.Status != PlanStatusActive { + return fmt.Errorf("rate plan is not available for subscription") + } + + // Check validity dates + now := time.Now() + if now.Before(plan.ValidFrom) { + return fmt.Errorf("rate plan is not yet available") + } + + if plan.ValidTo != nil && now.After(*plan.ValidTo) { + return fmt.Errorf("rate plan has expired") + } + + // Validate discounts + if len(req.AppliedDiscounts) > 0 { + if err := pe.validateSubscriptionDiscounts(plan, req.AppliedDiscounts); err != nil { + return err + } + } + + return nil +} + +// CalculateSubscriptionCost calculates the total cost for a subscription +func (pe *PricingEngine) CalculateSubscriptionCost(ctx context.Context, req *CalculateCostRequest) (*CostBreakdown, error) { + // Get the rate plan + plan, err := pe.repo.GetRatePlan(ctx, req.RatePlanID) + if err != nil { + return nil, fmt.Errorf("rate plan not found: %w", err) + } + + breakdown := &CostBreakdown{ + RatePlanID: req.RatePlanID, + Currency: plan.Currency, + CalculatedAt: time.Now(), + BreakdownItems: []CostItem{}, + } + + // Base cost + baseCost := plan.BasePrice + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "base_price", + Description: "Base subscription cost", + Amount: baseCost, + Currency: plan.Currency, + }) + + // Calculate data overage + dataOverageCost := pe.calculateDataOverage(plan, req.DataUsed) + if dataOverageCost > 0 { + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "data_overage", + Description: "Data usage over allowance", + Amount: dataOverageCost, + Currency: plan.Currency, + }) + } + + // Calculate voice overage + voiceOverageCost := pe.calculateVoiceOverage(plan, req.VoiceUsed) + if voiceOverageCost > 0 { + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "voice_overage", + Description: "Voice usage over allowance", + Amount: voiceOverageCost, + Currency: plan.Currency, + }) + } + + // Calculate SMS overage + smsOverageCost := pe.calculateSMSOverage(plan, req.SMSUsed) + if smsOverageCost > 0 { + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "sms_overage", + Description: "SMS usage over allowance", + Amount: smsOverageCost, + Currency: plan.Currency, + }) + } + + // Apply discounts + discountAmount := pe.calculateDiscounts(plan, req.AppliedDiscounts, baseCost) + if discountAmount > 0 { + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "discount", + Description: "Applied discounts", + Amount: -discountAmount, + Currency: plan.Currency, + }) + } + + // Calculate total + totalCost := baseCost + dataOverageCost + voiceOverageCost + smsOverageCost - discountAmount + breakdown.TotalCost = totalCost + breakdown.Subtotal = baseCost + dataOverageCost + voiceOverageCost + smsOverageCost + breakdown.DiscountTotal = discountAmount + + return breakdown, nil +} + +// Helper methods + +func (pe *PricingEngine) validateBasicFields(plan *RatePlan) error { + if plan.Name == "" { + return fmt.Errorf("rate plan name is required") + } + if plan.CarrierID == "" { + return fmt.Errorf("carrier ID is required") + } + if plan.Region == "" { + return fmt.Errorf("region is required") + } + if plan.BasePrice < 0 { + return fmt.Errorf("base price cannot be negative") + } + if plan.Currency == "" { + return fmt.Errorf("currency is required") + } + if plan.BillingCycle == "" { + return fmt.Errorf("billing cycle is required") + } + return nil +} + +func (pe *PricingEngine) validateDates(plan *RatePlan) error { + if plan.ValidFrom.IsZero() { + return fmt.Errorf("valid from date is required") + } + + now := time.Now() + if plan.ValidFrom.After(now) { + pe.logger.Warning("Rate plan valid from date is in the future") + } + + if plan.ValidTo != nil && plan.ValidTo.Before(plan.ValidFrom) { + return fmt.Errorf("valid to date cannot be before valid from date") + } + + return nil +} + +func (pe *PricingEngine) validateAllowances(plan *RatePlan) error { + // Validate data allowance + if plan.DataAllowance != nil { + if plan.DataAllowance.Amount <= 0 && !plan.DataAllowance.Unlimited { + return fmt.Errorf("data allowance amount must be positive or unlimited") + } + if plan.DataAllowance.Unit == "" { + return fmt.Errorf("data allowance unit is required") + } + } + + // Validate voice allowance + if plan.VoiceAllowance != nil { + if plan.VoiceAllowance.Minutes <= 0 && !plan.VoiceAllowance.Unlimited { + return fmt.Errorf("voice allowance minutes must be positive or unlimited") + } + } + + // Validate SMS allowance + if plan.SMSAllowance != nil { + if plan.SMSAllowance.Messages <= 0 && !plan.SMSAllowance.Unlimited { + return fmt.Errorf("SMS allowance messages must be positive or unlimited") + } + } + + return nil +} + +func (pe *PricingEngine) validateOverageRates(plan *RatePlan) error { + if plan.OverageRates != nil { + if plan.OverageRates.DataRate < 0 { + return fmt.Errorf("data overage rate cannot be negative") + } + if plan.OverageRates.VoiceRate < 0 { + return fmt.Errorf("voice overage rate cannot be negative") + } + if plan.OverageRates.SMSRate < 0 { + return fmt.Errorf("SMS overage rate cannot be negative") + } + if plan.OverageRates.Currency == "" { + return fmt.Errorf("overage rates currency is required") + } + } + return nil +} + +func (pe *PricingEngine) validateDiscounts(plan *RatePlan) error { + if plan.Discounts != nil { + for _, discount := range plan.Discounts { + if discount.Name == "" { + return fmt.Errorf("discount name is required") + } + if discount.Value <= 0 { + return fmt.Errorf("discount value must be positive") + } + if discount.ValidFrom.IsZero() { + return fmt.Errorf("discount valid from date is required") + } + if discount.ValidTo != nil && discount.ValidTo.Before(discount.ValidFrom) { + return fmt.Errorf("discount valid to date cannot be before valid from date") + } + } + } + return nil +} + +func (pe *PricingEngine) validateEarlyTermination(plan *RatePlan) error { + if plan.EarlyTermination != nil && plan.EarlyTermination.Enabled { + if plan.EarlyTermination.FeeType == "" { + return fmt.Errorf("early termination fee type is required") + } + if plan.EarlyTermination.FeeType == "fixed" && plan.EarlyTermination.FeeAmount <= 0 { + return fmt.Errorf("early termination fee amount must be positive for fixed fee type") + } + if plan.EarlyTermination.FeeType == "percentage" && (plan.EarlyTermination.FeePercentage <= 0 || plan.EarlyTermination.FeePercentage > 100) { + return fmt.Errorf("early termination fee percentage must be between 0 and 100") + } + } + return nil +} + +func (pe *PricingEngine) calculateRecommendedPrice(plan *RatePlan, marketAverage float64) float64 { + // Basic pricing strategy: position slightly below market average for competitive advantage + if marketAverage > 0 { + return marketAverage * 0.95 // 5% below market average + } + return plan.BasePrice +} + +func (pe *PricingEngine) validateSubscriptionDiscounts(plan *RatePlan, discountIDs []string) error { + if plan.Discounts == nil { + return fmt.Errorf("no discounts available for this rate plan") + } + + for _, discountID := range discountIDs { + found := false + for _, discount := range plan.Discounts { + if discount.ID == discountID { + if !discount.IsActive { + return fmt.Errorf("discount %s is not active", discountID) + } + now := time.Now() + if now.Before(discount.ValidFrom) || (discount.ValidTo != nil && now.After(*discount.ValidTo)) { + return fmt.Errorf("discount %s is not currently valid", discountID) + } + found = true + break + } + } + if !found { + return fmt.Errorf("discount %s not found", discountID) + } + } + + return nil +} + +func (pe *PricingEngine) calculateDataOverage(plan *RatePlan, dataUsed int64) float64 { + if plan.DataAllowance == nil || plan.DataAllowance.Unlimited || plan.OverageRates == nil { + return 0 + } + + allowanceMB := plan.DataAllowance.Amount + if plan.DataAllowance.Unit == "GB" { + allowanceMB *= 1024 + } + + if dataUsed <= allowanceMB { + return 0 + } + + overageMB := dataUsed - allowanceMB + return float64(overageMB) * plan.OverageRates.DataRate +} + +func (pe *PricingEngine) calculateVoiceOverage(plan *RatePlan, voiceUsed int64) float64 { + if plan.VoiceAllowance == nil || plan.VoiceAllowance.Unlimited || plan.OverageRates == nil { + return 0 + } + + if voiceUsed <= plan.VoiceAllowance.Minutes { + return 0 + } + + overageMinutes := voiceUsed - plan.VoiceAllowance.Minutes + return float64(overageMinutes) * plan.OverageRates.VoiceRate +} + +func (pe *PricingEngine) calculateSMSOverage(plan *RatePlan, smsUsed int64) float64 { + if plan.SMSAllowance == nil || plan.SMSAllowance.Unlimited || plan.OverageRates == nil { + return 0 + } + + if smsUsed <= plan.SMSAllowance.Messages { + return 0 + } + + overageSMS := smsUsed - plan.SMSAllowance.Messages + return float64(overageSMS) * plan.OverageRates.SMSRate +} + +func (pe *PricingEngine) calculateDiscounts(plan *RatePlan, discountIDs []string, baseCost float64) float64 { + if plan.Discounts == nil || len(discountIDs) == 0 { + return 0 + } + + totalDiscount := 0.0 + for _, discountID := range discountIDs { + for _, discount := range plan.Discounts { + if discount.ID == discountID && discount.IsActive { + if discount.Type == DiscountTypePercentage { + totalDiscount += baseCost * discount.Value / 100 + } else if discount.Type == DiscountTypeFixed { + totalDiscount += discount.Value + } + } + } + } + + return totalDiscount +} + +// Supporting types + +type PriceOptimization struct { + CurrentPrice float64 `json:"current_price"` + MarketAverage float64 `json:"market_average"` + RecommendedPrice float64 `json:"recommended_price"` + PriceDifference float64 `json:"price_difference"` + CompetitorCount int `json:"competitor_count"` + OptimizedAt time.Time `json:"optimized_at"` +} + +type CostBreakdown struct { + RatePlanID string `json:"rate_plan_id"` + Currency string `json:"currency"` + TotalCost float64 `json:"total_cost"` + Subtotal float64 `json:"subtotal"` + DiscountTotal float64 `json:"discount_total"` + BreakdownItems []CostItem `json:"breakdown_items"` + CalculatedAt time.Time `json:"calculated_at"` +} + +type CostItem struct { + Type string `json:"type"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +}