mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-23 21:22:16 +00:00
feat: Add pricing engine with rate plan validation, cost calculation, and price optimization
- Add PricingEngine struct with repository and logger fields - Add ValidateRatePlan with comprehensive validation for fields, dates, allowances, overage rates, discounts, and early termination - Add CalculateOptimalPrice with market analysis and recommended pricing based on competitor data - Add ValidateSubscription with plan availability, validity dates, and discount validation - Add CalculateSubscriptionCost with base
This commit is contained in:
@@ -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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user