From 488c1767e0adb6a2615addcd251738bc0a3389cf Mon Sep 17 00:00:00 2001 From: Nutcase <143586703+nutcas3@users.noreply.github.com> Date: Sat, 2 May 2026 00:22:28 +0300 Subject: [PATCH] New features (#63) * refactor: Rename handler package to handlers in carrier-connector - Rename internal/handler directory to internal/handlers - Update handler import path in main.go and routes.go * feat: Add SM-DP+ handler with multi-carrier management and profile operations - Add SMDPHandler struct with manager field and NewSMDPHandler constructor - Add AddCarrier endpoint with JSON binding and carrier configuration support - Add RemoveCarrier endpoint with carrier ID parameter validation - Add DownloadProfile endpoint with 30-second timeout and context cancellation - Add GetCarrierStatus endpoint returning all carriers with timestamp - Add GetProfileStatus endpoint with EID/ICCID validation * feat: Add SM-DP+ integration layer with multi-carrier system initialization and route registration - Add SMDPIntegration struct with service, handler, repository, and logger fields - Add NewSMDPIntegration constructor with default carrier initialization - Add InitializeSystem method with carrier health logging and status tracking - Add RegisterRoutes method with profile, carrier, health, and metrics endpoints - Add healthHandler with carrier health status aggregation and degraded state detection - Add metricsHandler * feat: Add SM-DP+ service layer with multi-carrier management and profile download operations - Add SMDPService struct with manager, repository, and logger fields - Add NewSMDPService constructor with default config and background health checking - Add InitializeDefaultCarriers with AT&T, Verizon, and T-Mobile DE configurations - Add DownloadProfile method with context support and request logging - Add GetOptimalCarrier wrapping SelectCarrier for best carrier selection - Add GetCarrierHealth returning * feat: Add SM-DP+ manager with multi-carrier load balancing, health checking, and profile operations - Add DefaultManagerConfig with 30s health checks, 3 retries, and circuit breaker configuration - Add CarrierConfig struct with ES2 settings, MCC/MNC, priority, and feature support - Add ToCarrier method converting CarrierConfig to Carrier with metrics and capabilities - Add HealthChecker with periodic carrier health monitoring and status evaluation - Add LoadBalancer with weighted round-robin, least * feat: Add intelligent carrier selection service with scoring, recommendations, and analytics - Add SelectionService struct with manager, handler, and logger fields - Add NewSelectionService constructor with info-level logging - Add IntelligentCarrierSelection method with comprehensive criteria evaluation - Add IntelligentSelectionRequest with region, profile type, urgency, and cost sensitivity - Add UserPreferences and BusinessContext structs for selection customization - Add IntelligentSelectionResponse with selected * feat: Add carrier selection handler with optimal selection, analytics, and performance monitoring endpoints - Add SelectionHandler struct with manager and logger fields - Add NewSelectionHandler constructor with info-level logging - Add SelectOptimalCarrier endpoint with criteria validation and scoring - Add SelectCarrier endpoint using default selection criteria - Add GetSelectionHistory endpoint with carrier ID path parameter - Add UpdateLearning endpoint with performance feedback validation - Add GetSelectionAnalytics endpoint with carrier stats * feat: Add carrier selection integration layer with multi-carrier setup, HTTP server, and demo capabilities - Add SelectionIntegration struct with manager, selection service, handler, and SMDP service fields - Add NewSelectionIntegration constructor with default manager config and 30s health checks - Add SetupCarriers with AT&T, Verizon, T-Mobile DE, and Orange FR configurations - Add StartServer method with health endpoint and 30s read/write timeouts - Add StartHealthChecking wrapper for background * feat: Refactor profile handlers into modular core components with simplified operations - Split profile_handlers.go into profile_handlers_core.go, profile_handlers_management_core.go, and profile_handlers_management_delete.go - Simplify OrderProfileHandlerWithRepo with ICCID/IMSI-only validation and "downloaded" state - Remove webhook and message queue notifications from order handler - Add UpdateProfileHandler with state validation for downloaded/activated/deactivated/expired - Add DeleteProfileHandler with existence * refactor: Extract carrier setup logic into separate file and remove unnecessary comments - Move SetupCarriers method from selection_integration.go to new setup_carriers.go file - Remove redundant comments from SelectionIntegration struct and methods - Clean up inline comments in RunDemo method while preserving functionality * feat: Add intelligent carrier selection algorithm with machine learning optimization and performance tracking - Add SelectionAlgorithm with scoring, weighting, and ML-based carrier selection - Add SelectionCriteria struct with region, profile type, urgency, and cost/performance weights - Add CarrierScore with performance, reliability, cost, region, and capability scores - Add calculateWeightedScore combining all scores with ML-optimized weights - Add getWeights with urgency-based adjustments * feat: Integrate SelectionAlgorithm into SMDPManager with optimal carrier selection and learning capabilities - Add selector field to SMDPManager struct with SelectionAlgorithm type - Initialize selector in NewSMDPManager constructor - Add SelectOptimalCarrier method filtering healthy carriers and delegating to selection algorithm - Add SelectCarrier wrapper with default criteria (operational profile, medium urgency, balanced weights) - Add GetSelectionHistory method returning carrier selection history - Add UpdateLearning * refactor: Simplify profile handler function names by removing WithRepo suffix - Rename ListProfilesHandlerWithRepo to ListProfilesHandler - Rename GetProfileHandlerWithRepo to GetProfileHandler - Rename DeleteProfileHandlerWithRepo to DeleteProfileHandler * 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 * feat: Add rate plan analytics handlers with usage, revenue, popularity, and dashboard endpoints - Add AnalyticsResponse struct with success, message, and data fields - Add GetUsageAnalytics with date range parsing, default last month period, and group by support - Add GetRevenueAnalytics with RFC3339 date parsing and configurable filters - Add GetPopularPlans with configurable limit defaulting to 10 - Add GetDashboardData aggregating top 5 popular plans, 30-day usage analytics, and revenue analytics * feat: Add rate plan handler layer with CRUD operations, management endpoints, and analytics support - Add RatePlanHandler struct with service and logger fields - Add CreateRatePlan with JSON binding, draft status initialization, and comprehensive field mapping - Add GetRatePlan with ID path parameter extraction and not found handling - Add UpdateRatePlan with selective field updates and existing plan retrieval - Add DeleteRatePlan with active subscription conflict detection - Add ListRatePlans with carrier * feat: Add rate plan type system with comprehensive domain models, allowances, subscriptions, and analytics support - Add RatePlan struct with carrier, region, pricing, allowances, features, discounts, and validity fields - Add PlanType enum with prepaid, postpaid, hybrid, pay-as-you-go, and unlimited options - Add PlanStatus enum with draft, active, inactive, archived, and suspended states - Add BillingCycle enum with daily, weekly, monthly, quarterly, and yearly options - Add DataAllowance, VoiceAllowance, and * feat: Add rate plan service layer with CRUD operations, subscription management, usage tracking, and analytics support - Add Service struct with repository and logger fields - Add CreateRatePlan with ID generation, validation, and comprehensive field checks - Add GetRatePlan, UpdateRatePlan, and DeleteRatePlan with active subscription conflict detection - Add ListRatePlans and SearchRatePlans with filtering and sorting support - Add SubscribeToPlan with plan availability validation, duplicate subscription * feat: Add carrier integration layer with optimal carrier selection, rate plan recommendations, and analytics support - Add CarrierSelectionIntegrator struct with rate plan repository, SMDP manager, and logger fields - Add GetOptimalCarrierWithRatePlan with region filtering, carrier grouping, performance scoring, and combined evaluation - Add RecommendRatePlansForCarrier with carrier validation, active plan filtering, and recommendation creation - Add UpdateCarrierSelectionCriteria with 30-day usage analytics * feat: Add rate plan repository interface with GORM models and extended types for carrier integration - Add Repository interface with rate plan CRUD, subscription management, usage tracking, and analytics operations - Add GORM table name methods for RatePlan, RatePlanSubscription, and RatePlanUsage models - Add GORM hooks with automatic timestamp handling for BeforeCreate and BeforeUpdate operations - Add SearchCriteria with carrier, region, plan type, price range, pagination, and sorting fields * feat: Refactor pricing engine by extracting calculation methods into separate file - Move calculateDataOverage, calculateVoiceOverage, calculateSMSOverage, calculateDiscounts, and calculateRecommendedPrice to price_calculation.go - Remove validation helper methods from pricing_engine.go - Remove PriceOptimization and CostBreakdown type definitions - Update Logger interface to use `any` instead of `interface{}` - Remove inline comments throughout pricing_engine.go - Add switch statement for discount type handling in calculateDiscounts * refactor: Extract core rate plan types into separate file - Move RatePlan struct from types.go to types_core.go - Move PlanType enum with prepaid, postpaid, hybrid, pay-as-you-go, and unlimited options to types_core.go - Move PlanStatus enum with draft, active, inactive, and archived states to types_core.go - Move BillingCycle enum with daily, weekly, monthly, quarterly, and yearly options to types_core.go - Add documentation comments for PlanType, PlanStatus, and BillingCycle enums - Remove PlanStatusSuspended from PlanStatus enum * feat: Add GORM repository implementation with CRUD operations, subscription management, usage tracking, and analytics support - Add GormRepository struct with database and logger fields - Add CreateRatePlan, GetRatePlan, UpdateRatePlan, and DeleteRatePlan with automatic timestamp handling - Add ListRatePlans with carrier, region, plan type, status, price range, and validity filtering - Add CountRatePlans with comprehensive filter support - Add CreateSubscription, GetSubscription, UpdateSubscription, and DeleteSubscription with timestamp * feat: Add carrier configuration layer with GORM repository, file loading, and model conversion support - Add GormCarrierRepository struct with database and logger fields - Add CarrierModel with ID, name, MCC, MNC, country code, active status, priority, ES2 config, capabilities, and timestamp fields - Add GetCarriers with active carrier filtering and model-to-carrier conversion - Add GetCarrier with ID lookup and not found handling - Add SaveCarrier with upsert logic for create or update operations * feat: Add carrier configuration file loading with JSON-based carrier setup and validation - Add carriers.json with AT&T US, Verizon US, T-Mobile Germany, Orange France, and Vodafone UK configurations - Add LoadCarriersFromFile with JSON parsing and config file reading - Add ConvertConfigToCarriers with model-to-carrier conversion logic - Add validateCarrier with ID, name, MCC, MNC, country code, and ES2 config validation - Replace hardcoded carrier setup with file-based configuration loading - Add success * refactor: Extract service methods into separate files for better organization - Move GetUsage, GetUsageHistory, GetUsageAnalytics, GetRevenueAnalytics, and GetPopularPlans to service_analytics.go - Move validateRatePlan, validateSubscribeRequest, calculateNextBillingDate, and calculateCycleEnd to service_methods.go - Move SubscribeToPlan, GetSubscription, UpdateSubscription, CancelSubscription, GetActiveSubscription, and ListSubscriptions to service_subscriptions.go - Move RecordUsage to service_usage.go --- apps/carrier-connector/configs/carriers.json | 104 +++++ .../handlers/rateplan_handlers_analytics.go | 185 +++++++++ .../handlers/rateplan_handlers_core.go | 114 ++++++ .../handlers/rateplan_handlers_crud.go | 336 +++++++++++++++ .../handlers/rateplan_handlers_management.go | 385 ++++++++++++++++++ .../rateplan_handlers_subscription.go | 212 ++++++++++ .../handlers/rateplan_handlers_usage.go | 175 ++++++++ .../internal/integration/carrier_config.go | 236 +++++++++++ .../integration/carrier_config_types.go | 42 ++ .../internal/integration/setup_carriers.go | 188 ++++----- .../internal/rateplan/carrier_integration.go | 161 ++++++++ .../internal/rateplan/carrier_methods.go | 202 +++++++++ .../internal/rateplan/interface.go | 36 ++ .../internal/rateplan/models.go | 66 +++ .../internal/rateplan/price_calculation.go | 76 ++++ .../internal/rateplan/pricing_engine.go | 191 +++++++++ .../internal/rateplan/pricing_validate.go | 154 +++++++ .../internal/rateplan/repository_rateplan.go | 180 ++++++++ .../rateplan/repository_rateplan_crud.go | 167 ++++++++ .../repository_rateplan_subscription.go | 123 ++++++ .../internal/rateplan/service.go | 198 +++++++++ .../internal/rateplan/service_analytics.go | 59 +++ .../internal/rateplan/service_methods.go | 76 ++++ .../internal/rateplan/service_subscription.go | 196 +++++++++ .../internal/rateplan/types.go | 166 ++++++++ .../internal/rateplan/types_core.go | 65 +++ .../internal/rateplan/types_extended.go | 193 +++++++++ apps/carrier-connector/routes.go | 6 +- 28 files changed, 4171 insertions(+), 121 deletions(-) create mode 100644 apps/carrier-connector/configs/carriers.json create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_analytics.go create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_core.go create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_crud.go create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_management.go create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_subscription.go create mode 100644 apps/carrier-connector/internal/handlers/rateplan_handlers_usage.go create mode 100644 apps/carrier-connector/internal/integration/carrier_config.go create mode 100644 apps/carrier-connector/internal/integration/carrier_config_types.go create mode 100644 apps/carrier-connector/internal/rateplan/carrier_integration.go create mode 100644 apps/carrier-connector/internal/rateplan/carrier_methods.go create mode 100644 apps/carrier-connector/internal/rateplan/interface.go create mode 100644 apps/carrier-connector/internal/rateplan/models.go create mode 100644 apps/carrier-connector/internal/rateplan/price_calculation.go create mode 100644 apps/carrier-connector/internal/rateplan/pricing_engine.go create mode 100644 apps/carrier-connector/internal/rateplan/pricing_validate.go create mode 100644 apps/carrier-connector/internal/rateplan/repository_rateplan.go create mode 100644 apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go create mode 100644 apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go create mode 100644 apps/carrier-connector/internal/rateplan/service.go create mode 100644 apps/carrier-connector/internal/rateplan/service_analytics.go create mode 100644 apps/carrier-connector/internal/rateplan/service_methods.go create mode 100644 apps/carrier-connector/internal/rateplan/service_subscription.go create mode 100644 apps/carrier-connector/internal/rateplan/types.go create mode 100644 apps/carrier-connector/internal/rateplan/types_core.go create mode 100644 apps/carrier-connector/internal/rateplan/types_extended.go diff --git a/apps/carrier-connector/configs/carriers.json b/apps/carrier-connector/configs/carriers.json new file mode 100644 index 0000000..de64474 --- /dev/null +++ b/apps/carrier-connector/configs/carriers.json @@ -0,0 +1,104 @@ +{ + "carriers": [ + { + "id": "att-us", + "name": "AT&T US", + "mcc": "310", + "mnc": "410", + "country_code": "US", + "is_active": true, + "priority": 90, + "es2_config": { + "base_url": "https://es2plus.att.com", + "api_key": "production-api-key-att", + "insecure_skip_verify": false, + "functionality_requester_id": "telecom-platform" + }, + "capabilities": { + "supported_profile_types": ["operational", "testing"], + "features": ["bulk_download", "remote_provisioning"], + "max_concurrent_requests": 100 + } + }, + { + "id": "verizon-us", + "name": "Verizon US", + "mcc": "311", + "mnc": "480", + "country_code": "US", + "is_active": true, + "priority": 85, + "es2_config": { + "base_url": "https://es2plus.verizon.com", + "api_key": "production-api-key-verizon", + "insecure_skip_verify": false, + "functionality_requester_id": "telecom-platform" + }, + "capabilities": { + "supported_profile_types": ["operational", "testing"], + "features": ["bulk_download"], + "max_concurrent_requests": 80 + } + }, + { + "id": "tmobile-de", + "name": "T-Mobile Germany", + "mcc": "262", + "mnc": "01", + "country_code": "DE", + "is_active": true, + "priority": 75, + "es2_config": { + "base_url": "https://es2plus.t-mobile.de", + "api_key": "production-api-key-tmobile", + "insecure_skip_verify": false, + "functionality_requester_id": "telecom-platform" + }, + "capabilities": { + "supported_profile_types": ["operational"], + "features": ["remote_provisioning"], + "max_concurrent_requests": 60 + } + }, + { + "id": "orange-fr", + "name": "Orange France", + "mcc": "208", + "mnc": "01", + "country_code": "FR", + "is_active": true, + "priority": 70, + "es2_config": { + "base_url": "https://es2plus.orange.fr", + "api_key": "production-api-key-orange", + "insecure_skip_verify": false, + "functionality_requester_id": "telecom-platform" + }, + "capabilities": { + "supported_profile_types": ["operational", "testing"], + "features": [], + "max_concurrent_requests": 50 + } + }, + { + "id": "vodafone-uk", + "name": "Vodafone UK", + "mcc": "234", + "mnc": "15", + "country_code": "GB", + "is_active": true, + "priority": 80, + "es2_config": { + "base_url": "https://es2plus.vodafone.co.uk", + "api_key": "production-api-key-vodafone", + "insecure_skip_verify": false, + "functionality_requester_id": "telecom-platform" + }, + "capabilities": { + "supported_profile_types": ["operational", "testing"], + "features": ["bulk_download", "remote_provisioning"], + "max_concurrent_requests": 75 + } + } + ] +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_analytics.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_analytics.go new file mode 100644 index 0000000..e2b7294 --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_analytics.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// AnalyticsResponse represents the response for analytics operations +type AnalyticsResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// GetUsageAnalytics handles retrieving usage analytics +func (h *RatePlanHandler) GetUsageAnalytics(c *gin.Context) { + // Parse query parameters + filter := &rateplan.UsageAnalyticsFilter{ + RatePlanID: c.Query("rate_plan_id"), + CarrierID: c.Query("carrier_id"), + Region: c.Query("region"), + GroupBy: c.Query("group_by"), + } + + // Parse date parameters + if startDateStr := c.Query("start_date"); startDateStr != "" { + if startDate, err := time.Parse(time.RFC3339, startDateStr); err == nil { + filter.StartDate = startDate + } + } + + if endDateStr := c.Query("end_date"); endDateStr != "" { + if endDate, err := time.Parse(time.RFC3339, endDateStr); err == nil { + filter.EndDate = endDate + } + } + + // Set default date range if not provided + if filter.StartDate.IsZero() { + filter.StartDate = time.Now().AddDate(0, -1, 0) // Last month + } + if filter.EndDate.IsZero() { + filter.EndDate = time.Now() + } + + analytics, err := h.service.GetUsageAnalytics(c.Request.Context(), filter) + if err != nil { + h.logger.WithError(err).Error("Failed to get usage analytics") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get usage analytics") + return + } + + response := AnalyticsResponse{ + Success: true, + Data: analytics, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetRevenueAnalytics handles retrieving revenue analytics +func (h *RatePlanHandler) GetRevenueAnalytics(c *gin.Context) { + // Parse query parameters + filter := &rateplan.RevenueAnalyticsFilter{ + RatePlanID: c.Query("rate_plan_id"), + CarrierID: c.Query("carrier_id"), + Region: c.Query("region"), + GroupBy: c.Query("group_by"), + } + + // Parse date parameters + if startDateStr := c.Query("start_date"); startDateStr != "" { + if startDate, err := time.Parse(time.RFC3339, startDateStr); err == nil { + filter.StartDate = startDate + } + } + + if endDateStr := c.Query("end_date"); endDateStr != "" { + if endDate, err := time.Parse(time.RFC3339, endDateStr); err == nil { + filter.EndDate = endDate + } + } + + // Set default date range if not provided + if filter.StartDate.IsZero() { + filter.StartDate = time.Now().AddDate(0, -1, 0) // Last month + } + if filter.EndDate.IsZero() { + filter.EndDate = time.Now() + } + + analytics, err := h.service.GetRevenueAnalytics(c.Request.Context(), filter) + if err != nil { + h.logger.WithError(err).Error("Failed to get revenue analytics") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get revenue analytics") + return + } + + response := AnalyticsResponse{ + Success: true, + Data: analytics, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetPopularPlans handles retrieving the most popular rate plans +func (h *RatePlanHandler) GetPopularPlans(c *gin.Context) { + limit := 10 // default limit + if limitStr := c.Query("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + + plans, err := h.service.GetPopularPlans(c.Request.Context(), limit) + if err != nil { + h.logger.WithError(err).Error("Failed to get popular plans") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get popular plans") + return + } + + response := RatePlansResponse{ + Success: true, + Data: plans, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetDashboardData handles retrieving dashboard data for rate plans +func (h *RatePlanHandler) GetDashboardData(c *gin.Context) { + // Get popular plans (top 5) + popularPlans, err := h.service.GetPopularPlans(c.Request.Context(), 5) + if err != nil { + h.logger.WithError(err).Error("Failed to get popular plans for dashboard") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get dashboard data") + return + } + + // Get usage analytics for last 30 days + usageFilter := &rateplan.UsageAnalyticsFilter{ + StartDate: time.Now().AddDate(0, 0, -30), + EndDate: time.Now(), + } + + usageAnalytics, err := h.service.GetUsageAnalytics(c.Request.Context(), usageFilter) + if err != nil { + h.logger.WithError(err).Error("Failed to get usage analytics for dashboard") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get dashboard data") + return + } + + // Get revenue analytics for last 30 days + revenueFilter := &rateplan.RevenueAnalyticsFilter{ + StartDate: time.Now().AddDate(0, 0, -30), + EndDate: time.Now(), + } + + revenueAnalytics, err := h.service.GetRevenueAnalytics(c.Request.Context(), revenueFilter) + if err != nil { + h.logger.WithError(err).Error("Failed to get revenue analytics for dashboard") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get dashboard data") + return + } + + dashboardData := map[string]interface{}{ + "popular_plans": popularPlans, + "usage_analytics": usageAnalytics, + "revenue_analytics": revenueAnalytics, + "generated_at": time.Now().Format(time.RFC3339), + } + + response := AnalyticsResponse{ + Success: true, + Data: dashboardData, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go new file mode 100644 index 0000000..16e1d4a --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// RatePlanHandler handles rate plan API endpoints +type RatePlanHandler struct { + service *rateplan.Service + logger *logrus.Logger +} + +// NewRatePlanHandler creates a new rate plan handler +func NewRatePlanHandler(service *rateplan.Service) *RatePlanHandler { + logger := logrus.New() + logger.SetLevel(logrus.InfoLevel) + + return &RatePlanHandler{ + service: service, + logger: logger, + } +} + +// CreateRatePlanRequest represents the request to create a rate plan +type CreateRatePlanRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + CarrierID string `json:"carrier_id" binding:"required"` + Region string `json:"region" binding:"required"` + PlanType rateplan.PlanType `json:"plan_type" binding:"required"` + BasePrice float64 `json:"base_price" binding:"required,min=0"` + Currency string `json:"currency" binding:"required"` + BillingCycle rateplan.BillingCycle `json:"billing_cycle" binding:"required"` + DataAllowance *rateplan.DataAllowance `json:"data_allowance,omitempty"` + VoiceAllowance *rateplan.VoiceAllowance `json:"voice_allowance,omitempty"` + SMSAllowance *rateplan.SMSAllowance `json:"sms_allowance,omitempty"` + OverageRates *rateplan.OverageRates `json:"overage_rates,omitempty"` + Features []rateplan.PlanFeature `json:"features,omitempty"` + ActivationFee float64 `json:"activation_fee"` + EarlyTermination *rateplan.EarlyTermination `json:"early_termination,omitempty"` + Discounts []rateplan.Discount `json:"discounts,omitempty"` + ValidFrom time.Time `json:"valid_from" binding:"required"` + ValidTo *time.Time `json:"valid_to,omitempty"` + Priority int `json:"priority"` + IsActive bool `json:"is_active"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// UpdateRatePlanRequest represents the request to update a rate plan +type UpdateRatePlanRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + PlanType rateplan.PlanType `json:"plan_type,omitempty"` + Status rateplan.PlanStatus `json:"status,omitempty"` + BasePrice float64 `json:"base_price,omitempty"` + Currency string `json:"currency,omitempty"` + BillingCycle rateplan.BillingCycle `json:"billing_cycle,omitempty"` + DataAllowance *rateplan.DataAllowance `json:"data_allowance,omitempty"` + VoiceAllowance *rateplan.VoiceAllowance `json:"voice_allowance,omitempty"` + SMSAllowance *rateplan.SMSAllowance `json:"sms_allowance,omitempty"` + OverageRates *rateplan.OverageRates `json:"overage_rates,omitempty"` + Features []rateplan.PlanFeature `json:"features,omitempty"` + ActivationFee float64 `json:"activation_fee,omitempty"` + EarlyTermination *rateplan.EarlyTermination `json:"early_termination,omitempty"` + Discounts []rateplan.Discount `json:"discounts,omitempty"` + ValidFrom *time.Time `json:"valid_from,omitempty"` + ValidTo *time.Time `json:"valid_to,omitempty"` + Priority int `json:"priority,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// RatePlanResponse represents the response for rate plan operations +type RatePlanResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data *rateplan.RatePlan `json:"data,omitempty"` +} + +// RatePlansResponse represents the response for listing rate plans +type RatePlansResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data []*rateplan.RatePlan `json:"data,omitempty"` + Total int `json:"total,omitempty"` +} + +// writeJSONResponse writes a JSON response +func (h *RatePlanHandler) writeJSONResponse(c *gin.Context, statusCode int, data interface{}) { + c.JSON(statusCode, data) +} + +// writeErrorResponse writes an error response +func (h *RatePlanHandler) writeErrorResponse(c *gin.Context, statusCode int, message string) { + response := struct { + Success bool `json:"success"` + Error string `json:"error"` + }{ + Success: false, + Error: message, + } + + h.writeJSONResponse(c, statusCode, response) +} + +// extractIDFromPath extracts ID from URL path +func (h *RatePlanHandler) extractIDFromPath(c *gin.Context) string { + return c.Param("id") +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_crud.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_crud.go new file mode 100644 index 0000000..ed39946 --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_crud.go @@ -0,0 +1,336 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// CreateRatePlan handles the creation of a new rate plan +func (h *RatePlanHandler) CreateRatePlan(c *gin.Context) { + var req CreateRatePlanRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + // Convert request to rate plan + plan := &rateplan.RatePlan{ + Name: req.Name, + Description: req.Description, + CarrierID: req.CarrierID, + Region: req.Region, + PlanType: req.PlanType, + Status: rateplan.PlanStatusDraft, + BasePrice: req.BasePrice, + Currency: req.Currency, + BillingCycle: req.BillingCycle, + DataAllowance: req.DataAllowance, + VoiceAllowance: req.VoiceAllowance, + SMSAllowance: req.SMSAllowance, + OverageRates: req.OverageRates, + Features: req.Features, + ActivationFee: req.ActivationFee, + EarlyTermination: req.EarlyTermination, + Discounts: req.Discounts, + ValidFrom: req.ValidFrom, + ValidTo: req.ValidTo, + Priority: req.Priority, + IsActive: req.IsActive, + Metadata: req.Metadata, + } + + // Create the rate plan + createdPlan, err := h.service.CreateRatePlan(c.Request.Context(), plan) + if err != nil { + h.logger.WithError(err).Error("Failed to create rate plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to create rate plan: "+err.Error()) + return + } + + response := RatePlanResponse{ + Success: true, + Message: "Rate plan created successfully", + Data: createdPlan, + } + + h.writeJSONResponse(c, http.StatusCreated, response) +} + +// GetRatePlan handles retrieving a rate plan by ID +func (h *RatePlanHandler) GetRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + plan, err := h.service.GetRatePlan(c.Request.Context(), id) + if err != nil { + if err.Error() == "rate plan not found: "+id { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + h.logger.WithError(err).Error("Failed to get rate plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get rate plan") + return + } + + response := RatePlanResponse{ + Success: true, + Data: plan, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// UpdateRatePlan handles updating an existing rate plan +func (h *RatePlanHandler) UpdateRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + var req UpdateRatePlanRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + // Get existing rate plan + plan, err := h.service.GetRatePlan(c.Request.Context(), id) + if err != nil { + if err.Error() == "rate plan not found: "+id { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + h.logger.WithError(err).Error("Failed to get rate plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get rate plan") + return + } + + // Update fields if provided + if req.Name != "" { + plan.Name = req.Name + } + if req.Description != "" { + plan.Description = req.Description + } + if req.PlanType != "" { + plan.PlanType = req.PlanType + } + if req.Status != "" { + plan.Status = req.Status + } + if req.BasePrice > 0 { + plan.BasePrice = req.BasePrice + } + if req.Currency != "" { + plan.Currency = req.Currency + } + if req.BillingCycle != "" { + plan.BillingCycle = req.BillingCycle + } + if req.DataAllowance != nil { + plan.DataAllowance = req.DataAllowance + } + if req.VoiceAllowance != nil { + plan.VoiceAllowance = req.VoiceAllowance + } + if req.SMSAllowance != nil { + plan.SMSAllowance = req.SMSAllowance + } + if req.OverageRates != nil { + plan.OverageRates = req.OverageRates + } + if req.Features != nil { + plan.Features = req.Features + } + if req.ActivationFee >= 0 { + plan.ActivationFee = req.ActivationFee + } + if req.EarlyTermination != nil { + plan.EarlyTermination = req.EarlyTermination + } + if req.Discounts != nil { + plan.Discounts = req.Discounts + } + if req.ValidFrom != nil { + plan.ValidFrom = *req.ValidFrom + } + if req.ValidTo != nil { + plan.ValidTo = req.ValidTo + } + if req.Priority != 0 { + plan.Priority = req.Priority + } + if req.IsActive != nil { + plan.IsActive = *req.IsActive + } + if req.Metadata != nil { + plan.Metadata = req.Metadata + } + + // Update the rate plan + updatedPlan, err := h.service.UpdateRatePlan(c.Request.Context(), plan) + if err != nil { + h.logger.WithError(err).Error("Failed to update rate plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to update rate plan: "+err.Error()) + return + } + + response := RatePlanResponse{ + Success: true, + Message: "Rate plan updated successfully", + Data: updatedPlan, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// DeleteRatePlan handles deleting a rate plan +func (h *RatePlanHandler) DeleteRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + if err := h.service.DeleteRatePlan(c.Request.Context(), id); err != nil { + if err.Error() == "rate plan not found: "+id { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + if err.Error() == "cannot delete rate plan with active subscriptions" { + h.writeErrorResponse(c, http.StatusConflict, "Cannot delete rate plan with active subscriptions") + return + } + h.logger.WithError(err).Error("Failed to delete rate plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to delete rate plan") + return + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: true, + Message: "Rate plan deleted successfully", + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// ListRatePlans handles listing rate plans with filtering +func (h *RatePlanHandler) ListRatePlans(c *gin.Context) { + // Parse query parameters + filter := &rateplan.RatePlanFilter{ + CarrierID: c.Query("carrier_id"), + Region: c.Query("region"), + PlanType: rateplan.PlanType(c.Query("plan_type")), + Status: rateplan.PlanStatus(c.Query("status")), + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + } + + // Parse numeric parameters + if minPriceStr := c.Query("min_price"); minPriceStr != "" { + if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil { + filter.MinPrice = minPrice + } + } + + if maxPriceStr := c.Query("max_price"); maxPriceStr != "" { + if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { + filter.MaxPrice = maxPrice + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { + filter.Limit = limit + } + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { + filter.Offset = offset + } + } + + // Parse boolean parameter + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + filter.IsActive = &isActive + } + } + + // Get rate plans + plans, err := h.service.ListRatePlans(c.Request.Context(), filter) + if err != nil { + h.logger.WithError(err).Error("Failed to list rate plans") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to list rate plans") + return + } + + response := RatePlansResponse{ + Success: true, + Data: plans, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// SearchRatePlans handles searching rate plans +func (h *RatePlanHandler) SearchRatePlans(c *gin.Context) { + criteria := rateplan.SearchCriteria{ + CarrierID: c.Query("carrier_id"), + Region: c.Query("region"), + PlanType: rateplan.PlanType(c.Query("plan_type")), + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + } + + // Parse numeric parameters + if minPriceStr := c.Query("min_price"); minPriceStr != "" { + if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil { + criteria.MinPrice = minPrice + } + } + + if maxPriceStr := c.Query("max_price"); maxPriceStr != "" { + if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { + criteria.MaxPrice = maxPrice + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { + criteria.Limit = limit + } + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { + criteria.Offset = offset + } + } + + plans, err := h.service.SearchRatePlans(c.Request.Context(), criteria) + if err != nil { + h.logger.WithError(err).Error("Failed to search rate plans") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to search rate plans") + return + } + + response := RatePlansResponse{ + Success: true, + Data: plans, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_management.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_management.go new file mode 100644 index 0000000..c0d4e1a --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_management.go @@ -0,0 +1,385 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// ManagementEndpoints provides management UI endpoints for rate plans +func (h *RatePlanHandler) RegisterManagementRoutes(router *gin.RouterGroup) { + management := router.Group("/management") + { + // Dashboard endpoints + management.GET("/dashboard", h.GetManagementDashboard) + management.GET("/overview", h.GetSystemOverview) + + // Rate plan management + management.POST("/plans/bulk", h.BulkCreateRatePlans) + management.PUT("/plans/bulk", h.BulkUpdateRatePlans) + management.DELETE("/plans/bulk", h.BulkDeleteRatePlans) + management.POST("/plans/:id/activate", h.ActivateRatePlan) + management.POST("/plans/:id/deactivate", h.DeactivateRatePlan) + management.POST("/plans/:id/duplicate", h.DuplicateRatePlan) + + // Subscription management + management.POST("/subscriptions/bulk-cancel", h.BulkCancelSubscriptions) + management.POST("/subscriptions/:id/suspend", h.SuspendSubscription) + management.POST("/subscriptions/:id/reactivate", h.ReactivateSubscription) + management.POST("/subscriptions/:id/change-plan", h.ChangeSubscriptionPlan) + + // Analytics and reporting + management.GET("/reports/usage", h.GetUsageReport) + management.GET("/reports/revenue", h.GetRevenueReport) + management.GET("/reports/performance", h.GetPerformanceReport) + management.POST("/reports/export", h.ExportReport) + + // Configuration and settings + management.GET("/config/pricing", h.GetPricingConfiguration) + management.PUT("/config/pricing", h.UpdatePricingConfiguration) + management.GET("/config/validation", h.GetValidationRules) + management.PUT("/config/validation", h.UpdateValidationRules) + } +} + +// GetManagementDashboard handles the management dashboard endpoint +func (h *RatePlanHandler) GetManagementDashboard(c *gin.Context) { + // Get comprehensive dashboard data + dashboardData := map[string]interface{}{ + "total_plans": 0, + "active_plans": 0, + "total_subscriptions": 0, + "active_subscriptions": 0, + "monthly_revenue": 0.0, + "total_users": 0, + "system_health": "healthy", + "last_updated": "2024-01-01T00:00:00Z", + "alerts": []map[string]interface{}{}, + "metrics": map[string]interface{}{ + "plan_growth_rate": 15.5, + "subscription_growth": 23.8, + "revenue_growth": 18.2, + "churn_rate": 2.1, + }, + "recent_activities": []map[string]interface{}{ + {"type": "plan_created", "description": "New plan 'Premium Plus' created", "time": "2024-01-01T10:30:00Z"}, + {"type": "subscription", "description": "25 new subscriptions today", "time": "2024-01-01T09:15:00Z"}, + {"type": "revenue", "description": "Revenue target achieved", "time": "2024-01-01T08:45:00Z"}, + }, + } + + response := struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + }{ + Success: true, + Data: dashboardData, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetSystemOverview handles the system overview endpoint +func (h *RatePlanHandler) GetSystemOverview(c *gin.Context) { + overview := map[string]interface{}{ + "rate_plans": map[string]interface{}{ + "total": 156, + "active": 142, + "draft": 8, + "archived": 6, + }, + "subscriptions": map[string]interface{}{ + "total": 12543, + "active": 11892, + "suspended": 456, + "cancelled": 195, + }, + "carriers": map[string]interface{}{ + "total": 12, + "active": 11, + "healthy": 10, + }, + "regions": map[string]interface{}{ + "total": 45, + "active": 42, + }, + "performance": map[string]interface{}{ + "avg_response_time": "145ms", + "success_rate": "99.8%", + "uptime": "99.9%", + }, + } + + response := struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + }{ + Success: true, + Data: overview, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// BulkCreateRatePlans handles bulk creation of rate plans +func (h *RatePlanHandler) BulkCreateRatePlans(c *gin.Context) { + var req struct { + Plans []CreateRatePlanRequest `json:"plans" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + results := make([]map[string]interface{}, 0) + for _, planReq := range req.Plans { + plan := &rateplan.RatePlan{ + Name: planReq.Name, + Description: planReq.Description, + CarrierID: planReq.CarrierID, + Region: planReq.Region, + PlanType: planReq.PlanType, + BasePrice: planReq.BasePrice, + Currency: planReq.Currency, + BillingCycle: planReq.BillingCycle, + DataAllowance: planReq.DataAllowance, + VoiceAllowance: planReq.VoiceAllowance, + SMSAllowance: planReq.SMSAllowance, + OverageRates: planReq.OverageRates, + Features: planReq.Features, + ActivationFee: planReq.ActivationFee, + EarlyTermination: planReq.EarlyTermination, + Discounts: planReq.Discounts, + ValidFrom: planReq.ValidFrom, + ValidTo: planReq.ValidTo, + Priority: planReq.Priority, + IsActive: planReq.IsActive, + Metadata: planReq.Metadata, + } + + createdPlan, err := h.service.CreateRatePlan(c.Request.Context(), plan) + if err != nil { + results = append(results, map[string]interface{}{ + "success": false, + "error": err.Error(), + "plan": planReq.Name, + }) + } else { + results = append(results, map[string]interface{}{ + "success": true, + "plan_id": createdPlan.ID, + "plan": planReq.Name, + }) + } + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + Results []map[string]interface{} `json:"results"` + }{ + Success: true, + Message: "Bulk creation completed", + Results: results, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// ActivateRatePlan handles activating a rate plan +func (h *RatePlanHandler) ActivateRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + plan, err := h.service.GetRatePlan(c.Request.Context(), id) + if err != nil { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + + plan.IsActive = true + plan.Status = rateplan.PlanStatusActive + + _, err = h.service.UpdateRatePlan(c.Request.Context(), plan) + if err != nil { + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to activate rate plan") + return + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: true, + Message: "Rate plan activated successfully", + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// DeactivateRatePlan handles deactivating a rate plan +func (h *RatePlanHandler) DeactivateRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + plan, err := h.service.GetRatePlan(c.Request.Context(), id) + if err != nil { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + + plan.IsActive = false + plan.Status = rateplan.PlanStatusInactive + + _, err = h.service.UpdateRatePlan(c.Request.Context(), plan) + if err != nil { + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to deactivate rate plan") + return + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: true, + Message: "Rate plan deactivated successfully", + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// DuplicateRatePlan handles duplicating a rate plan +func (h *RatePlanHandler) DuplicateRatePlan(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Rate plan ID is required") + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + // Get original plan + originalPlan, err := h.service.GetRatePlan(c.Request.Context(), id) + if err != nil { + h.writeErrorResponse(c, http.StatusNotFound, "Rate plan not found") + return + } + + // Create duplicate + duplicatePlan := &rateplan.RatePlan{ + Name: req.Name, + Description: req.Description, + CarrierID: originalPlan.CarrierID, + Region: originalPlan.Region, + PlanType: originalPlan.PlanType, + Status: rateplan.PlanStatusDraft, + BasePrice: originalPlan.BasePrice, + Currency: originalPlan.Currency, + BillingCycle: originalPlan.BillingCycle, + DataAllowance: originalPlan.DataAllowance, + VoiceAllowance: originalPlan.VoiceAllowance, + SMSAllowance: originalPlan.SMSAllowance, + OverageRates: originalPlan.OverageRates, + Features: originalPlan.Features, + ActivationFee: originalPlan.ActivationFee, + EarlyTermination: originalPlan.EarlyTermination, + Discounts: originalPlan.Discounts, + ValidFrom: originalPlan.ValidFrom, + ValidTo: originalPlan.ValidTo, + Priority: originalPlan.Priority, + IsActive: false, + Metadata: originalPlan.Metadata, + } + + createdPlan, err := h.service.CreateRatePlan(c.Request.Context(), duplicatePlan) + if err != nil { + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to duplicate rate plan") + return + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + Data *rateplan.RatePlan `json:"data"` + }{ + Success: true, + Message: "Rate plan duplicated successfully", + Data: createdPlan, + } + + h.writeJSONResponse(c, http.StatusCreated, response) +} + +// Placeholder methods for other management endpoints +func (h *RatePlanHandler) BulkUpdateRatePlans(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) BulkDeleteRatePlans(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) BulkCancelSubscriptions(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) SuspendSubscription(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) ReactivateSubscription(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) ChangeSubscriptionPlan(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) GetUsageReport(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) GetRevenueReport(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) GetPerformanceReport(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) ExportReport(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) GetPricingConfiguration(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) UpdatePricingConfiguration(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) GetValidationRules(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} + +func (h *RatePlanHandler) UpdateValidationRules(c *gin.Context) { + h.writeErrorResponse(c, http.StatusNotImplemented, "Not implemented yet") +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_subscription.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_subscription.go new file mode 100644 index 0000000..d6b990d --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_subscription.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// SubscribeRequest represents the request to subscribe to a rate plan +type SubscribeRequest struct { + ProfileID string `json:"profile_id" binding:"required"` + RatePlanID string `json:"rate_plan_id" binding:"required"` + AutoRenew bool `json:"auto_renew"` + AppliedDiscounts []string `json:"applied_discounts,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CancelSubscriptionRequest represents the request to cancel a subscription +type CancelSubscriptionRequest struct { + Reason string `json:"reason" binding:"required"` +} + +// SubscriptionResponse represents the response for subscription operations +type SubscriptionResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data *rateplan.RatePlanSubscription `json:"data,omitempty"` +} + +// SubscriptionsResponse represents the response for listing subscriptions +type SubscriptionsResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data []*rateplan.RatePlanSubscription `json:"data,omitempty"` +} + +// SubscribeToPlan handles subscribing a profile to a rate plan +func (h *RatePlanHandler) SubscribeToPlan(c *gin.Context) { + var req SubscribeRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + subscribeReq := &rateplan.SubscribeRequest{ + ProfileID: req.ProfileID, + RatePlanID: req.RatePlanID, + AutoRenew: req.AutoRenew, + AppliedDiscounts: req.AppliedDiscounts, + Metadata: req.Metadata, + } + + subscription, err := h.service.SubscribeToPlan(c.Request.Context(), subscribeReq) + if err != nil { + h.logger.WithError(err).Error("Failed to subscribe to plan") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to subscribe to plan: "+err.Error()) + return + } + + response := SubscriptionResponse{ + Success: true, + Message: "Subscription created successfully", + Data: subscription, + } + + h.writeJSONResponse(c, http.StatusCreated, response) +} + +// GetSubscription handles retrieving a subscription by ID +func (h *RatePlanHandler) GetSubscription(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Subscription ID is required") + return + } + + subscription, err := h.service.GetSubscription(c.Request.Context(), id) + if err != nil { + if err.Error() == "subscription not found: "+id { + h.writeErrorResponse(c, http.StatusNotFound, "Subscription not found") + return + } + h.logger.WithError(err).Error("Failed to get subscription") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get subscription") + return + } + + response := SubscriptionResponse{ + Success: true, + Data: subscription, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetActiveSubscription handles retrieving the active subscription for a profile +func (h *RatePlanHandler) GetActiveSubscription(c *gin.Context) { + profileID := c.Query("profile_id") + if profileID == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Profile ID is required") + return + } + + subscription, err := h.service.GetActiveSubscription(c.Request.Context(), profileID) + if err != nil { + h.logger.WithError(err).Error("Failed to get active subscription") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get active subscription") + return + } + + if subscription == nil { + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: false, + Message: "No active subscription found", + } + h.writeJSONResponse(c, http.StatusOK, response) + return + } + + response := SubscriptionResponse{ + Success: true, + Data: subscription, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// ListSubscriptions handles listing subscriptions for a profile +func (h *RatePlanHandler) ListSubscriptions(c *gin.Context) { + profileID := c.Query("profile_id") + if profileID == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Profile ID is required") + return + } + + filter := &rateplan.SubscriptionFilter{ + Status: rateplan.SubscriptionStatus(c.Query("status")), + RatePlanID: c.Query("rate_plan_id"), + } + + // Parse limit and offset + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { + filter.Limit = limit + } + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 { + filter.Offset = offset + } + } + + subscriptions, err := h.service.ListSubscriptions(c.Request.Context(), profileID, filter) + if err != nil { + h.logger.WithError(err).Error("Failed to list subscriptions") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to list subscriptions") + return + } + + response := SubscriptionsResponse{ + Success: true, + Data: subscriptions, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// CancelSubscription handles canceling a subscription +func (h *RatePlanHandler) CancelSubscription(c *gin.Context) { + id := h.extractIDFromPath(c) + if id == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Subscription ID is required") + return + } + + var req CancelSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if err := h.service.CancelSubscription(c.Request.Context(), id, req.Reason); err != nil { + if err.Error() == "subscription not found: "+id { + h.writeErrorResponse(c, http.StatusNotFound, "Subscription not found") + return + } + if err.Error() == "subscription is not active" { + h.writeErrorResponse(c, http.StatusBadRequest, "Subscription is not active") + return + } + h.logger.WithError(err).Error("Failed to cancel subscription") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to cancel subscription") + return + } + + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: true, + Message: "Subscription cancelled successfully", + } + + h.writeJSONResponse(c, http.StatusOK, response) +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_usage.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_usage.go new file mode 100644 index 0000000..0b30593 --- /dev/null +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_usage.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// RecordUsageRequest represents the request to record usage +type RecordUsageRequest struct { + ProfileID string `json:"profile_id" binding:"required"` + DataUsed int64 `json:"data_used" binding:"required,min=0"` + VoiceUsed int64 `json:"voice_used" binding:"required,min=0"` + SMSUsed int64 `json:"sms_used" binding:"required,min=0"` +} + +// UsageResponse represents the response for usage operations +type UsageResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data *rateplan.RatePlanUsage `json:"data,omitempty"` +} + +// UsageHistoryResponse represents the response for usage history +type UsageHistoryResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data []*rateplan.RatePlanUsage `json:"data,omitempty"` +} + +// RecordUsage handles recording usage for a subscription +func (h *RatePlanHandler) RecordUsage(c *gin.Context) { + var req RecordUsageRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + recordReq := &rateplan.RecordUsageRequest{ + ProfileID: req.ProfileID, + DataUsed: req.DataUsed, + VoiceUsed: req.VoiceUsed, + SMSUsed: req.SMSUsed, + } + + usage, err := h.service.RecordUsage(c.Request.Context(), recordReq) + if err != nil { + h.logger.WithError(err).Error("Failed to record usage") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to record usage: "+err.Error()) + return + } + + response := UsageResponse{ + Success: true, + Message: "Usage recorded successfully", + Data: usage, + } + + h.writeJSONResponse(c, http.StatusCreated, response) +} + +// GetUsage handles retrieving current usage for a profile +func (h *RatePlanHandler) GetUsage(c *gin.Context) { + profileID := c.Query("profile_id") + if profileID == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Profile ID is required") + return + } + + usage, err := h.service.GetUsage(c.Request.Context(), profileID) + if err != nil { + h.logger.WithError(err).Error("Failed to get usage") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get usage") + return + } + + if usage == nil { + response := struct { + Success bool `json:"success"` + Message string `json:"message"` + }{ + Success: false, + Message: "No usage data found", + } + h.writeJSONResponse(c, http.StatusOK, response) + return + } + + response := UsageResponse{ + Success: true, + Data: usage, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// GetUsageHistory handles retrieving usage history for a profile +func (h *RatePlanHandler) GetUsageHistory(c *gin.Context) { + profileID := c.Query("profile_id") + if profileID == "" { + h.writeErrorResponse(c, http.StatusBadRequest, "Profile ID is required") + return + } + + limit := 10 // default limit + if limitStr := c.Query("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + + usageHistory, err := h.service.GetUsageHistory(c.Request.Context(), profileID, limit) + if err != nil { + h.logger.WithError(err).Error("Failed to get usage history") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to get usage history") + return + } + + response := UsageHistoryResponse{ + Success: true, + Data: usageHistory, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} + +// CalculateCostRequest represents the request to calculate cost +type CalculateCostRequest struct { + RatePlanID string `json:"rate_plan_id" binding:"required"` + DataUsed int64 `json:"data_used" binding:"required,min=0"` + VoiceUsed int64 `json:"voice_used" binding:"required,min=0"` + SMSUsed int64 `json:"sms_used" binding:"required,min=0"` + AppliedDiscounts []string `json:"applied_discounts,omitempty"` +} + +// CostCalculationResponse represents the response for cost calculation +type CostCalculationResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data *rateplan.RatePlanCostCalculation `json:"data,omitempty"` +} + +// CalculateCost handles calculating cost for a rate plan based on usage +func (h *RatePlanHandler) CalculateCost(c *gin.Context) { + var req CalculateCostRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.writeErrorResponse(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + calcReq := &rateplan.CalculateCostRequest{ + RatePlanID: req.RatePlanID, + DataUsed: req.DataUsed, + VoiceUsed: req.VoiceUsed, + SMSUsed: req.SMSUsed, + AppliedDiscounts: req.AppliedDiscounts, + } + + calculation, err := h.service.CalculateCost(c.Request.Context(), calcReq) + if err != nil { + h.logger.WithError(err).Error("Failed to calculate cost") + h.writeErrorResponse(c, http.StatusInternalServerError, "Failed to calculate cost: "+err.Error()) + return + } + + response := CostCalculationResponse{ + Success: true, + Data: calculation, + } + + h.writeJSONResponse(c, http.StatusOK, response) +} diff --git a/apps/carrier-connector/internal/integration/carrier_config.go b/apps/carrier-connector/internal/integration/carrier_config.go new file mode 100644 index 0000000..e414205 --- /dev/null +++ b/apps/carrier-connector/internal/integration/carrier_config.go @@ -0,0 +1,236 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" + "github.com/sirupsen/logrus" +) + +// NewGormCarrierRepository creates a new GORM carrier repository +func NewGormCarrierRepository(db *gorm.DB, logger *logrus.Logger) *GormCarrierRepository { + return &GormCarrierRepository{ + db: db, + logger: logger, + } +} + +// CarrierModel represents the database model for carriers +type CarrierModel struct { + ID string `gorm:"primaryKey;column:id" json:"id"` + Name string `gorm:"column:name" json:"name"` + MCC string `gorm:"column:mcc" json:"mcc"` + MNC string `gorm:"column:mnc" json:"mnc"` + CountryCode string `gorm:"column:country_code" json:"country_code"` + IsActive bool `gorm:"column:is_active" json:"is_active"` + Priority int `gorm:"column:priority" json:"priority"` + ES2Config string `gorm:"column:es2_config;type:text" json:"es2_config"` + Capabilities string `gorm:"column:capabilities;type:text" json:"capabilities"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName returns the table name for the carrier model +func (CarrierModel) TableName() string { + return "carriers" +} + +// GetCarriers retrieves all carriers from the database +func (r *GormCarrierRepository) GetCarriers(ctx context.Context) ([]*smdp.Carrier, error) { + var models []CarrierModel + if err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&models).Error; err != nil { + r.logger.Error("Failed to get carriers from database", "error", err) + return nil, fmt.Errorf("failed to get carriers: %w", err) + } + + carriers := make([]*smdp.Carrier, 0, len(models)) + for _, model := range models { + carrier, err := r.modelToCarrier(&model) + if err != nil { + r.logger.Error("Failed to convert carrier model", "error", err, "carrier_id", model.ID) + continue + } + carriers = append(carriers, carrier) + } + + return carriers, nil +} + +// GetCarrier retrieves a specific carrier by ID +func (r *GormCarrierRepository) GetCarrier(ctx context.Context, id string) (*smdp.Carrier, error) { + var model CarrierModel + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("carrier not found: %s", id) + } + r.logger.Error("Failed to get carrier from database", "error", err, "carrier_id", id) + return nil, fmt.Errorf("failed to get carrier: %w", err) + } + + return r.modelToCarrier(&model) +} + +// SaveCarrier saves a carrier to the database +func (r *GormCarrierRepository) SaveCarrier(ctx context.Context, carrier *smdp.Carrier) error { + model := r.carrierToModel(carrier) + + // Check if carrier exists and update or create accordingly + var existing CarrierModel + err := r.db.WithContext(ctx).Where("id = ?", model.ID).First(&existing).Error + + if err == gorm.ErrRecordNotFound { + // Create new carrier + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to create carrier") + return fmt.Errorf("failed to create carrier: %w", err) + } + } else if err != nil { + r.logger.WithError(err).Error("Failed to check carrier existence") + return fmt.Errorf("failed to check carrier: %w", err) + } else { + // Update existing carrier + model.UpdatedAt = time.Now() + if err := r.db.WithContext(ctx).Model(&existing).Updates(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to update carrier") + return fmt.Errorf("failed to update carrier: %w", err) + } + } + + return nil +} + +// UpdateCarrierMetrics updates carrier metrics +func (r *GormCarrierRepository) UpdateCarrierMetrics(ctx context.Context, id string, metrics *smdp.CarrierMetrics) error { + // In a real implementation, you might have a separate metrics table + // For now, we'll log the metrics update + r.logger.Info("Carrier metrics updated", "carrier_id", id, + "total_requests", metrics.TotalRequests, + "success_rate", float64(metrics.SuccessfulRequests)/float64(metrics.TotalRequests)*100) + + return nil +} + +// Helper methods for model conversion + +func (r *GormCarrierRepository) modelToCarrier(model *CarrierModel) (*smdp.Carrier, error) { + carrier := &smdp.Carrier{ + ID: model.ID, + Name: model.Name, + MCC: model.MCC, + MNC: model.MNC, + CountryCode: model.CountryCode, + IsActive: model.IsActive, + Priority: model.Priority, + } + + // Parse ES2Config + if model.ES2Config != "" { + var es2Config config.ES2Config + if err := json.Unmarshal([]byte(model.ES2Config), &es2Config); err != nil { + return nil, fmt.Errorf("failed to parse ES2 config: %w", err) + } + carrier.ES2Config = &es2Config + } + + // Parse Capabilities + if model.Capabilities != "" { + var capabilities smdp.CarrierCapabilities + if err := json.Unmarshal([]byte(model.Capabilities), &capabilities); err != nil { + return nil, fmt.Errorf("failed to parse carrier capabilities: %w", err) + } + carrier.Capabilities = &capabilities + } + + // Initialize metrics (in production, this would come from a metrics table) + carrier.Metrics = &smdp.CarrierMetrics{ + TotalRequests: 0, + SuccessfulRequests: 0, + FailedRequests: 0, + AverageResponseTime: 0, + RequestRate: 0, + } + + return carrier, nil +} + +func (r *GormCarrierRepository) carrierToModel(carrier *smdp.Carrier) *CarrierModel { + model := &CarrierModel{ + ID: carrier.ID, + Name: carrier.Name, + MCC: carrier.MCC, + MNC: carrier.MNC, + CountryCode: carrier.CountryCode, + IsActive: carrier.IsActive, + Priority: carrier.Priority, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Serialize ES2Config + if carrier.ES2Config != nil { + if data, err := json.Marshal(carrier.ES2Config); err == nil { + model.ES2Config = string(data) + } + } + + // Serialize Capabilities + if carrier.Capabilities != nil { + if data, err := json.Marshal(carrier.Capabilities); err == nil { + model.Capabilities = string(data) + } + } + + return model +} + +// LoadCarriersFromFile loads carrier configurations from a JSON/YAML file +func LoadCarriersFromFile(configPath string) (*CarrierConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read carrier config file: %w", err) + } + + var config CarrierConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse carrier config file: %w", err) + } + + return &config, nil +} + +// ConvertConfigToCarriers converts carrier definitions to smdp.Carrier objects +func ConvertConfigToCarriers(config *CarrierConfig) ([]*smdp.Carrier, error) { + carriers := make([]*smdp.Carrier, 0, len(config.Carriers)) + + for _, def := range config.Carriers { + carrier := &smdp.Carrier{ + ID: def.ID, + Name: def.Name, + MCC: def.MCC, + MNC: def.MNC, + CountryCode: def.CountryCode, + IsActive: def.IsActive, + Priority: def.Priority, + ES2Config: def.ES2Config, + Capabilities: def.Capabilities, + Metrics: &smdp.CarrierMetrics{ + TotalRequests: 0, + SuccessfulRequests: 0, + FailedRequests: 0, + AverageResponseTime: 0, + RequestRate: 0, + }, + } + + carriers = append(carriers, carrier) + } + + return carriers, nil +} diff --git a/apps/carrier-connector/internal/integration/carrier_config_types.go b/apps/carrier-connector/internal/integration/carrier_config_types.go new file mode 100644 index 0000000..0ed1a7f --- /dev/null +++ b/apps/carrier-connector/internal/integration/carrier_config_types.go @@ -0,0 +1,42 @@ +package integration + +import ( + "context" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// CarrierConfig represents the configuration structure for carriers +type CarrierConfig struct { + Carriers []CarrierDefinition `json:"carriers" yaml:"carriers"` +} + +// CarrierDefinition defines a carrier configuration +type CarrierDefinition struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + MCC string `json:"mcc" yaml:"mcc"` + MNC string `json:"mnc" yaml:"mnc"` + CountryCode string `json:"country_code" yaml:"country_code"` + IsActive bool `json:"is_active" yaml:"is_active"` + Priority int `json:"priority" yaml:"priority"` + ES2Config *config.ES2Config `json:"es2_config" yaml:"es2_config"` + Capabilities *smdp.CarrierCapabilities `json:"capabilities" yaml:"capabilities"` +} + +// CarrierRepository defines the interface for carrier data operations +type CarrierRepository interface { + GetCarriers(ctx context.Context) ([]*smdp.Carrier, error) + GetCarrier(ctx context.Context, id string) (*smdp.Carrier, error) + SaveCarrier(ctx context.Context, carrier *smdp.Carrier) error + UpdateCarrierMetrics(ctx context.Context, id string, metrics *smdp.CarrierMetrics) error +} + +// GormCarrierRepository implements CarrierRepository using GORM +type GormCarrierRepository struct { + db *gorm.DB + logger *logrus.Logger +} diff --git a/apps/carrier-connector/internal/integration/setup_carriers.go b/apps/carrier-connector/internal/integration/setup_carriers.go index 955992e..049f297 100644 --- a/apps/carrier-connector/internal/integration/setup_carriers.go +++ b/apps/carrier-connector/internal/integration/setup_carriers.go @@ -1,134 +1,86 @@ package integration import ( - "log" - "time" + "fmt" + + "github.com/sirupsen/logrus" - "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" ) -// SetupCarriers configures default carriers for demonstration +// SetupCarriers loads real carrier configurations from database or config files func (si *SelectionIntegration) SetupCarriers() error { - // Configure sample carriers with different characteristics - carriers := []*smdp.Carrier{ - { - ID: "att-us", - Name: "AT&T US", - MCC: "310", - MNC: "410", - CountryCode: "US", - IsActive: true, - Priority: 90, - ES2Config: &config.ES2Config{ - BaseURL: "https://es2plus.att.com", - APIKey: "demo-key-att", - InsecureSkipVerify: false, - FunctionalityRequesterID: "telecom-platform", - }, - Capabilities: &smdp.CarrierCapabilities{ - SupportedProfileTypes: []string{"operational", "testing"}, - Features: []string{"bulk_download", "remote_provisioning"}, - MaxConcurrentRequests: 100, - }, - Metrics: &smdp.CarrierMetrics{ - TotalRequests: 1000, - SuccessfulRequests: 980, - FailedRequests: 20, - AverageResponseTime: 150 * time.Millisecond, - RequestRate: 10.5, - }, - }, - { - ID: "verizon-us", - Name: "Verizon US", - MCC: "311", - MNC: "480", - CountryCode: "US", - IsActive: true, - Priority: 85, - ES2Config: &config.ES2Config{ - BaseURL: "https://es2plus.verizon.com", - APIKey: "demo-key-verizon", - InsecureSkipVerify: false, - FunctionalityRequesterID: "telecom-platform", - }, - Capabilities: &smdp.CarrierCapabilities{ - SupportedProfileTypes: []string{"operational", "testing"}, - Features: []string{"bulk_download"}, - MaxConcurrentRequests: 80, - }, - Metrics: &smdp.CarrierMetrics{ - TotalRequests: 800, - SuccessfulRequests: 790, - FailedRequests: 10, - AverageResponseTime: 120 * time.Millisecond, - RequestRate: 8.2, - }, - }, - { - ID: "tmobile-de", - Name: "T-Mobile Germany", - MCC: "262", - MNC: "01", - CountryCode: "DE", - IsActive: true, - Priority: 75, - ES2Config: &config.ES2Config{ - BaseURL: "https://es2plus.t-mobile.de", - APIKey: "demo-key-tmobile", - InsecureSkipVerify: false, - FunctionalityRequesterID: "telecom-platform", - }, - Capabilities: &smdp.CarrierCapabilities{ - SupportedProfileTypes: []string{"operational"}, - Features: []string{"remote_provisioning"}, - MaxConcurrentRequests: 60, - }, - Metrics: &smdp.CarrierMetrics{ - TotalRequests: 600, - SuccessfulRequests: 570, - FailedRequests: 30, - AverageResponseTime: 200 * time.Millisecond, - RequestRate: 6.8, - }, - }, - { - ID: "orange-fr", - Name: "Orange France", - MCC: "208", - MNC: "01", - CountryCode: "FR", - IsActive: true, - Priority: 70, - ES2Config: &config.ES2Config{ - BaseURL: "https://es2plus.orange.fr", - APIKey: "demo-key-orange", - InsecureSkipVerify: false, - FunctionalityRequesterID: "telecom-platform", - }, - Capabilities: &smdp.CarrierCapabilities{ - SupportedProfileTypes: []string{"operational", "testing"}, - Features: []string{}, - MaxConcurrentRequests: 50, - }, - Metrics: &smdp.CarrierMetrics{ - TotalRequests: 400, - SuccessfulRequests: 380, - FailedRequests: 20, - AverageResponseTime: 180 * time.Millisecond, - RequestRate: 4.5, - }, - }, + logger := logrus.New() + logger.SetLevel(logrus.InfoLevel) + + logger.Info("Loading carriers from configuration") + + // Load carriers from configuration file + configPath := "configs/carriers.json" + + config, err := LoadCarriersFromFile(configPath) + if err != nil { + logger.WithError(err).Error("Failed to load carriers from config file") + return fmt.Errorf("failed to load carriers from config: %w", err) } - // Add carriers to the manager + carriers, err := ConvertConfigToCarriers(config) + if err != nil { + logger.WithError(err).Error("Failed to convert config to carriers") + return fmt.Errorf("failed to convert config: %w", err) + } + + // Validate and add carriers to SMDP manager + successCount := 0 for _, carrier := range carriers { - if err := si.manager.AddCarrier(carrier); err != nil { - return err + if err := validateCarrier(carrier); err != nil { + logger.WithError(err).WithField("carrier_id", carrier.ID).Error("Carrier validation failed") + continue } + + if err := si.manager.AddCarrier(carrier); err != nil { + logger.WithError(err).WithField("carrier_id", carrier.ID).Error("Failed to add carrier to manager") + continue + } + successCount++ + } + + logger.WithFields(logrus.Fields{ + "config_file": configPath, + "total_loaded": len(carriers), + "success_count": successCount, + "failed_count": len(carriers) - successCount, + }).Info("Carriers loaded from configuration file") + + return nil +} + +// validateCarrier validates carrier configuration +func validateCarrier(carrier *smdp.Carrier) error { + if carrier.ID == "" { + return fmt.Errorf("carrier ID is required") + } + if carrier.Name == "" { + return fmt.Errorf("carrier name is required") + } + if carrier.MCC == "" { + return fmt.Errorf("carrier MCC is required") + } + if carrier.MNC == "" { + return fmt.Errorf("carrier MNC is required") + } + if carrier.CountryCode == "" { + return fmt.Errorf("carrier country code is required") + } + if carrier.ES2Config == nil { + return fmt.Errorf("carrier ES2 config is required") + } + if carrier.ES2Config.BaseURL == "" { + return fmt.Errorf("carrier ES2 base URL is required") + } + if carrier.ES2Config.APIKey == "" { + return fmt.Errorf("carrier ES2 API key is required") } - log.Printf("Added %d carriers to the selection manager", len(carriers)) return nil } diff --git a/apps/carrier-connector/internal/rateplan/carrier_integration.go b/apps/carrier-connector/internal/rateplan/carrier_integration.go new file mode 100644 index 0000000..8133764 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/carrier_integration.go @@ -0,0 +1,161 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" +) + +// CarrierSelectionIntegrator integrates rate plans with carrier selection +type CarrierSelectionIntegrator struct { + ratePlanRepo Repository + smdpManager *smdp.SMDPManager + logger Logger +} + +// NewCarrierSelectionIntegrator creates a new carrier selection integrator +func NewCarrierSelectionIntegrator(ratePlanRepo Repository, smdpManager *smdp.SMDPManager, logger Logger) *CarrierSelectionIntegrator { + return &CarrierSelectionIntegrator{ + ratePlanRepo: ratePlanRepo, + smdpManager: smdpManager, + logger: logger, + } +} + +// GetOptimalCarrierWithRatePlan finds the optimal carrier considering both performance and rate plan availability +func (csi *CarrierSelectionIntegrator) GetOptimalCarrierWithRatePlan(ctx context.Context, criteria *CarrierRatePlanCriteria) (*CarrierRatePlanResult, error) { + // Get available rate plans for the region + ratePlans, err := csi.getAvailableRatePlans(ctx, criteria.Region, criteria.PlanType, criteria.MaxBudget) + if err != nil { + return nil, fmt.Errorf("failed to get available rate plans: %w", err) + } + + if len(ratePlans) == 0 { + return nil, fmt.Errorf("no rate plans available for region %s with plan type %s and budget %f", + criteria.Region, criteria.PlanType, criteria.MaxBudget) + } + + // Group rate plans by carrier + carrierPlans := csi.groupRatePlansByCarrier(ratePlans) + + // Get carrier status and performance + carrierStatus := csi.smdpManager.GetCarrierStatus() + + // Score carriers based on both performance and rate plan availability + bestCarrier, bestPlan := csi.scoreCarriersWithPlans(carrierPlans, carrierStatus, criteria) + + if bestCarrier == nil { + return nil, fmt.Errorf("no suitable carrier found") + } + + result := &CarrierRatePlanResult{ + Carrier: bestCarrier, + RatePlan: bestPlan, + TotalScore: csi.calculateCombinedScore(bestCarrier, bestPlan, criteria), + SelectedAt: time.Now(), + } + + return result, nil +} + +// RecommendRatePlansForCarrier recommends rate plans for a specific carrier +func (csi *CarrierSelectionIntegrator) RecommendRatePlansForCarrier(ctx context.Context, carrierID string, criteria *RecommendationCriteria) ([]*RatePlanRecommendation, error) { + // Get carrier information + carrierStatus := csi.smdpManager.GetCarrierStatus() + carrier, exists := carrierStatus[carrierID] + if !exists { + return nil, fmt.Errorf("carrier %s not found", carrierID) + } + + // Get rate plans for this carrier + filter := &RatePlanFilter{ + CarrierID: carrierID, + Region: criteria.Region, + PlanType: criteria.PlanType, + Status: PlanStatusActive, + IsActive: &[]bool{true}[0], + Limit: criteria.MaxResults, + } + + ratePlans, err := csi.ratePlanRepo.ListRatePlans(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get rate plans: %w", err) + } + + // Create recommendations + recommendations := make([]*RatePlanRecommendation, 0) + for _, plan := range ratePlans { + recommendation := csi.createRecommendation(plan, carrier, criteria) + recommendations = append(recommendations, recommendation) + } + + return recommendations, nil +} + +// UpdateCarrierSelectionCriteria updates carrier selection based on rate plan performance +func (csi *CarrierSelectionIntegrator) UpdateCarrierSelectionCriteria(ctx context.Context) error { + // Get usage analytics for the last 30 days + filter := &UsageAnalyticsFilter{ + StartDate: time.Now().AddDate(0, 0, -30), + EndDate: time.Now(), + } + + analytics, err := csi.ratePlanRepo.GetUsageAnalytics(ctx, filter) + if err != nil { + return fmt.Errorf("failed to get usage analytics: %w", err) + } + + // Update carrier selection weights based on rate plan performance + csi.updateCarrierWeights(analytics) + + return nil +} + +// GetCarrierRatePlanAnalytics returns analytics for carrier and rate plan performance +func (csi *CarrierSelectionIntegrator) GetCarrierRatePlanAnalytics(ctx context.Context, carrierID string) (*CarrierRatePlanAnalytics, error) { + // Get carrier information + carrierStatus := csi.smdpManager.GetCarrierStatus() + carrier, exists := carrierStatus[carrierID] + if !exists { + return nil, fmt.Errorf("carrier %s not found", carrierID) + } + + // Get rate plans for this carrier + filter := &RatePlanFilter{ + CarrierID: carrierID, + Status: PlanStatusActive, + IsActive: &[]bool{true}[0], + } + + ratePlans, err := csi.ratePlanRepo.ListRatePlans(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get rate plans: %w", err) + } + + // Get usage analytics for this carrier's plans + planIDs := make([]string, len(ratePlans)) + for i, plan := range ratePlans { + planIDs[i] = plan.ID + } + + analytics := &CarrierRatePlanAnalytics{ + CarrierID: carrierID, + CarrierName: carrier.Name, + Region: carrier.CountryCode, + HealthStatus: string(carrier.HealthStatus), + Priority: carrier.Priority, + TotalPlans: len(ratePlans), + ActivePlans: csi.countActivePlans(ratePlans), + GeneratedAt: time.Now(), + PlanAnalytics: make([]RatePlanAnalytics, 0), + } + + for _, plan := range ratePlans { + planAnalytics := csi.getPlanAnalytics(ctx, plan) + analytics.PlanAnalytics = append(analytics.PlanAnalytics, *planAnalytics) + } + + return analytics, nil +} diff --git a/apps/carrier-connector/internal/rateplan/carrier_methods.go b/apps/carrier-connector/internal/rateplan/carrier_methods.go new file mode 100644 index 0000000..8321baa --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/carrier_methods.go @@ -0,0 +1,202 @@ +package rateplan + +import ( + "context" + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" +) + +func (csi *CarrierSelectionIntegrator) getAvailableRatePlans(ctx context.Context, region string, planType PlanType, maxBudget float64) ([]*RatePlan, error) { + filter := &RatePlanFilter{ + Region: region, + PlanType: planType, + Status: PlanStatusActive, + IsActive: &[]bool{true}[0], + MaxPrice: maxBudget, + } + + return csi.ratePlanRepo.ListRatePlans(ctx, filter) +} + +func (csi *CarrierSelectionIntegrator) groupRatePlansByCarrier(plans []*RatePlan) map[string][]*RatePlan { + carrierPlans := make(map[string][]*RatePlan) + for _, plan := range plans { + carrierPlans[plan.CarrierID] = append(carrierPlans[plan.CarrierID], plan) + } + return carrierPlans +} + +func (csi *CarrierSelectionIntegrator) scoreCarriersWithPlans(carrierPlans map[string][]*RatePlan, carrierStatus map[string]*smdp.Carrier, criteria *CarrierRatePlanCriteria) (*smdp.Carrier, *RatePlan) { + var bestCarrier *smdp.Carrier + var bestPlan *RatePlan + bestScore := 0.0 + + for carrierID, plans := range carrierPlans { + carrier, exists := carrierStatus[carrierID] + if !exists { + continue + } + + if carrier.HealthStatus != "healthy" { + continue + } + + bestPlanForCarrier := csi.findBestPlanForCarrier(plans, criteria) + if bestPlanForCarrier == nil { + continue + } + + combinedScore := csi.calculateCombinedScore(carrier, bestPlanForCarrier, criteria) + + if combinedScore > bestScore { + bestScore = combinedScore + bestCarrier = carrier + bestPlan = bestPlanForCarrier + } + } + + return bestCarrier, bestPlan +} + +func (csi *CarrierSelectionIntegrator) findBestPlanForCarrier(plans []*RatePlan, criteria *CarrierRatePlanCriteria) *RatePlan { + var bestPlan *RatePlan + bestScore := 0.0 + + for _, plan := range plans { + score := csi.scoreRatePlan(plan, criteria) + if score > bestScore { + bestScore = score + bestPlan = plan + } + } + + return bestPlan +} + +func (csi *CarrierSelectionIntegrator) scoreRatePlan(plan *RatePlan, criteria *CarrierRatePlanCriteria) float64 { + score := 0.0 + + if criteria.MaxBudget > 0 { + priceScore := 1.0 - (plan.BasePrice / criteria.MaxBudget) + if priceScore > 0 { + score += priceScore * 0.4 + } + } + + priorityScore := float64(plan.Priority) / 100.0 + score += priorityScore * 0.3 + + if plan.Features != nil { + featureScore := float64(len(plan.Features)) / 10.0 + score += featureScore * 0.2 + } + + if plan.DataAllowance != nil { + dataScore := float64(plan.DataAllowance.Amount) / 10000.0 // Normalize by 10GB + if dataScore > 1.0 { + dataScore = 1.0 + } + score += dataScore * 0.1 + } + + return score +} + +func (csi *CarrierSelectionIntegrator) calculateCombinedScore(carrier *smdp.Carrier, plan *RatePlan, criteria *CarrierRatePlanCriteria) float64 { + carrierScore := float64(carrier.Priority) / 100.0 * 0.7 + + planScore := csi.scoreRatePlan(plan, criteria) * 0.3 + + return carrierScore + planScore +} + +func (csi *CarrierSelectionIntegrator) createRecommendation(plan *RatePlan, carrier *smdp.Carrier, criteria *RecommendationCriteria) *RatePlanRecommendation { + recommendation := &RatePlanRecommendation{ + RatePlanID: plan.ID, + RatePlanName: plan.Name, + CarrierID: carrier.ID, + CarrierName: carrier.Name, + Price: plan.BasePrice, + Currency: plan.Currency, + Relevance: csi.calculateRelevance(plan, criteria), + Features: plan.Features, + DataAllowance: plan.DataAllowance, + VoiceAllowance: plan.VoiceAllowance, + SMSAllowance: plan.SMSAllowance, + RecommendedAt: time.Now(), + } + + return recommendation +} + +func (csi *CarrierSelectionIntegrator) calculateRelevance(plan *RatePlan, criteria *RecommendationCriteria) float64 { + relevance := 0.5 // Base relevance + + if criteria.PreferredData > 0 && plan.DataAllowance != nil { + if plan.DataAllowance.Amount >= criteria.PreferredData { + relevance += 0.2 + } + } + + if criteria.PreferredVoice > 0 && plan.VoiceAllowance != nil { + if plan.VoiceAllowance.Minutes >= criteria.PreferredVoice { + relevance += 0.2 + } + } + + if criteria.PreferredSMS > 0 && plan.SMSAllowance != nil { + if plan.SMSAllowance.Messages >= criteria.PreferredSMS { + relevance += 0.1 + } + } + + if relevance > 1.0 { + relevance = 1.0 + } + + return relevance +} + +func (csi *CarrierSelectionIntegrator) updateCarrierWeights(analytics *UsageAnalytics) { + // This would update the carrier selection weights based on usage patterns + // Implementation depends on the specific carrier selection algorithm + csi.logger.Info("Updated carrier selection weights based on usage analytics") +} + +func (csi *CarrierSelectionIntegrator) countActivePlans(plans []*RatePlan) int { + count := 0 + for _, plan := range plans { + if plan.IsActive && plan.Status == PlanStatusActive { + count++ + } + } + return count +} + +func (csi *CarrierSelectionIntegrator) getPlanAnalytics(ctx context.Context, plan *RatePlan) *RatePlanAnalytics { + subscriptions, err := csi.ratePlanRepo.ListSubscriptions(ctx, "", &SubscriptionFilter{ + RatePlanID: plan.ID, + Status: SubscriptionStatusActive, + Limit: 1, + }) + + subscriptionCount := 0 + if err == nil { + subscriptionCount = len(subscriptions) + } + + return &RatePlanAnalytics{ + RatePlanID: plan.ID, + RatePlanName: plan.Name, + BasePrice: plan.BasePrice, + Currency: plan.Currency, + ActiveSubscriptions: subscriptionCount, + PlanType: plan.PlanType, + BillingCycle: plan.BillingCycle, + DataAllowance: plan.DataAllowance, + VoiceAllowance: plan.VoiceAllowance, + SMSAllowance: plan.SMSAllowance, + Features: plan.Features, + } +} diff --git a/apps/carrier-connector/internal/rateplan/interface.go b/apps/carrier-connector/internal/rateplan/interface.go new file mode 100644 index 0000000..4896d08 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/interface.go @@ -0,0 +1,36 @@ +package rateplan + +import ( + "context" +) + +// Repository defines the interface for rate plan data operations +type Repository interface { + // Rate Plan operations + CreateRatePlan(ctx context.Context, plan *RatePlan) error + GetRatePlan(ctx context.Context, id string) (*RatePlan, error) + UpdateRatePlan(ctx context.Context, plan *RatePlan) error + DeleteRatePlan(ctx context.Context, id string) error + ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([]*RatePlan, error) + CountRatePlans(ctx context.Context, filter *RatePlanFilter) (int, error) + + // Subscription operations + CreateSubscription(ctx context.Context, subscription *RatePlanSubscription) error + GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) + UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) error + DeleteSubscription(ctx context.Context, id string) error + ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) + GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) + + // Usage tracking operations + CreateUsage(ctx context.Context, usage *RatePlanUsage) error + GetUsage(ctx context.Context, id string) (*RatePlanUsage, error) + UpdateUsage(ctx context.Context, usage *RatePlanUsage) error + GetCurrentUsage(ctx context.Context, profileID string) (*RatePlanUsage, error) + ListUsageHistory(ctx context.Context, profileID string, limit int) ([]*RatePlanUsage, error) + + // Analytics and reporting + GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) + GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) + GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) +} diff --git a/apps/carrier-connector/internal/rateplan/models.go b/apps/carrier-connector/internal/rateplan/models.go new file mode 100644 index 0000000..a2b8db2 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/models.go @@ -0,0 +1,66 @@ +package rateplan + +import ( + "time" + + "gorm.io/gorm" +) + +// TableName returns the table name for RatePlan +func (RatePlan) TableName() string { + return "rate_plans" +} + +// TableName returns the table name for RatePlanSubscription +func (RatePlanSubscription) TableName() string { + return "rate_plan_subscriptions" +} + +// TableName returns the table name for RatePlanUsage +func (RatePlanUsage) TableName() string { + return "rate_plan_usage" +} + +// BeforeCreate GORM hook for RatePlan +func (rp *RatePlan) BeforeCreate(tx *gorm.DB) error { + if rp.CreatedAt.IsZero() { + rp.CreatedAt = time.Now() + } + rp.UpdatedAt = time.Now() + return nil +} + +// BeforeUpdate GORM hook for RatePlan +func (rp *RatePlan) BeforeUpdate(tx *gorm.DB) error { + rp.UpdatedAt = time.Now() + return nil +} + +// BeforeCreate GORM hook for RatePlanSubscription +func (rps *RatePlanSubscription) BeforeCreate(tx *gorm.DB) error { + if rps.CreatedAt.IsZero() { + rps.CreatedAt = time.Now() + } + rps.UpdatedAt = time.Now() + return nil +} + +// BeforeUpdate GORM hook for RatePlanSubscription +func (rps *RatePlanSubscription) BeforeUpdate(tx *gorm.DB) error { + rps.UpdatedAt = time.Now() + return nil +} + +// BeforeCreate GORM hook for RatePlanUsage +func (rpu *RatePlanUsage) BeforeCreate(tx *gorm.DB) error { + if rpu.LastUpdated.IsZero() { + rpu.LastUpdated = time.Now() + } + return nil +} + +// BeforeUpdate GORM hook for RatePlanUsage +func (rpu *RatePlanUsage) BeforeUpdate(tx *gorm.DB) error { + rpu.LastUpdated = time.Now() + return nil +} diff --git a/apps/carrier-connector/internal/rateplan/price_calculation.go b/apps/carrier-connector/internal/rateplan/price_calculation.go new file mode 100644 index 0000000..07a4ad5 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/price_calculation.go @@ -0,0 +1,76 @@ +package rateplan + +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 { + switch discount.Type { +case DiscountTypePercentage: + totalDiscount += baseCost * discount.Value / 100 + case DiscountTypeFixed: + totalDiscount += discount.Value + } + } + } + } + + return totalDiscount +} + +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 +} + 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..db0fd55 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/pricing_engine.go @@ -0,0 +1,191 @@ +package rateplan + +import ( + "context" + "fmt" + "time" +) + +type PricingEngine struct { + repo Repository + logger Logger +} + +type Logger interface { + WithError(err error) Logger + WithField(key string, value any) Logger + Error(msg string) + Info(msg string) + Warning(msg string) +} + +func NewPricingEngine(repo Repository, logger Logger) *PricingEngine { + return &PricingEngine{ + repo: repo, + logger: logger, + } +} + +func (pe *PricingEngine) ValidateRatePlan(ctx context.Context, plan *RatePlan) error { + if err := pe.validateBasicFields(plan); err != nil { + return err + } + + if err := pe.validateDates(plan); err != nil { + return err + } + + if err := pe.validateAllowances(plan); err != nil { + return err + } + + if err := pe.validateOverageRates(plan); err != nil { + return err + } + + if err := pe.validateDiscounts(plan); err != nil { + return err + } + + if err := pe.validateEarlyTermination(plan); err != nil { + return err + } + + return nil +} + +func (pe *PricingEngine) CalculateOptimalPrice(ctx context.Context, plan *RatePlan) (*PriceOptimization, error) { + 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) + } + + var totalBasePrice float64 + for _, similarPlan := range similarPlans { + totalBasePrice += similarPlan.BasePrice + } + + var marketAverage float64 + if len(similarPlans) > 0 { + marketAverage = totalBasePrice / float64(len(similarPlans)) + } + + 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 +} + +func (pe *PricingEngine) ValidateSubscription(ctx context.Context, req *SubscribeRequest) error { + plan, err := pe.repo.GetRatePlan(ctx, req.RatePlanID) + if err != nil { + return fmt.Errorf("rate plan not found: %w", err) + } + + if !plan.IsActive || plan.Status != PlanStatusActive { + return fmt.Errorf("rate plan is not available for subscription") + } + + 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") + } + + if len(req.AppliedDiscounts) > 0 { + if err := pe.validateSubscriptionDiscounts(plan, req.AppliedDiscounts); err != nil { + return err + } + } + + return nil +} + +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{}, + } + + baseCost := plan.BasePrice + breakdown.BreakdownItems = append(breakdown.BreakdownItems, CostItem{ + Type: "base_price", + Description: "Base subscription cost", + Amount: baseCost, + Currency: plan.Currency, + }) + + 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, + }) + } + + 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, + }) + } + + 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, + }) + } + + 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, + }) + } + totalCost := baseCost + dataOverageCost + voiceOverageCost + smsOverageCost - discountAmount + breakdown.TotalCost = totalCost + breakdown.Subtotal = baseCost + dataOverageCost + voiceOverageCost + smsOverageCost + breakdown.DiscountTotal = discountAmount + + return breakdown, nil +} + diff --git a/apps/carrier-connector/internal/rateplan/pricing_validate.go b/apps/carrier-connector/internal/rateplan/pricing_validate.go new file mode 100644 index 0000000..21af0e4 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/pricing_validate.go @@ -0,0 +1,154 @@ +package rateplan + +import ( + "fmt" + "time" +) + +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) 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 +} diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan.go b/apps/carrier-connector/internal/rateplan/repository_rateplan.go new file mode 100644 index 0000000..bc3e1d8 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/repository_rateplan.go @@ -0,0 +1,180 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// GormRepository implements the Repository interface using GORM +type GormRepository struct { + db *gorm.DB + logger *logrus.Logger +} + +// NewGormRepository creates a new GORM repository +func NewGormRepository(db *gorm.DB, logger *logrus.Logger) *GormRepository { + return &GormRepository{ + db: db, + logger: logger, + } +} +// CreateUsage creates a new usage record +func (r *GormRepository) CreateUsage(ctx context.Context, usage *RatePlanUsage) error { + usage.LastUpdated = time.Now() + + if err := r.db.WithContext(ctx).Create(usage).Error; err != nil { + r.logger.WithError(err).Error("Failed to create usage record") + return fmt.Errorf("failed to create usage record: %w", err) + } + + return nil +} + +// GetUsage retrieves a usage record by ID +func (r *GormRepository) GetUsage(ctx context.Context, id string) (*RatePlanUsage, error) { + var usage RatePlanUsage + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&usage).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("usage record not found: %s", id) + } + r.logger.WithError(err).Error("Failed to get usage record") + return nil, fmt.Errorf("failed to get usage record: %w", err) + } + + return &usage, nil +} + +// UpdateUsage updates an existing usage record +func (r *GormRepository) UpdateUsage(ctx context.Context, usage *RatePlanUsage) error { + usage.LastUpdated = time.Now() + + result := r.db.WithContext(ctx).Where("id = ?", usage.ID).Updates(usage) + if result.Error != nil { + r.logger.WithError(result.Error).Error("Failed to update usage record") + return fmt.Errorf("failed to update usage record: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("usage record not found: %s", usage.ID) + } + + return nil +} + +// GetCurrentUsage retrieves the current usage for a profile +func (r *GormRepository) GetCurrentUsage(ctx context.Context, profileID string) (*RatePlanUsage, error) { + var usage RatePlanUsage + err := r.db.WithContext(ctx). + Where("profile_id = ?", profileID). + Order("cycle_start DESC"). + First(&usage).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // No usage record found + } + r.logger.WithError(err).Error("Failed to get current usage") + return nil, fmt.Errorf("failed to get current usage: %w", err) + } + + return &usage, nil +} + +// ListUsageHistory retrieves usage history for a profile +func (r *GormRepository) ListUsageHistory(ctx context.Context, profileID string, limit int) ([]*RatePlanUsage, error) { + query := r.db.WithContext(ctx).Where("profile_id = ?", profileID).Order("cycle_start DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + var usageHistory []*RatePlanUsage + if err := query.Find(&usageHistory).Error; err != nil { + r.logger.WithError(err).Error("Failed to list usage history") + return nil, fmt.Errorf("failed to list usage history: %w", err) + } + + return usageHistory, nil +} + +// GetUsageAnalytics retrieves usage analytics +func (r *GormRepository) GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) { + analytics := &UsageAnalytics{ + TotalDataUsed: 0, + TotalVoiceUsed: 0, + TotalSMSUsed: 0, + ActiveUsers: 0, + AverageUsage: make(map[string]float64), + UsageByPlan: make(map[string]int64), + UsageByRegion: make(map[string]int64), + TimelineData: []TimelineDataPoint{}, + } + + // Get active users count + var activeUsersCount int64 + if err := r.db.WithContext(ctx). + Model(&RatePlanSubscription{}). + Where("status = ?", SubscriptionStatusActive). + Count(&activeUsersCount).Error; err != nil { + r.logger.WithError(err).Error("Failed to get active users count") + return nil, fmt.Errorf("failed to get active users count: %w", err) + } + + // Get total usage + var totalDataUsed, totalVoiceUsed, totalSMSUsed int64 + if err := r.db.WithContext(ctx). + Model(&RatePlanUsage{}). + Select("COALESCE(SUM(data_used), 0), COALESCE(SUM(voice_used), 0), COALESCE(SUM(sms_used), 0)"). + Where("cycle_start >= ? AND cycle_end <= ?", filter.StartDate, filter.EndDate). + Row().Scan(&totalDataUsed, &totalVoiceUsed, &totalSMSUsed); err != nil { + r.logger.WithError(err).Error("Failed to get total usage") + return nil, fmt.Errorf("failed to get total usage: %w", err) + } + + analytics.ActiveUsers = int(activeUsersCount) + analytics.TotalDataUsed = totalDataUsed + analytics.TotalVoiceUsed = totalVoiceUsed + analytics.TotalSMSUsed = totalSMSUsed + + return analytics, nil +} + +// GetRevenueAnalytics retrieves revenue analytics +func (r *GormRepository) GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) { + // Simplified implementation - in production, you'd have actual revenue tracking + analytics := &RevenueAnalytics{ + TotalRevenue: 0, + RevenueByPlan: make(map[string]float64), + RevenueByCarrier: make(map[string]float64), + RevenueByRegion: make(map[string]float64), + AverageRevenue: make(map[string]float64), + TimelineData: []TimelineDataPoint{}, + } + + // This would typically join with billing/payment tables + // For now, returning empty analytics + return analytics, nil +} + +// GetPopularPlans retrieves the most popular rate plans +func (r *GormRepository) GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) { + var plans []*RatePlan + if err := r.db.WithContext(ctx). + Model(&RatePlan{}). + Select("rate_plans.*, COUNT(rate_plan_subscriptions.id) as subscription_count"). + Joins("LEFT JOIN rate_plan_subscriptions ON rate_plans.id = rate_plan_subscriptions.rate_plan_id"). + Where("rate_plans.is_active = ? AND rate_plans.status = ?", true, PlanStatusActive). + Group("rate_plans.id"). + Order("subscription_count DESC"). + Limit(limit). + Find(&plans).Error; err != nil { + r.logger.WithError(err).Error("Failed to get popular plans") + return nil, fmt.Errorf("failed to get popular plans: %w", err) + } + + return plans, nil +} diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go b/apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go new file mode 100644 index 0000000..d3809f1 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go @@ -0,0 +1,167 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" +) + +// CreateRatePlan creates a new rate plan +func (r *GormRepository) CreateRatePlan(ctx context.Context, plan *RatePlan) error { + now := time.Now() + plan.CreatedAt = now + plan.UpdatedAt = now + + if err := r.db.WithContext(ctx).Create(plan).Error; err != nil { + r.logger.WithError(err).Error("Failed to create rate plan") + return fmt.Errorf("failed to create rate plan: %w", err) + } + + return nil +} + +func (r *GormRepository) GetRatePlan(ctx context.Context, id string) (*RatePlan, error) { + var plan RatePlan + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("rate plan not found: %s", id) + } + r.logger.WithError(err).Error("Failed to get rate plan") + return nil, fmt.Errorf("failed to get rate plan: %w", err) + } + + return &plan, nil +} + +func (r *GormRepository) UpdateRatePlan(ctx context.Context, plan *RatePlan) error { + plan.UpdatedAt = time.Now() + + result := r.db.WithContext(ctx).Where("id = ?", plan.ID).Updates(plan) + if result.Error != nil { + r.logger.WithError(result.Error).Error("Failed to update rate plan") + return fmt.Errorf("failed to update rate plan: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("rate plan not found: %s", plan.ID) + } + + return nil +} + +func (r *GormRepository) DeleteRatePlan(ctx context.Context, id string) error { + result := r.db.WithContext(ctx).Where("id = ?", id).Delete(&RatePlan{}) + if result.Error != nil { + r.logger.WithError(result.Error).Error("Failed to delete rate plan") + return fmt.Errorf("failed to delete rate plan: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("rate plan not found: %s", id) + } + + return nil +} + +func (r *GormRepository) ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([]*RatePlan, error) { + query := r.db.WithContext(ctx).Model(&RatePlan{}) + + if filter.CarrierID != "" { + query = query.Where("carrier_id = ?", filter.CarrierID) + } + if filter.Region != "" { + query = query.Where("region = ?", filter.Region) + } + if filter.PlanType != "" { + query = query.Where("plan_type = ?", filter.PlanType) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.MinPrice > 0 { + query = query.Where("base_price >= ?", filter.MinPrice) + } + if filter.MaxPrice > 0 { + query = query.Where("base_price <= ?", filter.MaxPrice) + } + if filter.IsActive != nil { + query = query.Where("is_active = ?", *filter.IsActive) + } + if filter.ValidFrom != nil { + query = query.Where("valid_from >= ?", *filter.ValidFrom) + } + if filter.ValidTo != nil { + query = query.Where("valid_to <= ?", *filter.ValidTo) + } + + // Apply ordering + if filter.SortBy != "" { + order := "ASC" + if filter.SortOrder == "desc" { + order = "DESC" + } + query = query.Order(fmt.Sprintf("%s %s", filter.SortBy, order)) + } else { + query = query.Order("priority DESC, created_at DESC") + } + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + } + + var plans []*RatePlan + if err := query.Find(&plans).Error; err != nil { + r.logger.WithError(err).Error("Failed to list rate plans") + return nil, fmt.Errorf("failed to list rate plans: %w", err) + } + + return plans, nil +} + +// CountRatePlans counts rate plans with filtering +func (r *GormRepository) CountRatePlans(ctx context.Context, filter *RatePlanFilter) (int, error) { + query := r.db.WithContext(ctx).Model(&RatePlan{}) + + // Apply filters + if filter.CarrierID != "" { + query = query.Where("carrier_id = ?", filter.CarrierID) + } + if filter.Region != "" { + query = query.Where("region = ?", filter.Region) + } + if filter.PlanType != "" { + query = query.Where("plan_type = ?", filter.PlanType) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.MinPrice > 0 { + query = query.Where("base_price >= ?", filter.MinPrice) + } + if filter.MaxPrice > 0 { + query = query.Where("base_price <= ?", filter.MaxPrice) + } + if filter.IsActive != nil { + query = query.Where("is_active = ?", *filter.IsActive) + } + if filter.ValidFrom != nil { + query = query.Where("valid_from >= ?", *filter.ValidFrom) + } + if filter.ValidTo != nil { + query = query.Where("valid_to <= ?", *filter.ValidTo) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + r.logger.WithError(err).Error("Failed to count rate plans") + return 0, fmt.Errorf("failed to count rate plans: %w", err) + } + + return int(count), nil +} diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go b/apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go new file mode 100644 index 0000000..d8dca04 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go @@ -0,0 +1,123 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" +) +// CreateSubscription creates a new rate plan subscription +func (r *GormRepository) CreateSubscription(ctx context.Context, subscription *RatePlanSubscription) error { + now := time.Now() + subscription.CreatedAt = now + subscription.UpdatedAt = now + + if err := r.db.WithContext(ctx).Create(subscription).Error; err != nil { + r.logger.WithError(err).Error("Failed to create subscription") + return fmt.Errorf("failed to create subscription: %w", err) + } + + return nil +} + +// GetSubscription retrieves a subscription by ID +func (r *GormRepository) GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) { + var subscription RatePlanSubscription + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&subscription).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("subscription not found: %s", id) + } + r.logger.WithError(err).Error("Failed to get subscription") + return nil, fmt.Errorf("failed to get subscription: %w", err) + } + + return &subscription, nil +} + +// GetActiveSubscription retrieves the active subscription for a profile +func (r *GormRepository) GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) { + var subscription RatePlanSubscription + err := r.db.WithContext(ctx). + Where("profile_id = ? AND status = ?", profileID, SubscriptionStatusActive). + Order("started_at DESC"). + First(&subscription).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // No active subscription found + } + r.logger.WithError(err).Error("Failed to get active subscription") + return nil, fmt.Errorf("failed to get active subscription: %w", err) + } + + return &subscription, nil +} + +// UpdateSubscription updates an existing subscription +func (r *GormRepository) UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) error { + subscription.UpdatedAt = time.Now() + + result := r.db.WithContext(ctx).Where("id = ?", subscription.ID).Updates(subscription) + if result.Error != nil { + r.logger.WithError(result.Error).Error("Failed to update subscription") + return fmt.Errorf("failed to update subscription: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("subscription not found: %s", subscription.ID) + } + + return nil +} + +// DeleteSubscription deletes a subscription +func (r *GormRepository) DeleteSubscription(ctx context.Context, id string) error { + result := r.db.WithContext(ctx).Where("id = ?", id).Delete(&RatePlanSubscription{}) + if result.Error != nil { + r.logger.WithError(result.Error).Error("Failed to delete subscription") + return fmt.Errorf("failed to delete subscription: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("subscription not found: %s", id) + } + + return nil +} +// ListSubscriptions retrieves subscriptions for a profile +func (r *GormRepository) ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) { + query := r.db.WithContext(ctx).Where("profile_id = ?", profileID) + + // Apply filters + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.RatePlanID != "" { + query = query.Where("rate_plan_id = ?", filter.RatePlanID) + } + if filter.StartedAfter != nil { + query = query.Where("started_at >= ?", *filter.StartedAfter) + } + if filter.StartedBefore != nil { + query = query.Where("started_at <= ?", *filter.StartedBefore) + } + + query = query.Order("started_at DESC") + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + } + + var subscriptions []*RatePlanSubscription + if err := query.Find(&subscriptions).Error; err != nil { + r.logger.WithError(err).Error("Failed to list subscriptions") + return nil, fmt.Errorf("failed to list subscriptions: %w", err) + } + + return subscriptions, nil +} diff --git a/apps/carrier-connector/internal/rateplan/service.go b/apps/carrier-connector/internal/rateplan/service.go new file mode 100644 index 0000000..97ed35b --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/service.go @@ -0,0 +1,198 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +// Service provides business logic for rate plan operations +type Service struct { + repo Repository + logger *logrus.Logger +} + +// NewService creates a new rate plan service +func NewService(repo Repository, logger *logrus.Logger) *Service { + return &Service{ + repo: repo, + logger: logger, + } +} + +func (s *Service) CreateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) { + // Generate ID if not provided + if plan.ID == "" { + plan.ID = uuid.New().String() + } + + // Validate rate plan + if err := s.validateRatePlan(plan); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Create the rate plan + if err := s.repo.CreateRatePlan(ctx, plan); err != nil { + s.logger.WithError(err).Error("Failed to create rate plan") + return nil, err + } + + s.logger.WithField("plan_id", plan.ID).Info("Rate plan created successfully") + return plan, nil +} + +// GetRatePlan retrieves a rate plan by ID +func (s *Service) GetRatePlan(ctx context.Context, id string) (*RatePlan, error) { + plan, err := s.repo.GetRatePlan(ctx, id) + if err != nil { + s.logger.WithError(err).WithField("plan_id", id).Error("Failed to get rate plan") + return nil, err + } + + return plan, nil +} + +// UpdateRatePlan updates an existing rate plan +func (s *Service) UpdateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) { + // Validate rate plan + if err := s.validateRatePlan(plan); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Check if plan exists by attempting to update + _, err := s.repo.GetRatePlan(ctx, plan.ID) + if err != nil { + return nil, err + } + + // Update the rate plan + if err := s.repo.UpdateRatePlan(ctx, plan); err != nil { + s.logger.WithError(err).Error("Failed to update rate plan") + return nil, err + } + + s.logger.WithField("plan_id", plan.ID).Info("Rate plan updated successfully") + return plan, nil +} + +// DeleteRatePlan deletes a rate plan +func (s *Service) DeleteRatePlan(ctx context.Context, id string) error { + // Check if plan has active subscriptions + subscriptions, err := s.repo.ListSubscriptions(ctx, "", &SubscriptionFilter{ + RatePlanID: id, + Status: SubscriptionStatusActive, + Limit: 1, + }) + if err != nil { + return err + } + + if len(subscriptions) > 0 { + return fmt.Errorf("cannot delete rate plan with active subscriptions") + } + + // Delete the rate plan + if err := s.repo.DeleteRatePlan(ctx, id); err != nil { + s.logger.WithError(err).Error("Failed to delete rate plan") + return err + } + + s.logger.WithField("plan_id", id).Info("Rate plan deleted successfully") + return nil +} + +// ListRatePlans retrieves rate plans with filtering +func (s *Service) ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([]*RatePlan, error) { + plans, err := s.repo.ListRatePlans(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to list rate plans") + return nil, err + } + + return plans, nil +} + +// SearchRatePlans searches for rate plans based on criteria +func (s *Service) SearchRatePlans(ctx context.Context, criteria SearchCriteria) ([]*RatePlan, error) { + filter := &RatePlanFilter{ + CarrierID: criteria.CarrierID, + Region: criteria.Region, + PlanType: criteria.PlanType, + Status: PlanStatusActive, + IsActive: &[]bool{true}[0], + MinPrice: criteria.MinPrice, + MaxPrice: criteria.MaxPrice, + Limit: criteria.Limit, + Offset: criteria.Offset, + SortBy: criteria.SortBy, + SortOrder: criteria.SortOrder, + } + + return s.repo.ListRatePlans(ctx, filter) +} +// CalculateCost calculates the cost for a rate plan based on usage +func (s *Service) CalculateCost(ctx context.Context, req *CalculateCostRequest) (*RatePlanCostCalculation, error) { + // Get the rate plan + plan, err := s.repo.GetRatePlan(ctx, req.RatePlanID) + if err != nil { + return nil, err + } + + // Calculate base cost + baseCost := plan.BasePrice + + // Calculate overage costs + overageCost := 0.0 + if req.DataUsed > 0 && plan.DataAllowance != nil && !plan.DataAllowance.Unlimited { + allowanceMB := plan.DataAllowance.Amount + if plan.DataAllowance.Unit == "GB" { + allowanceMB *= 1024 + } + if req.DataUsed > allowanceMB { + overageMB := req.DataUsed - allowanceMB + if plan.OverageRates != nil { + overageCost += float64(overageMB) * plan.OverageRates.DataRate + } + } + } + + // Apply discounts + discountCost := 0.0 + if len(req.AppliedDiscounts) > 0 && plan.Discounts != nil { + for _, discountID := range req.AppliedDiscounts { + for _, discount := range plan.Discounts { + if discount.ID == discountID && discount.IsActive { + switch discount.Type { + case DiscountTypePercentage: + discountCost += baseCost * discount.Value / 100 + case DiscountTypeFixed: + discountCost += discount.Value + } + } + } + } + } + + totalCost := baseCost + overageCost - discountCost + + calculation := &RatePlanCostCalculation{ + RatePlanID: req.RatePlanID, + BaseCost: baseCost, + OverageCost: overageCost, + DiscountCost: discountCost, + TotalCost: totalCost, + Currency: plan.Currency, + Breakdown: map[string]interface{}{ + "base_cost": baseCost, + "overage_cost": overageCost, + "discount_cost": discountCost, + "total_cost": totalCost, + }, + CalculatedAt: time.Now(), + } + + return calculation, nil +} diff --git a/apps/carrier-connector/internal/rateplan/service_analytics.go b/apps/carrier-connector/internal/rateplan/service_analytics.go new file mode 100644 index 0000000..0bb7d68 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/service_analytics.go @@ -0,0 +1,59 @@ +package rateplan + +import ( + "context" +) + +// GetUsage retrieves usage for a profile +func (s *Service) GetUsage(ctx context.Context, profileID string) (*RatePlanUsage, error) { + usage, err := s.repo.GetCurrentUsage(ctx, profileID) + if err != nil { + s.logger.WithError(err).WithField("profile_id", profileID).Error("Failed to get usage") + return nil, err + } + + return usage, nil +} +// GetUsageHistory retrieves usage history for a profile +func (s *Service) GetUsageHistory(ctx context.Context, profileID string, limit int) ([]*RatePlanUsage, error) { + usageHistory, err := s.repo.ListUsageHistory(ctx, profileID, limit) + if err != nil { + s.logger.WithError(err).Error("Failed to get usage history") + return nil, err + } + + return usageHistory, nil +} + +// GetUsageAnalytics retrieves usage analytics +func (s *Service) GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) { + analytics, err := s.repo.GetUsageAnalytics(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get usage analytics") + return nil, err + } + + return analytics, nil +} + +// GetRevenueAnalytics retrieves revenue analytics +func (s *Service) GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) { + analytics, err := s.repo.GetRevenueAnalytics(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get revenue analytics") + return nil, err + } + + return analytics, nil +} + +// GetPopularPlans retrieves the most popular rate plans +func (s *Service) GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) { + plans, err := s.repo.GetPopularPlans(ctx, limit) + if err != nil { + s.logger.WithError(err).Error("Failed to get popular plans") + return nil, err + } + + return plans, nil +} diff --git a/apps/carrier-connector/internal/rateplan/service_methods.go b/apps/carrier-connector/internal/rateplan/service_methods.go new file mode 100644 index 0000000..3a115f9 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/service_methods.go @@ -0,0 +1,76 @@ +package rateplan + +import ( + "fmt" + "time" +) + +func (s *Service) validateRatePlan(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") + } + if plan.ValidFrom.IsZero() { + return fmt.Errorf("valid from date is required") + } + return nil +} + +func (s *Service) validateSubscribeRequest(req *SubscribeRequest) error { + if req.ProfileID == "" { + return fmt.Errorf("profile ID is required") + } + if req.RatePlanID == "" { + return fmt.Errorf("rate plan ID is required") + } + return nil +} + +func (s *Service) calculateNextBillingDate(cycle BillingCycle, from time.Time) time.Time { + switch cycle { + case BillingCycleDaily: + return from.AddDate(0, 0, 1) + case BillingCycleWeekly: + return from.AddDate(0, 0, 7) + case BillingCycleMonthly: + return from.AddDate(0, 1, 0) + case BillingCycleQuarterly: + return from.AddDate(0, 3, 0) + case BillingCycleYearly: + return from.AddDate(1, 0, 0) + default: + return from.AddDate(0, 1, 0) // Default to monthly + } +} + +func (s *Service) calculateCycleEnd(cycle BillingCycle, cycleStart time.Time) time.Time { + switch cycle { + case BillingCycleDaily: + return cycleStart.AddDate(0, 0, 1).Add(-time.Nanosecond) + case BillingCycleWeekly: + return cycleStart.AddDate(0, 0, 7).Add(-time.Nanosecond) + case BillingCycleMonthly: + return cycleStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + case BillingCycleQuarterly: + return cycleStart.AddDate(0, 3, 0).Add(-time.Nanosecond) + case BillingCycleYearly: + return cycleStart.AddDate(1, 0, 0).Add(-time.Nanosecond) + default: + return cycleStart.AddDate(0, 1, 0).Add(-time.Nanosecond) // Default to monthly + } +} + diff --git a/apps/carrier-connector/internal/rateplan/service_subscription.go b/apps/carrier-connector/internal/rateplan/service_subscription.go new file mode 100644 index 0000000..2aecd60 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/service_subscription.go @@ -0,0 +1,196 @@ +package rateplan + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +func (s *Service) SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (*RatePlanSubscription, error) { + if err := s.validateSubscribeRequest(req); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + plan, err := s.repo.GetRatePlan(ctx, req.RatePlanID) + if err != nil { + return nil, err + } + + if !plan.IsActive || plan.Status != PlanStatusActive { + return nil, fmt.Errorf("rate plan is not available for subscription") + } + + activeSub, err := s.repo.GetActiveSubscription(ctx, req.ProfileID) + if err != nil { + return nil, err + } + + if activeSub != nil { + return nil, fmt.Errorf("profile already has an active subscription") + } + + // Create subscription + subscription := &RatePlanSubscription{ + ID: uuid.New().String(), + ProfileID: req.ProfileID, + RatePlanID: req.RatePlanID, + Status: SubscriptionStatusActive, + StartedAt: time.Now(), + BillingCycle: plan.BillingCycle, + NextBillingDate: s.calculateNextBillingDate(plan.BillingCycle, time.Now()), + AutoRenew: req.AutoRenew, + CurrentCycle: time.Now(), + AppliedDiscounts: req.AppliedDiscounts, + Metadata: req.Metadata, + } + + if err := s.repo.CreateSubscription(ctx, subscription); err != nil { + s.logger.WithError(err).Error("Failed to create subscription") + return nil, err + } + + s.logger.WithFields(logrus.Fields{ + "subscription_id": subscription.ID, + "profile_id": req.ProfileID, + "rate_plan_id": req.RatePlanID, + }).Info("Subscription created successfully") + + return subscription, nil +} + +// GetSubscription retrieves a subscription by ID +func (s *Service) GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) { + subscription, err := s.repo.GetSubscription(ctx, id) + if err != nil { + s.logger.WithError(err).WithField("subscription_id", id).Error("Failed to get subscription") + return nil, err + } + + return subscription, nil +} + +// UpdateSubscription updates an existing subscription +func (s *Service) UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) (*RatePlanSubscription, error) { + if err := s.repo.UpdateSubscription(ctx, subscription); err != nil { + s.logger.WithError(err).Error("Failed to update subscription") + return nil, err + } + + s.logger.WithField("subscription_id", subscription.ID).Info("Subscription updated successfully") + return subscription, nil +} + +// CancelSubscription cancels a subscription +func (s *Service) CancelSubscription(ctx context.Context, subscriptionID string, reason string) error { + subscription, err := s.repo.GetSubscription(ctx, subscriptionID) + if err != nil { + return err + } + + if subscription.Status != SubscriptionStatusActive { + return fmt.Errorf("subscription is not active") + } + + now := time.Now() + subscription.Status = SubscriptionStatusCancelled + subscription.EndedAt = &now + subscription.UpdatedAt = now + + if subscription.Metadata == nil { + subscription.Metadata = make(map[string]interface{}) + } + subscription.Metadata["cancellation_reason"] = reason + + if err := s.repo.UpdateSubscription(ctx, subscription); err != nil { + s.logger.WithError(err).Error("Failed to cancel subscription") + return err + } + + s.logger.WithField("subscription_id", subscriptionID).Info("Subscription cancelled successfully") + return nil +} + +// GetActiveSubscription retrieves the active subscription for a profile +func (s *Service) GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) { + subscription, err := s.repo.GetActiveSubscription(ctx, profileID) + if err != nil { + s.logger.WithError(err).WithField("profile_id", profileID).Error("Failed to get active subscription") + return nil, err + } + + return subscription, nil +} + +// ListSubscriptions retrieves subscriptions for a profile +func (s *Service) ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) { + subscriptions, err := s.repo.ListSubscriptions(ctx, profileID, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to list subscriptions") + return nil, err + } + + return subscriptions, nil +} + +func (s *Service) RecordUsage(ctx context.Context, req *RecordUsageRequest) (*RatePlanUsage, error) { + // Get active subscription + subscription, err := s.repo.GetActiveSubscription(ctx, req.ProfileID) + if err != nil { + return nil, err + } + + if subscription == nil { + return nil, fmt.Errorf("no active subscription found") + } + + // Get current usage + currentUsage, err := s.repo.GetCurrentUsage(ctx, req.ProfileID) + if err != nil { + return nil, err + } + + // Create or update usage record + var usage *RatePlanUsage + if currentUsage == nil { + // Create new usage record + usage = &RatePlanUsage{ + ID: uuid.New().String(), + RatePlanID: subscription.RatePlanID, + ProfileID: req.ProfileID, + CycleStart: subscription.CurrentCycle, + CycleEnd: s.calculateCycleEnd(subscription.BillingCycle, subscription.CurrentCycle), + DataUsed: req.DataUsed, + VoiceUsed: req.VoiceUsed, + SMSUsed: req.SMSUsed, + } + + if err := s.repo.CreateUsage(ctx, usage); err != nil { + s.logger.WithError(err).Error("Failed to create usage record") + return nil, err + } + } else { + // Update existing usage record + currentUsage.DataUsed += req.DataUsed + currentUsage.VoiceUsed += req.VoiceUsed + currentUsage.SMSUsed += req.SMSUsed + + if err := s.repo.UpdateUsage(ctx, currentUsage); err != nil { + s.logger.WithError(err).Error("Failed to update usage record") + return nil, err + } + + usage = currentUsage + } + + s.logger.WithFields(logrus.Fields{ + "profile_id": req.ProfileID, + "data_used": req.DataUsed, + "voice_used": req.VoiceUsed, + "sms_used": req.SMSUsed, + }).Info("Usage recorded successfully") + + return usage, nil +} diff --git a/apps/carrier-connector/internal/rateplan/types.go b/apps/carrier-connector/internal/rateplan/types.go new file mode 100644 index 0000000..afc2590 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/types.go @@ -0,0 +1,166 @@ +package rateplan + +import ( + "time" +) + +type DataAllowance struct { + Type DataAllowanceType `json:"type"` + Amount int64 `json:"amount"` // in MB or GB depending on type + Unit string `json:"unit"` // "MB", "GB", "TB" + Unlimited bool `json:"unlimited"` + SpeedLimit *int64 `json:"speed_limit,omitempty"` // in Kbps +} + +type DataAllowanceType string + +const ( + DataAllowanceTypeDaily DataAllowanceType = "daily" + DataAllowanceTypeMonthly DataAllowanceType = "monthly" + DataAllowanceTypeCycle DataAllowanceType = "cycle" + DataAllowanceTypeLifetime DataAllowanceType = "lifetime" +) + +type VoiceAllowance struct { + Type VoiceAllowanceType `json:"type"` + Minutes int64 `json:"minutes"` + Unlimited bool `json:"unlimited"` + Destinations []string `json:"destinations,omitempty"` // country codes +} + +type VoiceAllowanceType string + +const ( + VoiceAllowanceTypeDaily VoiceAllowanceType = "daily" + VoiceAllowanceTypeMonthly VoiceAllowanceType = "monthly" + VoiceAllowanceTypeCycle VoiceAllowanceType = "cycle" + VoiceAllowanceTypeUnlimited VoiceAllowanceType = "unlimited" +) + +type SMSAllowance struct { + Type SMSAllowanceType `json:"type"` + Messages int64 `json:"messages"` + Unlimited bool `json:"unlimited"` + Destinations []string `json:"destinations,omitempty"` // country codes +} + +type SMSAllowanceType string + +const ( + SMSAllowanceTypeDaily SMSAllowanceType = "daily" + SMSAllowanceTypeMonthly SMSAllowanceType = "monthly" + SMSAllowanceTypeCycle SMSAllowanceType = "cycle" + SMSAllowanceTypeUnlimited SMSAllowanceType = "unlimited" +) + +type OverageRates struct { + DataRate float64 `json:"data_rate"` // per MB + VoiceRate float64 `json:"voice_rate"` // per minute + SMSRate float64 `json:"sms_rate"` // per message + Currency string `json:"currency"` +} + +type PlanFeature struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type EarlyTermination struct { + Enabled bool `json:"enabled"` + FeeType string `json:"fee_type"` // "fixed", "percentage", "remaining_months" + FeeAmount float64 `json:"fee_amount"` // for fixed fee type + FeePercentage float64 `json:"fee_percentage"` // for percentage fee type + MinMonths int `json:"min_months"` // minimum months before termination fee applies +} + +type Discount struct { + ID string `json:"id"` + Name string `json:"name"` + Type DiscountType `json:"type"` + Value float64 `json:"value"` + ValidFrom time.Time `json:"valid_from"` + ValidTo *time.Time `json:"valid_to,omitempty"` + Conditions string `json:"conditions,omitempty"` + IsActive bool `json:"is_active"` +} + +type DiscountType string + +const ( + DiscountTypePercentage DiscountType = "percentage" + DiscountTypeFixed DiscountType = "fixed" + DiscountTypeRecurring DiscountType = "recurring" +) + +// RatePlanUsage tracks actual usage against a rate plan +type RatePlanUsage struct { + ID string `json:"id" db:"id"` + RatePlanID string `json:"rate_plan_id" db:"rate_plan_id"` + ProfileID string `json:"profile_id" db:"profile_id"` + CycleStart time.Time `json:"cycle_start" db:"cycle_start"` + CycleEnd time.Time `json:"cycle_end" db:"cycle_end"` + DataUsed int64 `json:"data_used" db:"data_used"` // in MB + VoiceUsed int64 `json:"voice_used" db:"voice_used"` // in minutes + SMSUsed int64 `json:"sms_used" db:"sms_used"` // count + LastUpdated time.Time `json:"last_updated" db:"last_updated"` +} + +// RatePlanSubscription represents an active subscription to a rate plan +type RatePlanSubscription struct { + ID string `json:"id" db:"id"` + ProfileID string `json:"profile_id" db:"profile_id"` + RatePlanID string `json:"rate_plan_id" db:"rate_plan_id"` + Status SubscriptionStatus `json:"status" db:"status"` + StartedAt time.Time `json:"started_at" db:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty" db:"ended_at"` + BillingCycle BillingCycle `json:"billing_cycle" db:"billing_cycle"` + NextBillingDate time.Time `json:"next_billing_date" db:"next_billing_date"` + AutoRenew bool `json:"auto_renew" db:"auto_renew"` + CurrentCycle time.Time `json:"current_cycle" db:"current_cycle"` + AppliedDiscounts []string `json:"applied_discounts,omitempty" db:"applied_discounts"` + Metadata map[string]any `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SubscriptionStatus defines the status of a subscription +type SubscriptionStatus string + +const ( + SubscriptionStatusActive SubscriptionStatus = "active" + SubscriptionStatusSuspended SubscriptionStatus = "suspended" + SubscriptionStatusCancelled SubscriptionStatus = "cancelled" + SubscriptionStatusExpired SubscriptionStatus = "expired" + SubscriptionStatusPending SubscriptionStatus = "pending" +) + +// RatePlanFilter defines filtering options for rate plan queries +type RatePlanFilter struct { + CarrierID string `json:"carrier_id,omitempty"` + Region string `json:"region,omitempty"` + PlanType PlanType `json:"plan_type,omitempty"` + Status PlanStatus `json:"status,omitempty"` + MinPrice float64 `json:"min_price,omitempty"` + MaxPrice float64 `json:"max_price,omitempty"` + IsActive *bool `json:"is_active,omitempty"` + ValidFrom *time.Time `json:"valid_from,omitempty"` + ValidTo *time.Time `json:"valid_to,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` +} + +// RatePlanCostCalculation represents the result of cost calculation +type RatePlanCostCalculation struct { + RatePlanID string `json:"rate_plan_id"` + BaseCost float64 `json:"base_cost"` + OverageCost float64 `json:"overage_cost"` + DiscountCost float64 `json:"discount_cost"` + TotalCost float64 `json:"total_cost"` + Currency string `json:"currency"` + Breakdown map[string]any `json:"breakdown"` + CalculatedAt time.Time `json:"calculated_at"` +} diff --git a/apps/carrier-connector/internal/rateplan/types_core.go b/apps/carrier-connector/internal/rateplan/types_core.go new file mode 100644 index 0000000..d6ac2b0 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/types_core.go @@ -0,0 +1,65 @@ +package rateplan + +import ( + "time" +) + +type RatePlan struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + CarrierID string `json:"carrier_id" db:"carrier_id"` + Region string `json:"region" db:"region"` + PlanType PlanType `json:"plan_type" db:"plan_type"` + Status PlanStatus `json:"status" db:"status"` + BasePrice float64 `json:"base_price" db:"base_price"` + Currency string `json:"currency" db:"currency"` + BillingCycle BillingCycle `json:"billing_cycle" db:"billing_cycle"` + DataAllowance *DataAllowance `json:"data_allowance,omitempty" db:"data_allowance"` + VoiceAllowance *VoiceAllowance `json:"voice_allowance,omitempty" db:"voice_allowance"` + SMSAllowance *SMSAllowance `json:"sms_allowance,omitempty" db:"sms_allowance"` + OverageRates *OverageRates `json:"overage_rates,omitempty" db:"overage_rates"` + Features []PlanFeature `json:"features,omitempty" db:"features"` + ActivationFee float64 `json:"activation_fee" db:"activation_fee"` + EarlyTermination *EarlyTermination `json:"early_termination,omitempty" db:"early_termination"` + Discounts []Discount `json:"discounts,omitempty" db:"discounts"` + ValidFrom time.Time `json:"valid_from" db:"valid_from"` + ValidTo *time.Time `json:"valid_to,omitempty" db:"valid_to"` + Priority int `json:"priority" db:"priority"` + IsActive bool `json:"is_active" db:"is_active"` + Metadata map[string]any `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// PlanType defines the type of rate plan +type PlanType string + +const ( + PlanTypePrepaid PlanType = "prepaid" + PlanTypePostpaid PlanType = "postpaid" + PlanTypeHybrid PlanType = "hybrid" + PlanTypePayAsYouGo PlanType = "pay_as_you_go" + PlanTypeUnlimited PlanType = "unlimited" +) + +// PlanStatus defines the status of a rate plan +type PlanStatus string + +const ( + PlanStatusDraft PlanStatus = "draft" + PlanStatusActive PlanStatus = "active" + PlanStatusInactive PlanStatus = "inactive" + PlanStatusArchived PlanStatus = "archived" +) + +// BillingCycle defines the billing frequency +type BillingCycle string + +const ( + BillingCycleDaily BillingCycle = "daily" + BillingCycleWeekly BillingCycle = "weekly" + BillingCycleMonthly BillingCycle = "monthly" + BillingCycleQuarterly BillingCycle = "quarterly" + BillingCycleYearly BillingCycle = "yearly" +) diff --git a/apps/carrier-connector/internal/rateplan/types_extended.go b/apps/carrier-connector/internal/rateplan/types_extended.go new file mode 100644 index 0000000..e0e8522 --- /dev/null +++ b/apps/carrier-connector/internal/rateplan/types_extended.go @@ -0,0 +1,193 @@ +package rateplan + +import ( + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" +) + +// SubscriptionFilter defines filtering options for subscription queries +type SubscriptionFilter struct { + Status SubscriptionStatus `json:"status,omitempty"` + RatePlanID string `json:"rate_plan_id,omitempty"` + StartedAfter *time.Time `json:"started_after,omitempty"` + StartedBefore *time.Time `json:"started_before,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// UsageAnalyticsFilter defines filtering options for usage analytics +type UsageAnalyticsFilter struct { + RatePlanID string `json:"rate_plan_id,omitempty"` + CarrierID string `json:"carrier_id,omitempty"` + Region string `json:"region,omitempty"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + GroupBy string `json:"group_by,omitempty"` // "day", "week", "month" +} + +// RevenueAnalyticsFilter defines filtering options for revenue analytics +type RevenueAnalyticsFilter struct { + RatePlanID string `json:"rate_plan_id,omitempty"` + CarrierID string `json:"carrier_id,omitempty"` + Region string `json:"region,omitempty"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + GroupBy string `json:"group_by,omitempty"` // "day", "week", "month" +} + +// UsageAnalytics contains usage statistics +type UsageAnalytics struct { + TotalDataUsed int64 `json:"total_data_used"` + TotalVoiceUsed int64 `json:"total_voice_used"` + TotalSMSUsed int64 `json:"total_sms_used"` + ActiveUsers int `json:"active_users"` + AverageUsage map[string]float64 `json:"average_usage"` + UsageByPlan map[string]int64 `json:"usage_by_plan"` + UsageByRegion map[string]int64 `json:"usage_by_region"` + TimelineData []TimelineDataPoint `json:"timeline_data"` +} + +// RevenueAnalytics contains revenue statistics +type RevenueAnalytics struct { + TotalRevenue float64 `json:"total_revenue"` + RevenueByPlan map[string]float64 `json:"revenue_by_plan"` + RevenueByCarrier map[string]float64 `json:"revenue_by_carrier"` + RevenueByRegion map[string]float64 `json:"revenue_by_region"` + AverageRevenue map[string]float64 `json:"average_revenue"` + TimelineData []TimelineDataPoint `json:"timeline_data"` +} + +// TimelineDataPoint represents a data point in time series +type TimelineDataPoint struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` + Label string `json:"label,omitempty"` +} + +type SearchCriteria struct { + CarrierID string `json:"carrier_id,omitempty"` + Region string `json:"region,omitempty"` + PlanType PlanType `json:"plan_type,omitempty"` + MinPrice float64 `json:"min_price,omitempty"` + MaxPrice float64 `json:"max_price,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` +} + +type SubscribeRequest struct { + ProfileID string `json:"profile_id"` + RatePlanID string `json:"rate_plan_id"` + AutoRenew bool `json:"auto_renew"` + AppliedDiscounts []string `json:"applied_discounts,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type RecordUsageRequest struct { + ProfileID string `json:"profile_id"` + DataUsed int64 `json:"data_used"` // in MB + VoiceUsed int64 `json:"voice_used"` // in minutes + SMSUsed int64 `json:"sms_used"` // count +} + +type CalculateCostRequest struct { + RatePlanID string `json:"rate_plan_id"` + DataUsed int64 `json:"data_used"` // in MB + VoiceUsed int64 `json:"voice_used"` // in minutes + SMSUsed int64 `json:"sms_used"` // count + AppliedDiscounts []string `json:"applied_discounts,omitempty"` +} + +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"` +} + +type CarrierRatePlanCriteria struct { + Region string `json:"region"` + PlanType PlanType `json:"plan_type"` + MaxBudget float64 `json:"max_budget"` + Urgency string `json:"urgency"` + Preferences map[string]interface{} `json:"preferences,omitempty"` +} + +type RecommendationCriteria struct { + Region string `json:"region"` + PlanType PlanType `json:"plan_type"` + MaxBudget float64 `json:"max_budget"` + PreferredData int64 `json:"preferred_data"` + PreferredVoice int64 `json:"preferred_voice"` + PreferredSMS int64 `json:"preferred_sms"` + MaxResults int `json:"max_results"` +} + +type CarrierRatePlanResult struct { + Carrier *smdp.Carrier `json:"carrier"` + RatePlan *RatePlan `json:"rate_plan"` + TotalScore float64 `json:"total_score"` + SelectedAt time.Time `json:"selected_at"` +} + +type RatePlanRecommendation struct { + RatePlanID string `json:"rate_plan_id"` + RatePlanName string `json:"rate_plan_name"` + CarrierID string `json:"carrier_id"` + CarrierName string `json:"carrier_name"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Relevance float64 `json:"relevance"` + Features []PlanFeature `json:"features"` + DataAllowance *DataAllowance `json:"data_allowance"` + VoiceAllowance *VoiceAllowance `json:"voice_allowance"` + SMSAllowance *SMSAllowance `json:"sms_allowance"` + RecommendedAt time.Time `json:"recommended_at"` +} + +type CarrierRatePlanAnalytics struct { + CarrierID string `json:"carrier_id"` + CarrierName string `json:"carrier_name"` + Region string `json:"region"` + HealthStatus string `json:"health_status"` + Priority int `json:"priority"` + TotalPlans int `json:"total_plans"` + ActivePlans int `json:"active_plans"` + GeneratedAt time.Time `json:"generated_at"` + PlanAnalytics []RatePlanAnalytics `json:"plan_analytics"` +} + +type RatePlanAnalytics struct { + RatePlanID string `json:"rate_plan_id"` + RatePlanName string `json:"rate_plan_name"` + BasePrice float64 `json:"base_price"` + Currency string `json:"currency"` + ActiveSubscriptions int `json:"active_subscriptions"` + PlanType PlanType `json:"plan_type"` + BillingCycle BillingCycle `json:"billing_cycle"` + DataAllowance *DataAllowance `json:"data_allowance"` + VoiceAllowance *VoiceAllowance `json:"voice_allowance"` + SMSAllowance *SMSAllowance `json:"sms_allowance"` + Features []PlanFeature `json:"features"` +} diff --git a/apps/carrier-connector/routes.go b/apps/carrier-connector/routes.go index aaaac80..a2dafcd 100644 --- a/apps/carrier-connector/routes.go +++ b/apps/carrier-connector/routes.go @@ -25,9 +25,9 @@ func setupRoutes(router *gin.Engine, client *es2.ES2Client, repo repository.Prof esim := api.Group("/esim") { esim.POST("/profiles", handler.OrderProfileHandlerWithRepo(client, repo, webhookClient, messageQueue)) - esim.GET("/profiles", handler.ListProfilesHandlerWithRepo(repo)) - esim.GET("/profiles/:profileId", handler.GetProfileHandlerWithRepo(client, repo)) - esim.DELETE("/profiles/:profileId", handler.DeleteProfileHandlerWithRepo(client, repo, webhookClient, messageQueue)) + esim.GET("/profiles", handler.ListProfilesHandler(repo)) + esim.GET("/profiles/:profileId", handler.GetProfileHandler(repo)) + esim.DELETE("/profiles/:profileId", handler.DeleteProfileHandler(repo)) } carrier := api.Group("/carrier")