From 7614476e4f6de50573554749774a532731931f2a Mon Sep 17 00:00:00 2001 From: Nutcase <143586703+nutcas3@users.noreply.github.com> Date: Sat, 2 May 2026 01:28:15 +0300 Subject: [PATCH] Multi-Currency Support for International Billing (#64) * 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 * feat: Add currency configuration file with 20 major currencies and regional support - Add currencies.json with USD, EUR, GBP, JPY, CAD, AUD, CHF, CNY, INR, BRL, MXN, SEK, NOK, DKK, SGD, HKD, NZD, ZAR, KRW, and TRY configurations - Add currency metadata with code, name, symbol, decimal places, active status, and supported regions - Add regional mappings for currency support across multiple countries and territories * feat: Add currency analytics service with revenue tracking, transaction analysis, and usage statistics - Add AnalyticsServiceImpl struct with repository and logger fields - Add GetRevenueByCurrency with completed transaction filtering and revenue aggregation by currency - Add GetTransactionVolumeByCurrency with transaction count grouping - Add GetExchangeRateTrends with historical rate retrieval and date-based filtering - Add GetCurrencyUsageStats with total/active currency counts, transaction volume * feat: Add multi-currency billing service with transaction processing, currency conversion, and history tracking - Add BillingServiceImpl struct with repository, exchange service, logger, and base currency fields - Add NewBillingService constructor with dependency injection - Add ProcessBilling with request validation, currency conversion, transaction creation, and payment processing - Add ConvertAmount wrapper delegating to exchange service - Add GetBillingHistory with profile ID filtering and transaction retries * feat: Add exchange rate service with multi-provider support, currency conversion, and historical rate tracking - Add ExchangeRateService struct with repository, logger, providers, and base currency fields - Add NewExchangeRateService constructor and AddProvider for provider registration - Add GetExchangeRate with repository caching, provider fallback, and same-currency handling - Add ConvertAmount wrapper with exchange rate retrieval and amount calculation - Add RefreshRates with multi-provider rate * refactor: Extract currency interfaces and GORM models into separate files - Move Repository, ExchangeRateProvider, BillingService, and AnalyticsService interfaces from types.go to new interface.go - Move BillingSummary and CurrencyUsageStats structs to interface.go - Move CurrencyModel, ExchangeRateModel, and TransactionModel to new models.go - Move GORM hooks (BeforeCreate, BeforeUpdate) and TableName methods to models.go - Remove redundant comments from exchange_rate_service.go * feat: Add rate plan currency integrator with subscription creation, cost calculation, and multi-currency billing support - Add RatePlanCurrencyIntegrator struct with billing service, exchange service, rate plan service, logger, and base currency fields - Add NewRatePlanCurrencyIntegrator constructor with dependency injection - Add SubscribeToPlanWithCurrency with price conversion, subscription creation, and initial billing processing - Add CalculatePlanCostInCurrency with base cost calculation * feat: Add currency repository with GORM implementation, rate plan integration, and transaction management - Add GormRepository with CRUD operations for currencies, exchange rates, and transactions - Add repository helper methods for model-to-domain conversions with JSON metadata handling - Add GetHistoricalRates with date range filtering and chronological ordering - Add ListExchangeRates with currency pair, active status, and date range filtering - Add CreateTransaction, GetTransaction, UpdateTransaction, and List * feat: Add currency handler layer with conversion, billing, exchange rates, and analytics endpoints - Add CurrencyHandler struct with billing service, exchange service, and logger fields - Add ConvertCurrency endpoint with JSON binding and amount conversion support - Add GetExchangeRate endpoint with currency pair path parameters - Add ProcessBilling endpoint with billing request validation and transaction processing - Add GetBillingHistory with profile ID filtering, date range, limit, and offset support * refactor: Rename service package to services and move repository implementations to repository package - Rename internal/service to internal/services in carrier-connector - Update service imports in selection_integration.go and smdp_integration.go - Move currency repository files from internal/currency to internal/repository - Move rate plan repository files from internal/rateplan to internal/repository - Update package declarations and imports for currency domain types - Add currency package imports * refactor: Add ExchangeRateService interface and format currency types with consistent alignment - Add ExchangeRateService interface with GetExchangeRate, ConvertAmount, GetRateHistory, UpdateExchangeRate, GetSupportedCurrencies, ValidateCurrencyPair, and RefreshRates methods - Add CurrencyRevenue struct with currency and revenue fields - Add TransactionTypeStats struct with type, count, amount, and currency fields - Align struct field tags consistently across Currency, ExchangeRate, Transaction, and request/response types * refactor: Update service layer to use repository package types and align imports - Update Service struct to use repository.Repository interface - Update all method signatures to use repository package types (RatePlan, RatePlanSubscription, RatePlanUsage, etc.) - Update filter and request types to use repository package (RatePlanFilter, SubscriptionFilter, CalculateCostRequest, etc.) - Update status constants to use repository package (SubscriptionStatusActive, PlanStatusActive, DiscountTypePercentage, DiscountTypeFixed) * refactor: Update handler constructors to use interface types instead of concrete implementations - Update CurrencyHandler.exchangeService field from *currency.ExchangeRateService to currency.ExchangeRateService interface - Update NewCurrencyHandler parameter from *currency.ExchangeRateService to currency.ExchangeRateService interface - Update RatePlanHandler.service field from *rateplan.Service to rateplan.Service interface - Update NewRatePlanHandler parameter from *rateplan.Service to rateplan.Service interface * refactor: Rename currency parameter from 'currency' to 'curr' in currencyToModel helper method and add Service interface to rate plan package - Rename currency parameter to curr in GormRepository.currencyToModel to avoid shadowing currency package name - Add Service interface with rate plan, subscription, usage, and analytics operations - Add CreateRatePlan, GetRatePlan, UpdateRatePlan, DeleteRatePlan, ListRatePlans, and SearchRatePlans methods - Add SubscribeToPlan, GetSubscription, UpdateSubscription, CancelSubscription, GetActiveSubscription, and ListSubscriptions methods - Add Rec * feat: Add repository types package with rate plan, subscription, usage, and analytics domain models - Add Repository interface with rate plan CRUD, subscription management, usage tracking, and analytics operations - Add RatePlan struct with pricing, allowances, overage rates, discounts, and metadata fields - Add RatePlanSubscription struct with billing cycle, auto-renewal, and applied discounts support - Add RatePlanUsage struct with cycle tracking and data/voice/SMS usage metrics - Add filter types for rate plans, subscriptions, usage analytics, and revenue analytics --- .../carrier-connector/configs/currencies.json | 164 +++++++++ .../internal/currency/interface.go | 92 +++++ .../internal/currency/models.go | 111 ++++++ .../internal/currency/types.go | 163 +++++++++ .../internal/handlers/currency_handlers.go | 267 +++++++++++++++ .../handlers/rateplan_handlers_core.go | 4 +- .../integration/selection_integration.go | 12 +- .../internal/integration/smdp_integration.go | 8 +- .../internal/rateplan/interface.go | 30 ++ .../internal/repository/repository_core.go | 191 +++++++++++ .../internal/repository/repository_helpers.go | 128 +++++++ .../repository_rateplan.go | 16 +- .../repository_rateplan_crud.go | 4 +- .../repository_rateplan_subscription.go | 4 +- .../internal/repository/repository_rates.go | 92 +++++ .../repository/repository_transactions.go | 171 ++++++++++ .../internal/repository/types.go | 315 ++++++++++++++++++ .../internal/services/analytics_service.go | 244 ++++++++++++++ .../internal/services/billing_core.go | 149 +++++++++ .../services/exchange_rate_service.go | 197 +++++++++++ .../internal/services/rateplan_core.go | 173 ++++++++++ .../internal/services/rateplan_methods.go | 162 +++++++++ .../selection_analytics.go | 2 +- .../selection_service.go | 2 +- .../{rateplan => services}/service.go | 34 +- .../service_analytics.go | 15 +- .../{rateplan => services}/service_methods.go | 33 +- .../service_subscription.go | 29 +- .../{service => services}/smdp_service.go | 2 +- 29 files changed, 2730 insertions(+), 84 deletions(-) create mode 100644 apps/carrier-connector/configs/currencies.json create mode 100644 apps/carrier-connector/internal/currency/interface.go create mode 100644 apps/carrier-connector/internal/currency/models.go create mode 100644 apps/carrier-connector/internal/currency/types.go create mode 100644 apps/carrier-connector/internal/handlers/currency_handlers.go create mode 100644 apps/carrier-connector/internal/repository/repository_core.go create mode 100644 apps/carrier-connector/internal/repository/repository_helpers.go rename apps/carrier-connector/internal/{rateplan => repository}/repository_rateplan.go (93%) rename apps/carrier-connector/internal/{rateplan => repository}/repository_rateplan_crud.go (99%) rename apps/carrier-connector/internal/{rateplan => repository}/repository_rateplan_subscription.go (99%) create mode 100644 apps/carrier-connector/internal/repository/repository_rates.go create mode 100644 apps/carrier-connector/internal/repository/repository_transactions.go create mode 100644 apps/carrier-connector/internal/repository/types.go create mode 100644 apps/carrier-connector/internal/services/analytics_service.go create mode 100644 apps/carrier-connector/internal/services/billing_core.go create mode 100644 apps/carrier-connector/internal/services/exchange_rate_service.go create mode 100644 apps/carrier-connector/internal/services/rateplan_core.go create mode 100644 apps/carrier-connector/internal/services/rateplan_methods.go rename apps/carrier-connector/internal/{service => services}/selection_analytics.go (99%) rename apps/carrier-connector/internal/{service => services}/selection_service.go (99%) rename apps/carrier-connector/internal/{rateplan => services}/service.go (79%) rename apps/carrier-connector/internal/{rateplan => services}/service_analytics.go (80%) rename apps/carrier-connector/internal/{rateplan => services}/service_methods.go (62%) rename apps/carrier-connector/internal/{rateplan => services}/service_subscription.go (83%) rename apps/carrier-connector/internal/{service => services}/smdp_service.go (99%) diff --git a/apps/carrier-connector/configs/currencies.json b/apps/carrier-connector/configs/currencies.json new file mode 100644 index 0000000..c722c18 --- /dev/null +++ b/apps/carrier-connector/configs/currencies.json @@ -0,0 +1,164 @@ +{ + "currencies": [ + { + "code": "USD", + "name": "United States Dollar", + "symbol": "$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["US", "PR", "VI", "GU", "UM"] + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["AT", "BE", "CY", "DE", "EE", "ES", "FI", "FR", "GR", "IE", "IT", "LU", "LV", "LT", "MT", "NL", "PT", "SK", "SI"] + }, + { + "code": "GBP", + "name": "British Pound Sterling", + "symbol": "£", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["GB", "GG", "IM", "JE"] + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "decimal_places": 0, + "is_active": true, + "supported_regions": ["JP"] + }, + { + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "C$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["CA"] + }, + { + "code": "AUD", + "name": "Australian Dollar", + "symbol": "A$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["AU", "CX", "CC", "HM", "KI", "NR", "TV", "NF"] + }, + { + "code": "CHF", + "name": "Swiss Franc", + "symbol": "CHF", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["CH", "LI"] + }, + { + "code": "CNY", + "name": "Chinese Yuan", + "symbol": "¥", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["CN"] + }, + { + "code": "INR", + "name": "Indian Rupee", + "symbol": "₹", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["IN"] + }, + { + "code": "BRL", + "name": "Brazilian Real", + "symbol": "R$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["BR"] + }, + { + "code": "MXN", + "name": "Mexican Peso", + "symbol": "$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["MX"] + }, + { + "code": "SEK", + "name": "Swedish Krona", + "symbol": "kr", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["SE"] + }, + { + "code": "NOK", + "name": "Norwegian Krone", + "symbol": "kr", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["NO", "SJ", "BV"] + }, + { + "code": "DKK", + "name": "Danish Krone", + "symbol": "kr", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["DK", "FO", "GL"] + }, + { + "code": "SGD", + "name": "Singapore Dollar", + "symbol": "S$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["SG"] + }, + { + "code": "HKD", + "name": "Hong Kong Dollar", + "symbol": "HK$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["HK"] + }, + { + "code": "NZD", + "name": "New Zealand Dollar", + "symbol": "NZ$", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["NZ", "CK", "NU", "TK", "PN"] + }, + { + "code": "ZAR", + "name": "South African Rand", + "symbol": "R", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["ZA", "LS", "SZ"] + }, + { + "code": "KRW", + "name": "South Korean Won", + "symbol": "₩", + "decimal_places": 0, + "is_active": true, + "supported_regions": ["KR"] + }, + { + "code": "TRY", + "name": "Turkish Lira", + "symbol": "₺", + "decimal_places": 2, + "is_active": true, + "supported_regions": ["TR"] + } + ] +} diff --git a/apps/carrier-connector/internal/currency/interface.go b/apps/carrier-connector/internal/currency/interface.go new file mode 100644 index 0000000..5b4c0fa --- /dev/null +++ b/apps/carrier-connector/internal/currency/interface.go @@ -0,0 +1,92 @@ +package currency + +import ( + "context" + "time" +) + +// Repository defines the interface for currency data operations +type Repository interface { + // Currency operations + CreateCurrency(ctx context.Context, currency *Currency) error + GetCurrency(ctx context.Context, code string) (*Currency, error) + UpdateCurrency(ctx context.Context, currency *Currency) error + DeleteCurrency(ctx context.Context, code string) error + ListCurrencies(ctx context.Context, filter *CurrencyFilter) ([]*Currency, error) + CountCurrencies(ctx context.Context, filter *CurrencyFilter) (int, error) + + // Exchange rate operations + CreateExchangeRate(ctx context.Context, rate *ExchangeRate) error + GetExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*ExchangeRate, error) + UpdateExchangeRate(ctx context.Context, rate *ExchangeRate) error + DeleteExchangeRate(ctx context.Context, id string) error + ListExchangeRates(ctx context.Context, filter *ExchangeRateFilter) ([]*ExchangeRate, error) + GetLatestExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*ExchangeRate, error) + + // Transaction operations + CreateTransaction(ctx context.Context, transaction *Transaction) error + GetTransaction(ctx context.Context, id string) (*Transaction, error) + UpdateTransaction(ctx context.Context, transaction *Transaction) error + DeleteTransaction(ctx context.Context, id string) error + ListTransactions(ctx context.Context, filter *TransactionFilter) ([]*Transaction, error) + CountTransactions(ctx context.Context, filter *TransactionFilter) (int, error) +} + +// ExchangeRateProvider defines the interface for external exchange rate providers +type ExchangeRateProvider interface { + GetRates(ctx context.Context) (map[string]float64, error) + GetRate(ctx context.Context, fromCurrency, toCurrency string) (float64, error) + RefreshRates(ctx context.Context) error +} + +// ExchangeRateService defines the interface for exchange rate operations +type ExchangeRateService interface { + GetExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*ExchangeRate, error) + ConvertAmount(ctx context.Context, amount float64, fromCurrency, toCurrency string) (*CurrencyConversionResponse, error) + GetRateHistory(ctx context.Context, fromCurrency, toCurrency string, days int) ([]*ExchangeRate, error) + UpdateExchangeRate(ctx context.Context, rate *ExchangeRate) error + GetSupportedCurrencies(ctx context.Context) ([]*Currency, error) + ValidateCurrencyPair(ctx context.Context, fromCurrency, toCurrency string) error + RefreshRates(ctx context.Context) error +} + +// BillingService defines the interface for multi-currency billing operations +type BillingService interface { + ProcessBilling(ctx context.Context, req *BillingRequest) (*BillingResponse, error) + ConvertAmount(ctx context.Context, req *CurrencyConversionRequest) (*CurrencyConversionResponse, error) + GetBillingHistory(ctx context.Context, profileID string, filter *TransactionFilter) ([]*Transaction, error) + CalculateTotalBilling(ctx context.Context, profileID string, fromDate, toDate time.Time) (*BillingSummary, error) +} + +// AnalyticsService defines the interface for currency analytics +type AnalyticsService interface { + GetRevenueByCurrency(ctx context.Context, filter *TransactionFilter) (map[string]float64, error) + GetTransactionVolumeByCurrency(ctx context.Context, filter *TransactionFilter) (map[string]int64, error) + GetExchangeRateTrends(ctx context.Context, fromCurrency, toCurrency string, days int) ([]*ExchangeRate, error) + GetCurrencyUsageStats(ctx context.Context) (*CurrencyUsageStats, error) +} + +// BillingSummary represents a billing summary for a profile +type BillingSummary struct { + ProfileID string `json:"profile_id"` + TotalAmount float64 `json:"total_amount"` + Currency string `json:"currency"` + BaseTotalAmount float64 `json:"base_total_amount"` + BaseCurrency string `json:"base_currency"` + TransactionCount int `json:"transaction_count"` + FromDate time.Time `json:"from_date"` + ToDate time.Time `json:"to_date"` + Breakdown map[string]interface{} `json:"breakdown"` +} + +// CurrencyUsageStats represents statistics about currency usage +type CurrencyUsageStats struct { + TotalCurrencies int `json:"total_currencies"` + ActiveCurrencies int `json:"active_currencies"` + TotalTransactions int64 `json:"total_transactions"` + TotalVolume float64 `json:"total_volume"` + MostUsedCurrency string `json:"most_used_currency"` + CurrencyDistribution map[string]int64 `json:"currency_distribution"` + ExchangeRateCount int `json:"exchange_rate_count"` + LastUpdated time.Time `json:"last_updated"` +} diff --git a/apps/carrier-connector/internal/currency/models.go b/apps/carrier-connector/internal/currency/models.go new file mode 100644 index 0000000..709804c --- /dev/null +++ b/apps/carrier-connector/internal/currency/models.go @@ -0,0 +1,111 @@ +package currency + +import ( + "time" + + "gorm.io/gorm" +) + +// CurrencyModel represents the database model for currencies +type CurrencyModel struct { + Code string `gorm:"primaryKey;column:code" json:"code"` + Name string `gorm:"column:name" json:"name"` + Symbol string `gorm:"column:symbol" json:"symbol"` + DecimalPlaces int `gorm:"column:decimal_places" json:"decimal_places"` + IsActive bool `gorm:"column:is_active" json:"is_active"` + SupportedRegions string `gorm:"column:supported_regions;type:text" json:"supported_regions"` + 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 currency model +func (CurrencyModel) TableName() string { + return "currencies" +} + +// ExchangeRateModel represents the database model for exchange rates +type ExchangeRateModel struct { + ID string `gorm:"primaryKey;column:id" json:"id"` + FromCurrency string `gorm:"column:from_currency;index" json:"from_currency"` + ToCurrency string `gorm:"column:to_currency;index" json:"to_currency"` + Rate float64 `gorm:"column:rate" json:"rate"` + Source string `gorm:"column:source" json:"source"` + ValidFrom time.Time `gorm:"column:valid_from;index" json:"valid_from"` + ValidTo *time.Time `gorm:"column:valid_to;index" json:"valid_to"` + IsActive bool `gorm:"column:is_active" json:"is_active"` + 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 exchange rate model +func (ExchangeRateModel) TableName() string { + return "exchange_rates" +} + +// TransactionModel represents the database model for transactions +type TransactionModel struct { + ID string `gorm:"primaryKey;column:id" json:"id"` + ProfileID string `gorm:"column:profile_id;index" json:"profile_id"` + SubscriptionID string `gorm:"column:subscription_id;index" json:"subscription_id"` + Type string `gorm:"column:type;index" json:"type"` + Amount float64 `gorm:"column:amount" json:"amount"` + Currency string `gorm:"column:currency;index" json:"currency"` + BaseAmount float64 `gorm:"column:base_amount" json:"base_amount"` + BaseCurrency string `gorm:"column:base_currency" json:"base_currency"` + ExchangeRate float64 `gorm:"column:exchange_rate" json:"exchange_rate"` + Description string `gorm:"column:description" json:"description"` + Status string `gorm:"column:status;index" json:"status"` + Metadata string `gorm:"column:metadata;type:text" json:"metadata"` + CreatedAt time.Time `gorm:"column:created_at;index" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName returns the table name for the transaction model +func (TransactionModel) TableName() string { + return "transactions" +} + +// BeforeCreate hook for CurrencyModel +func (c *CurrencyModel) BeforeCreate(tx *gorm.DB) error { + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now() + } + c.UpdatedAt = time.Now() + return nil +} + +// BeforeUpdate hook for CurrencyModel +func (c *CurrencyModel) BeforeUpdate(tx *gorm.DB) error { + c.UpdatedAt = time.Now() + return nil +} + +// BeforeCreate hook for ExchangeRateModel +func (e *ExchangeRateModel) BeforeCreate(tx *gorm.DB) error { + if e.CreatedAt.IsZero() { + e.CreatedAt = time.Now() + } + e.UpdatedAt = time.Now() + return nil +} + +// BeforeUpdate hook for ExchangeRateModel +func (e *ExchangeRateModel) BeforeUpdate(tx *gorm.DB) error { + e.UpdatedAt = time.Now() + return nil +} + +// BeforeCreate hook for TransactionModel +func (t *TransactionModel) BeforeCreate(tx *gorm.DB) error { + if t.CreatedAt.IsZero() { + t.CreatedAt = time.Now() + } + t.UpdatedAt = time.Now() + return nil +} + +// BeforeUpdate hook for TransactionModel +func (t *TransactionModel) BeforeUpdate(tx *gorm.DB) error { + t.UpdatedAt = time.Now() + return nil +} diff --git a/apps/carrier-connector/internal/currency/types.go b/apps/carrier-connector/internal/currency/types.go new file mode 100644 index 0000000..fb2f563 --- /dev/null +++ b/apps/carrier-connector/internal/currency/types.go @@ -0,0 +1,163 @@ +package currency + +import ( + "time" +) + +// Currency represents a currency with its properties +type Currency struct { + Code string `json:"code" db:"code"` // ISO 4217 currency code (USD, EUR, etc.) + Name string `json:"name" db:"name"` // Full currency name + Symbol string `json:"symbol" db:"symbol"` // Currency symbol ($, €, etc.) + DecimalPlaces int `json:"decimal_places" db:"decimal_places"` // Number of decimal places + IsActive bool `json:"is_active" db:"is_active"` // Whether currency is active + SupportedRegions []string `json:"supported_regions" db:"supported_regions"` // Supported regions/countries + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ExchangeRate represents an exchange rate between two currencies +type ExchangeRate struct { + ID string `json:"id" db:"id"` + FromCurrency string `json:"from_currency" db:"from_currency"` + ToCurrency string `json:"to_currency" db:"to_currency"` + Rate float64 `json:"rate" db:"rate"` // Exchange rate (1 FromCurrency = Rate ToCurrency) + Source string `json:"source" db:"source"` // Data source (ECB, FED, etc.) + ValidFrom time.Time `json:"valid_from" db:"valid_from"` // When rate becomes valid + ValidTo *time.Time `json:"valid_to,omitempty" db:"valid_to"` // When rate expires (optional) + IsActive bool `json:"is_active" db:"is_active"` // Whether rate is currently active + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Transaction represents a financial transaction in multi-currency context +type Transaction struct { + ID string `json:"id" db:"id"` + ProfileID string `json:"profile_id" db:"profile_id"` + SubscriptionID string `json:"subscription_id" db:"subscription_id"` + Type TransactionType `json:"type" db:"type"` + Amount float64 `json:"amount" db:"amount"` + Currency string `json:"currency" db:"currency"` + BaseAmount float64 `json:"base_amount" db:"base_amount"` // Amount in base currency (USD) + BaseCurrency string `json:"base_currency" db:"base_currency"` // Base currency for reporting + ExchangeRate float64 `json:"exchange_rate" db:"exchange_rate"` // Rate used for conversion + Description string `json:"description" db:"description"` + Status TransactionStatus `json:"status" db:"status"` + Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TransactionType defines the type of transaction +type TransactionType string + +const ( + TransactionTypeSubscription TransactionType = "subscription" + TransactionTypeUsage TransactionType = "usage" + TransactionTypeOverage TransactionType = "overage" + TransactionTypeRefund TransactionType = "refund" + TransactionTypeAdjustment TransactionType = "adjustment" + TransactionTypeDiscount TransactionType = "discount" +) + +// TransactionStatus defines the status of a transaction +type TransactionStatus string + +const ( + TransactionStatusPending TransactionStatus = "pending" + TransactionStatusCompleted TransactionStatus = "completed" + TransactionStatusFailed TransactionStatus = "failed" + TransactionStatusCancelled TransactionStatus = "cancelled" +) + +// CurrencyFilter defines filtering options for currency queries +type CurrencyFilter struct { + IsActive *bool `json:"is_active,omitempty"` + Region string `json:"region,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` +} + +// ExchangeRateFilter defines filtering options for exchange rate queries +type ExchangeRateFilter struct { + FromCurrency string `json:"from_currency,omitempty"` + ToCurrency string `json:"to_currency,omitempty"` + Source string `json:"source,omitempty"` + IsValid *bool `json:"is_valid,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` +} + +// TransactionFilter defines filtering options for transaction queries +type TransactionFilter struct { + ProfileID string `json:"profile_id,omitempty"` + SubscriptionID string `json:"subscription_id,omitempty"` + Type TransactionType `json:"type,omitempty"` + Status TransactionStatus `json:"status,omitempty"` + Currency string `json:"currency,omitempty"` + FromDate *time.Time `json:"from_date,omitempty"` + ToDate *time.Time `json:"to_date,omitempty"` + MinAmount float64 `json:"min_amount,omitempty"` + MaxAmount float64 `json:"max_amount,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` +} + +// CurrencyConversionRequest represents a request to convert an amount between currencies +type CurrencyConversionRequest struct { + Amount float64 `json:"amount" binding:"required,min=0"` + FromCurrency string `json:"from_currency" binding:"required"` + ToCurrency string `json:"to_currency" binding:"required"` +} + +// CurrencyConversionResponse represents the response from currency conversion +type CurrencyConversionResponse struct { + OriginalAmount float64 `json:"original_amount"` + OriginalCurrency string `json:"original_currency"` + ConvertedAmount float64 `json:"converted_amount"` + ConvertedCurrency string `json:"converted_currency"` + ExchangeRate float64 `json:"exchange_rate"` + ConvertedAt time.Time `json:"converted_at"` +} + +// BillingRequest represents a billing request in multi-currency context +type BillingRequest struct { + ProfileID string `json:"profile_id" binding:"required"` + SubscriptionID string `json:"subscription_id" binding:"required"` + Amount float64 `json:"amount" binding:"required,min=0"` + Currency string `json:"currency" binding:"required"` + Description string `json:"description"` + BillingDate time.Time `json:"billing_date"` +} + +// BillingResponse represents the response from billing operation +type BillingResponse struct { + TransactionID string `json:"transaction_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + BaseAmount float64 `json:"base_amount"` + BaseCurrency string `json:"base_currency"` + ExchangeRate float64 `json:"exchange_rate"` + Status string `json:"status"` + ProcessedAt time.Time `json:"processed_at"` +} + +// CurrencyRevenue represents revenue for a specific currency +type CurrencyRevenue struct { + Currency string `json:"currency"` + Revenue float64 `json:"revenue"` +} + +// TransactionTypeStats represents statistics for a transaction type +type TransactionTypeStats struct { + Type TransactionType `json:"type"` + Count int `json:"count"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} diff --git a/apps/carrier-connector/internal/handlers/currency_handlers.go b/apps/carrier-connector/internal/handlers/currency_handlers.go new file mode 100644 index 0000000..6439bea --- /dev/null +++ b/apps/carrier-connector/internal/handlers/currency_handlers.go @@ -0,0 +1,267 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" +) + +// CurrencyHandler handles currency-related HTTP requests +type CurrencyHandler struct { + billingService currency.BillingService + exchangeService currency.ExchangeRateService + logger *logrus.Logger +} + +// NewCurrencyHandler creates a new currency handler +func NewCurrencyHandler(billingService currency.BillingService, exchangeService currency.ExchangeRateService, logger *logrus.Logger) *CurrencyHandler { + return &CurrencyHandler{ + billingService: billingService, + exchangeService: exchangeService, + logger: logger, + } +} + +// ConvertCurrency handles currency conversion requests +func (h *CurrencyHandler) ConvertCurrency(c *gin.Context) { + var req currency.CurrencyConversionRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.WithError(err).Error("Invalid currency conversion request") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.billingService.ConvertAmount(c.Request.Context(), &req) + if err != nil { + h.logger.WithError(err).Error("Currency conversion failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, response) +} + +// GetExchangeRate handles exchange rate requests +func (h *CurrencyHandler) GetExchangeRate(c *gin.Context) { + fromCurrency := c.Param("from") + toCurrency := c.Param("to") + + if fromCurrency == "" || toCurrency == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "from and to currency parameters are required"}) + return + } + + rate, err := h.exchangeService.GetExchangeRate(c.Request.Context(), fromCurrency, toCurrency) + if err != nil { + h.logger.WithError(err).Error("Failed to get exchange rate") + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, rate) +} + +// ProcessBilling handles billing requests +func (h *CurrencyHandler) ProcessBilling(c *gin.Context) { + var req currency.BillingRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.WithError(err).Error("Invalid billing request") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + response, err := h.billingService.ProcessBilling(c.Request.Context(), &req) + if err != nil { + h.logger.WithError(err).Error("Billing processing failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, response) +} + +// GetBillingHistory handles billing history requests +func (h *CurrencyHandler) GetBillingHistory(c *gin.Context) { + profileID := c.Param("profile_id") + if profileID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "profile_id parameter is required"}) + return + } + + // Parse query parameters + filter := ¤cy.TransactionFilter{} + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + filter.Limit = limit + } + } + if offsetStr := c.Query("offset"); offsetStr != "" { + if offset, err := strconv.Atoi(offsetStr); err == nil { + filter.Offset = offset + } + } + if fromDateStr := c.Query("from_date"); fromDateStr != "" { + if fromDate, err := time.Parse("2006-01-02", fromDateStr); err == nil { + filter.FromDate = &fromDate + } + } + if toDateStr := c.Query("to_date"); toDateStr != "" { + if toDate, err := time.Parse("2006-01-02", toDateStr); err == nil { + filter.ToDate = &toDate + } + } + + transactions, err := h.billingService.GetBillingHistory(c.Request.Context(), profileID, filter) + if err != nil { + h.logger.WithError(err).Error("Failed to get billing history") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "transactions": transactions, + "count": len(transactions), + }) +} + +// GetBillingSummary handles billing summary requests +func (h *CurrencyHandler) GetBillingSummary(c *gin.Context) { + profileID := c.Param("profile_id") + if profileID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "profile_id parameter is required"}) + return + } + + // Parse date parameters + fromDateStr := c.Query("from_date") + toDateStr := c.Query("to_date") + + var fromDate, toDate time.Time + var err error + + if fromDateStr != "" { + fromDate, err = time.Parse("2006-01-02", fromDateStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid from_date format, use YYYY-MM-DD"}) + return + } + } else { + fromDate = time.Now().AddDate(0, -1, 0) // Default to 1 month ago + } + + if toDateStr != "" { + toDate, err = time.Parse("2006-01-02", toDateStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid to_date format, use YYYY-MM-DD"}) + return + } + } else { + toDate = time.Now() // Default to now + } + + summary, err := h.billingService.CalculateTotalBilling(c.Request.Context(), profileID, fromDate, toDate) + if err != nil { + h.logger.WithError(err).Error("Failed to calculate billing summary") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, summary) +} + +// ProcessRefund handles refund requests +func (h *CurrencyHandler) ProcessRefund(c *gin.Context) { + transactionID := c.Param("transaction_id") + if transactionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "transaction_id parameter is required"}) + return + } + + var req struct { + Amount float64 `json:"amount" binding:"required,min=0"` + Reason string `json:"reason" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.WithError(err).Error("Invalid refund request") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // This would need to be added to the BillingService interface + // For now, we'll return an error + c.JSON(http.StatusNotImplemented, gin.H{"error": "Refund processing not yet implemented"}) +} + +// GetBillingAnalytics handles billing analytics requests +func (h *CurrencyHandler) GetBillingAnalytics(c *gin.Context) { + // This would need to be added to the BillingService interface + // For now, we'll return an error + c.JSON(http.StatusNotImplemented, gin.H{"error": "Billing analytics not yet implemented"}) +} + +// GetSupportedCurrencies handles supported currencies requests +func (h *CurrencyHandler) GetSupportedCurrencies(c *gin.Context) { + currencies, err := h.exchangeService.GetSupportedCurrencies(c.Request.Context()) + if err != nil { + h.logger.WithError(err).Error("Failed to get supported currencies") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "currencies": currencies, + "count": len(currencies), + }) +} + +// RefreshExchangeRates handles exchange rate refresh requests +func (h *CurrencyHandler) RefreshExchangeRates(c *gin.Context) { + err := h.exchangeService.RefreshRates(c.Request.Context()) + if err != nil { + h.logger.WithError(err).Error("Failed to refresh exchange rates") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Exchange rates refreshed successfully"}) +} + +// GetExchangeRateHistory handles exchange rate history requests +func (h *CurrencyHandler) GetExchangeRateHistory(c *gin.Context) { + fromCurrency := c.Param("from") + toCurrency := c.Param("to") + daysStr := c.Query("days") + + if fromCurrency == "" || toCurrency == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "from and to currency parameters are required"}) + return + } + + days := 30 // Default to 30 days + if daysStr != "" { + if parsedDays, err := strconv.Atoi(daysStr); err == nil && parsedDays > 0 { + days = parsedDays + } + } + + rates, err := h.exchangeService.GetRateHistory(c.Request.Context(), fromCurrency, toCurrency, days) + if err != nil { + h.logger.WithError(err).Error("Failed to get exchange rate history") + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "rates": rates, + "count": len(rates), + "from": fromCurrency, + "to": toCurrency, + "days": days, + }) +} diff --git a/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go b/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go index 16e1d4a..69fe22b 100644 --- a/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go +++ b/apps/carrier-connector/internal/handlers/rateplan_handlers_core.go @@ -11,12 +11,12 @@ import ( // RatePlanHandler handles rate plan API endpoints type RatePlanHandler struct { - service *rateplan.Service + service rateplan.Service logger *logrus.Logger } // NewRatePlanHandler creates a new rate plan handler -func NewRatePlanHandler(service *rateplan.Service) *RatePlanHandler { +func NewRatePlanHandler(service rateplan.Service) *RatePlanHandler { logger := logrus.New() logger.SetLevel(logrus.InfoLevel) diff --git a/apps/carrier-connector/internal/integration/selection_integration.go b/apps/carrier-connector/internal/integration/selection_integration.go index 40d2268..7ff4977 100644 --- a/apps/carrier-connector/internal/integration/selection_integration.go +++ b/apps/carrier-connector/internal/integration/selection_integration.go @@ -8,15 +8,15 @@ import ( "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/handlers" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" - "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/service" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/services" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" ) type SelectionIntegration struct { manager *smdp.SMDPManager - selectionService *service.SelectionService + selectionService *services.SelectionService selectionHandler *handlers.SelectionHandler - smdpService *service.SMDPService + smdpService *services.SMDPService server *http.Server } @@ -33,10 +33,10 @@ func NewSelectionIntegration(repo *repository.PostgresProfileStore) *SelectionIn manager := smdp.NewSMDPManager(repo, config) // Create selection service - selectionService := service.NewSelectionService(manager) + selectionService := services.NewSelectionService(manager) // Create SMDP service - smdpService := service.NewSMDPService(repo) + smdpService := services.NewSMDPService(repo) return &SelectionIntegration{ manager: manager, @@ -85,7 +85,7 @@ func (si *SelectionIntegration) GetManager() *smdp.SMDPManager { } // GetSelectionService returns the selection service for testing -func (si *SelectionIntegration) GetSelectionService() *service.SelectionService { +func (si *SelectionIntegration) GetSelectionService() *services.SelectionService { return si.selectionService } diff --git a/apps/carrier-connector/internal/integration/smdp_integration.go b/apps/carrier-connector/internal/integration/smdp_integration.go index 7ef94dd..b91f7e6 100644 --- a/apps/carrier-connector/internal/integration/smdp_integration.go +++ b/apps/carrier-connector/internal/integration/smdp_integration.go @@ -6,14 +6,14 @@ import ( "github.com/gin-gonic/gin" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/handlers" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" - "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/service" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/services" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/smdp" "github.com/sirupsen/logrus" ) // SMDPIntegration integrates the multi-carrier SM-DP+ system with the existing carrier connector type SMDPIntegration struct { - service *service.SMDPService + service *services.SMDPService handler *handlers.SMDPHandler repository *repository.PostgresProfileStore logger *logrus.Logger @@ -24,7 +24,7 @@ func NewSMDPIntegration(repo *repository.PostgresProfileStore) *SMDPIntegration logger := logrus.New() logger.SetLevel(logrus.InfoLevel) - svc := service.NewSMDPService(repo) + svc := services.NewSMDPService(repo) manager := svc.GetManager() hnd := handlers.NewSMDPHandler(manager) @@ -148,7 +148,7 @@ func (i *SMDPIntegration) metricsHandler(c *gin.Context) { } // GetService returns the SM-DP+ service for direct access -func (i *SMDPIntegration) GetService() *service.SMDPService { +func (i *SMDPIntegration) GetService() *services.SMDPService { return i.service } diff --git a/apps/carrier-connector/internal/rateplan/interface.go b/apps/carrier-connector/internal/rateplan/interface.go index 4896d08..592b221 100644 --- a/apps/carrier-connector/internal/rateplan/interface.go +++ b/apps/carrier-connector/internal/rateplan/interface.go @@ -4,6 +4,36 @@ import ( "context" ) +// Service defines the interface for rate plan business operations +type Service interface { + // Rate Plan operations + CreateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) + GetRatePlan(ctx context.Context, id string) (*RatePlan, error) + UpdateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) + DeleteRatePlan(ctx context.Context, id string) error + ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([]*RatePlan, error) + SearchRatePlans(ctx context.Context, criteria SearchCriteria) ([]*RatePlan, error) + + // Subscription operations + SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (*RatePlanSubscription, error) + GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) + UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) (*RatePlanSubscription, error) + CancelSubscription(ctx context.Context, subscriptionID string, reason string) error + GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) + ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) + + // Usage operations + RecordUsage(ctx context.Context, req *RecordUsageRequest) (*RatePlanUsage, error) + GetUsage(ctx context.Context, id string) (*RatePlanUsage, error) + GetUsageHistory(ctx context.Context, profileID string, limit int) ([]*RatePlanUsage, error) + CalculateCost(ctx context.Context, req *CalculateCostRequest) (*RatePlanCostCalculation, error) + + // Analytics operations + GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) + GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) + GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) +} + // Repository defines the interface for rate plan data operations type Repository interface { // Rate Plan operations diff --git a/apps/carrier-connector/internal/repository/repository_core.go b/apps/carrier-connector/internal/repository/repository_core.go new file mode 100644 index 0000000..f327e0b --- /dev/null +++ b/apps/carrier-connector/internal/repository/repository_core.go @@ -0,0 +1,191 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// GormRepository implements the currency repository using GORM +type GormRepository struct { + db *gorm.DB + logger *logrus.Logger +} + +// NewGormRepository creates a new GORM currency repository +func NewGormRepository(db *gorm.DB, logger *logrus.Logger) *GormRepository { + return &GormRepository{ + db: db, + logger: logger, + } +} + +// CreateCurrency creates a new currency +func (r *GormRepository) CreateCurrency(ctx context.Context, currency *currency.Currency) error { + model := r.currencyToModel(currency) + + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to create currency") + return fmt.Errorf("failed to create currency: %w", err) + } + + return nil +} + +// GetCurrency retrieves a currency by code +func (r *GormRepository) GetCurrency(ctx context.Context, code string) (*currency.Currency, error) { + var model currency.CurrencyModel + if err := r.db.WithContext(ctx).Where("code = ?", code).First(&model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("currency not found: %s", code) + } + r.logger.WithError(err).Error("Failed to get currency") + return nil, fmt.Errorf("failed to get currency: %w", err) + } + + return r.modelToCurrency(&model) +} + +// UpdateCurrency updates an existing currency +func (r *GormRepository) UpdateCurrency(ctx context.Context, currency *currency.Currency) error { + model := r.currencyToModel(currency) + + if err := r.db.WithContext(ctx).Save(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to update currency") + return fmt.Errorf("failed to update currency: %w", err) + } + + return nil +} + +// DeleteCurrency deletes a currency +func (r *GormRepository) DeleteCurrency(ctx context.Context, code string) error { + if err := r.db.WithContext(ctx).Delete(¤cy.CurrencyModel{}, "code = ?", code).Error; err != nil { + r.logger.WithError(err).Error("Failed to delete currency") + return fmt.Errorf("failed to delete currency: %w", err) + } + + return nil +} + +// ListCurrencies retrieves currencies based on filter +func (r *GormRepository) ListCurrencies(ctx context.Context, filter *currency.CurrencyFilter) ([]*currency.Currency, error) { + query := r.db.WithContext(ctx).Model(¤cy.CurrencyModel{}) + + // Apply filters + if filter.IsActive != nil { + query = query.Where("is_active = ?", *filter.IsActive) + } + if filter.Region != "" { + query = query.Where("supported_regions LIKE ?", "%"+filter.Region+"%") + } + + // Apply sorting + if filter.SortBy != "" { + order := filter.SortBy + if filter.SortOrder == "desc" { + order += " DESC" + } + query = query.Order(order) + } else { + query = query.Order("code ASC") + } + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + } + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + + var models []currency.CurrencyModel + if err := query.Find(&models).Error; err != nil { + r.logger.WithError(err).Error("Failed to list currencies") + return nil, fmt.Errorf("failed to list currencies: %w", err) + } + + currencies := make([]*currency.Currency, 0, len(models)) + for _, model := range models { + curr, err := r.modelToCurrency(&model) + if err != nil { + r.logger.WithError(err).Error("Failed to convert currency model") + continue + } + currencies = append(currencies, curr) + } + + return currencies, nil +} + +// CountCurrencies counts currencies based on filter +func (r *GormRepository) CountCurrencies(ctx context.Context, filter *currency.CurrencyFilter) (int, error) { + query := r.db.WithContext(ctx).Model(¤cy.CurrencyModel{}) + + // Apply filters + if filter.IsActive != nil { + query = query.Where("is_active = ?", *filter.IsActive) + } + if filter.Region != "" { + query = query.Where("supported_regions LIKE ?", "%"+filter.Region+"%") + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + r.logger.WithError(err).Error("Failed to count currencies") + return 0, fmt.Errorf("failed to count currencies: %w", err) + } + + return int(count), nil +} + +// CreateExchangeRate creates a new exchange rate +func (r *GormRepository) CreateExchangeRate(ctx context.Context, rate *currency.ExchangeRate) error { + model := r.exchangeRateToModel(rate) + + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to create exchange rate") + return fmt.Errorf("failed to create exchange rate: %w", err) + } + + return nil +} + +// GetExchangeRate retrieves an exchange rate by ID +func (r *GormRepository) GetExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*currency.ExchangeRate, error) { + var model currency.ExchangeRateModel + if err := r.db.WithContext(ctx).Where("from_currency = ? AND to_currency = ?", fromCurrency, toCurrency).First(&model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("exchange rate not found: %s to %s", fromCurrency, toCurrency) + } + r.logger.WithError(err).Error("Failed to get exchange rate") + return nil, fmt.Errorf("failed to get exchange rate: %w", err) + } + + return r.modelToExchangeRate(&model) +} + +// UpdateExchangeRate updates an existing exchange rate +func (r *GormRepository) UpdateExchangeRate(ctx context.Context, rate *currency.ExchangeRate) error { + model := r.exchangeRateToModel(rate) + + if err := r.db.WithContext(ctx).Save(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to update exchange rate") + return fmt.Errorf("failed to update exchange rate: %w", err) + } + + return nil +} + +// DeleteExchangeRate deletes an exchange rate +func (r *GormRepository) DeleteExchangeRate(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(¤cy.ExchangeRateModel{}, "id = ?", id).Error; err != nil { + r.logger.WithError(err).Error("Failed to delete exchange rate") + return fmt.Errorf("failed to delete exchange rate: %w", err) + } + + return nil +} diff --git a/apps/carrier-connector/internal/repository/repository_helpers.go b/apps/carrier-connector/internal/repository/repository_helpers.go new file mode 100644 index 0000000..9e780f3 --- /dev/null +++ b/apps/carrier-connector/internal/repository/repository_helpers.go @@ -0,0 +1,128 @@ +package repository + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" +) + +// currencyToModel converts currency domain model to database model +func (r *GormRepository) currencyToModel(curr *currency.Currency) *currency.CurrencyModel { + return ¤cy.CurrencyModel{ + Code: curr.Code, + Name: curr.Name, + Symbol: curr.Symbol, + DecimalPlaces: curr.DecimalPlaces, + IsActive: curr.IsActive, + SupportedRegions: strings.Join(curr.SupportedRegions, ","), + CreatedAt: curr.CreatedAt, + UpdatedAt: curr.UpdatedAt, + } +} + +// modelToCurrency converts database model to currency domain model +func (r *GormRepository) modelToCurrency(model *currency.CurrencyModel) (*currency.Currency, error) { + var regions []string + if model.SupportedRegions != "" { + regions = strings.Split(model.SupportedRegions, ",") + } + + return ¤cy.Currency{ + Code: model.Code, + Name: model.Name, + Symbol: model.Symbol, + DecimalPlaces: model.DecimalPlaces, + IsActive: model.IsActive, + SupportedRegions: regions, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + }, nil +} + +// exchangeRateToModel converts exchange rate domain model to database model +func (r *GormRepository) exchangeRateToModel(rate *currency.ExchangeRate) *currency.ExchangeRateModel { + return ¤cy.ExchangeRateModel{ + ID: rate.ID, + FromCurrency: rate.FromCurrency, + ToCurrency: rate.ToCurrency, + Rate: rate.Rate, + Source: rate.Source, + ValidFrom: rate.ValidFrom, + ValidTo: rate.ValidTo, + IsActive: rate.IsActive, + CreatedAt: rate.CreatedAt, + UpdatedAt: rate.UpdatedAt, + } +} + +// modelToExchangeRate converts database model to exchange rate domain model +func (r *GormRepository) modelToExchangeRate(model *currency.ExchangeRateModel) (*currency.ExchangeRate, error) { + return ¤cy.ExchangeRate{ + ID: model.ID, + FromCurrency: model.FromCurrency, + ToCurrency: model.ToCurrency, + Rate: model.Rate, + Source: model.Source, + ValidFrom: model.ValidFrom, + ValidTo: model.ValidTo, + IsActive: model.IsActive, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + }, nil +} + +// transactionToModel converts transaction domain model to database model +func (r *GormRepository) transactionToModel(transaction *currency.Transaction) *currency.TransactionModel { + metadata := "" + if transaction.Metadata != nil { + if data, err := json.Marshal(transaction.Metadata); err == nil { + metadata = string(data) + } + } + + return ¤cy.TransactionModel{ + ID: transaction.ID, + ProfileID: transaction.ProfileID, + SubscriptionID: transaction.SubscriptionID, + Type: string(transaction.Type), + Amount: transaction.Amount, + Currency: transaction.Currency, + BaseAmount: transaction.BaseAmount, + BaseCurrency: transaction.BaseCurrency, + ExchangeRate: transaction.ExchangeRate, + Description: transaction.Description, + Status: string(transaction.Status), + Metadata: metadata, + CreatedAt: transaction.CreatedAt, + UpdatedAt: transaction.UpdatedAt, + } +} + +// modelToTransaction converts database model to transaction domain model +func (r *GormRepository) modelToTransaction(model *currency.TransactionModel) (*currency.Transaction, error) { + var metadata map[string]interface{} + if model.Metadata != "" { + if err := json.Unmarshal([]byte(model.Metadata), &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata: %w", err) + } + } + + return ¤cy.Transaction{ + ID: model.ID, + ProfileID: model.ProfileID, + SubscriptionID: model.SubscriptionID, + Type: currency.TransactionType(model.Type), + Amount: model.Amount, + Currency: model.Currency, + BaseAmount: model.BaseAmount, + BaseCurrency: model.BaseCurrency, + ExchangeRate: model.ExchangeRate, + Description: model.Description, + Status: currency.TransactionStatus(model.Status), + Metadata: metadata, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + }, nil +} diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan.go b/apps/carrier-connector/internal/repository/repository_rateplan.go similarity index 93% rename from apps/carrier-connector/internal/rateplan/repository_rateplan.go rename to apps/carrier-connector/internal/repository/repository_rateplan.go index bc3e1d8..c277a9d 100644 --- a/apps/carrier-connector/internal/rateplan/repository_rateplan.go +++ b/apps/carrier-connector/internal/repository/repository_rateplan.go @@ -1,27 +1,13 @@ -package rateplan +package repository 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() diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go b/apps/carrier-connector/internal/repository/repository_rateplan_crud.go similarity index 99% rename from apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go rename to apps/carrier-connector/internal/repository/repository_rateplan_crud.go index d3809f1..1143770 100644 --- a/apps/carrier-connector/internal/rateplan/repository_rateplan_crud.go +++ b/apps/carrier-connector/internal/repository/repository_rateplan_crud.go @@ -1,4 +1,4 @@ -package rateplan +package repository import ( "context" @@ -6,6 +6,8 @@ import ( "time" "gorm.io/gorm" + + ) // CreateRatePlan creates a new rate plan diff --git a/apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go b/apps/carrier-connector/internal/repository/repository_rateplan_subscription.go similarity index 99% rename from apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go rename to apps/carrier-connector/internal/repository/repository_rateplan_subscription.go index d8dca04..b6f6b97 100644 --- a/apps/carrier-connector/internal/rateplan/repository_rateplan_subscription.go +++ b/apps/carrier-connector/internal/repository/repository_rateplan_subscription.go @@ -1,4 +1,4 @@ -package rateplan +package repository import ( "context" @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" ) + // CreateSubscription creates a new rate plan subscription func (r *GormRepository) CreateSubscription(ctx context.Context, subscription *RatePlanSubscription) error { now := time.Now() @@ -85,6 +86,7 @@ func (r *GormRepository) DeleteSubscription(ctx context.Context, id string) erro 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) diff --git a/apps/carrier-connector/internal/repository/repository_rates.go b/apps/carrier-connector/internal/repository/repository_rates.go new file mode 100644 index 0000000..8d2788f --- /dev/null +++ b/apps/carrier-connector/internal/repository/repository_rates.go @@ -0,0 +1,92 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" +) + +// ListExchangeRates retrieves exchange rates based on filter +func (r *GormRepository) ListExchangeRates(ctx context.Context, filter *currency.ExchangeRateFilter) ([]*currency.ExchangeRate, error) { + query := r.db.WithContext(ctx).Model(¤cy.ExchangeRateModel{}) + + // Apply filters + if filter.FromCurrency != "" { + query = query.Where("from_currency = ?", filter.FromCurrency) + } + if filter.ToCurrency != "" { + query = query.Where("to_currency = ?", filter.ToCurrency) + } + if filter.Source != "" { + query = query.Where("source = ?", filter.Source) + } + if filter.IsValid != nil { + now := time.Now() + if *filter.IsValid { + query = query.Where("valid_from <= ? AND (valid_to IS NULL OR valid_to >= ?)", now, now) + } else { + query = query.Where("valid_to < ?", now) + } + } + + // Apply sorting + if filter.SortBy != "" { + order := filter.SortBy + if filter.SortOrder == "desc" { + order += " DESC" + } + query = query.Order(order) + } else { + query = query.Order("from_currency, to_currency, valid_from DESC") + } + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + } + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + + var models []currency.ExchangeRateModel + if err := query.Find(&models).Error; err != nil { + r.logger.WithError(err).Error("Failed to list exchange rates") + return nil, fmt.Errorf("failed to list exchange rates: %w", err) + } + + rates := make([]*currency.ExchangeRate, 0, len(models)) + for _, model := range models { + rate, err := r.modelToExchangeRate(&model) + if err != nil { + r.logger.WithError(err).Error("Failed to convert exchange rate model") + continue + } + rates = append(rates, rate) + } + + return rates, nil +} + +// GetLatestExchangeRate gets the latest valid exchange rate +func (r *GormRepository) GetLatestExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*currency.ExchangeRate, error) { + now := time.Now() + var model currency.ExchangeRateModel + + if err := r.db.WithContext(ctx). + Where("from_currency = ? AND to_currency = ? AND is_active = ?", fromCurrency, toCurrency, true). + Where("valid_from <= ? AND (valid_to IS NULL OR valid_to >= ?)", now, now). + Order("valid_from DESC"). + First(&model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("no valid exchange rate found: %s to %s", fromCurrency, toCurrency) + } + r.logger.WithError(err).Error("Failed to get latest exchange rate") + return nil, fmt.Errorf("failed to get latest exchange rate: %w", err) + } + + return r.modelToExchangeRate(&model) +} diff --git a/apps/carrier-connector/internal/repository/repository_transactions.go b/apps/carrier-connector/internal/repository/repository_transactions.go new file mode 100644 index 0000000..68f6b74 --- /dev/null +++ b/apps/carrier-connector/internal/repository/repository_transactions.go @@ -0,0 +1,171 @@ +package repository + +import ( + "context" + "fmt" + + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" +) + +// CreateTransaction creates a new transaction +func (r *GormRepository) CreateTransaction(ctx context.Context, transaction *currency.Transaction) error { + model := r.transactionToModel(transaction) + + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to create transaction") + return fmt.Errorf("failed to create transaction: %w", err) + } + + return nil +} + +// GetTransaction retrieves a transaction by ID +func (r *GormRepository) GetTransaction(ctx context.Context, id string) (*currency.Transaction, error) { + var model currency.TransactionModel + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("transaction not found: %s", id) + } + r.logger.WithError(err).Error("Failed to get transaction") + return nil, fmt.Errorf("failed to get transaction: %w", err) + } + + return r.modelToTransaction(&model) +} + +// UpdateTransaction updates an existing transaction +func (r *GormRepository) UpdateTransaction(ctx context.Context, transaction *currency.Transaction) error { + model := r.transactionToModel(transaction) + + if err := r.db.WithContext(ctx).Save(model).Error; err != nil { + r.logger.WithError(err).Error("Failed to update transaction") + return fmt.Errorf("failed to update transaction: %w", err) + } + + return nil +} + +// DeleteTransaction deletes a transaction +func (r *GormRepository) DeleteTransaction(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Delete(¤cy.TransactionModel{}, "id = ?", id).Error; err != nil { + r.logger.WithError(err).Error("Failed to delete transaction") + return fmt.Errorf("failed to delete transaction: %w", err) + } + + return nil +} + +// ListTransactions retrieves transactions based on filter +func (r *GormRepository) ListTransactions(ctx context.Context, filter *currency.TransactionFilter) ([]*currency.Transaction, error) { + query := r.db.WithContext(ctx).Model(¤cy.TransactionModel{}) + + // Apply filters + if filter.ProfileID != "" { + query = query.Where("profile_id = ?", filter.ProfileID) + } + if filter.SubscriptionID != "" { + query = query.Where("subscription_id = ?", filter.SubscriptionID) + } + if filter.Type != "" { + query = query.Where("type = ?", filter.Type) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.Currency != "" { + query = query.Where("currency = ?", filter.Currency) + } + if filter.FromDate != nil { + query = query.Where("created_at >= ?", *filter.FromDate) + } + if filter.ToDate != nil { + query = query.Where("created_at <= ?", *filter.ToDate) + } + if filter.MinAmount > 0 { + query = query.Where("amount >= ?", filter.MinAmount) + } + if filter.MaxAmount > 0 { + query = query.Where("amount <= ?", filter.MaxAmount) + } + + // Apply sorting + if filter.SortBy != "" { + order := filter.SortBy + if filter.SortOrder == "desc" { + order += " DESC" + } + query = query.Order(order) + } else { + query = query.Order("created_at DESC") + } + + // Apply pagination + if filter.Limit > 0 { + query = query.Limit(filter.Limit) + } + if filter.Offset > 0 { + query = query.Offset(filter.Offset) + } + + var models []currency.TransactionModel + if err := query.Find(&models).Error; err != nil { + r.logger.WithError(err).Error("Failed to list transactions") + return nil, fmt.Errorf("failed to list transactions: %w", err) + } + + transactions := make([]*currency.Transaction, 0, len(models)) + for _, model := range models { + tx, err := r.modelToTransaction(&model) + if err != nil { + r.logger.WithError(err).Error("Failed to convert transaction model") + continue + } + transactions = append(transactions, tx) + } + + return transactions, nil +} + +// CountTransactions counts transactions based on filter +func (r *GormRepository) CountTransactions(ctx context.Context, filter *currency.TransactionFilter) (int, error) { + query := r.db.WithContext(ctx).Model(¤cy.TransactionModel{}) + + // Apply filters + if filter.ProfileID != "" { + query = query.Where("profile_id = ?", filter.ProfileID) + } + if filter.SubscriptionID != "" { + query = query.Where("subscription_id = ?", filter.SubscriptionID) + } + if filter.Type != "" { + query = query.Where("type = ?", filter.Type) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.Currency != "" { + query = query.Where("currency = ?", filter.Currency) + } + if filter.FromDate != nil { + query = query.Where("created_at >= ?", *filter.FromDate) + } + if filter.ToDate != nil { + query = query.Where("created_at <= ?", *filter.ToDate) + } + if filter.MinAmount > 0 { + query = query.Where("amount >= ?", filter.MinAmount) + } + if filter.MaxAmount > 0 { + query = query.Where("amount <= ?", filter.MaxAmount) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + r.logger.WithError(err).Error("Failed to count transactions") + return 0, fmt.Errorf("failed to count transactions: %w", err) + } + + return int(count), nil +} diff --git a/apps/carrier-connector/internal/repository/types.go b/apps/carrier-connector/internal/repository/types.go new file mode 100644 index 0000000..a58dc94 --- /dev/null +++ b/apps/carrier-connector/internal/repository/types.go @@ -0,0 +1,315 @@ +package repository + +import ( + "context" + "time" +) + +// 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) + + // Subscription operations + CreateSubscription(ctx context.Context, subscription *RatePlanSubscription) error + GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) + UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) error + GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) + ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) + + // Usage 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 operations + GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) + GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) + GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) +} + +// RatePlanUsage represents usage data for a rate plan +type RatePlanUsage struct { + ID string `json:"id" gorm:"primaryKey"` + RatePlanID string `json:"rate_plan_id" gorm:"index"` + ProfileID string `json:"profile_id" gorm:"index"` + CycleStart time.Time `json:"cycle_start"` + CycleEnd time.Time `json:"cycle_end"` + DataUsed int64 `json:"data_used"` + VoiceUsed int64 `json:"voice_used"` + SMSUsed int64 `json:"sms_used"` + LastUpdated time.Time `json:"last_updated"` +} + +// TableName returns the table name for RatePlanUsage +func (RatePlanUsage) TableName() string { + return "rate_plan_usage" +} + +// RatePlanSubscription represents a subscription to a rate plan +type RatePlanSubscription struct { + ID string `json:"id" gorm:"primaryKey"` + ProfileID string `json:"profile_id" gorm:"index"` + RatePlanID string `json:"rate_plan_id" gorm:"index"` + Status SubscriptionStatus `json:"status" gorm:"index"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + BillingCycle BillingCycle `json:"billing_cycle"` + NextBillingDate time.Time `json:"next_billing_date"` + AutoRenew bool `json:"auto_renew"` + CurrentCycle time.Time `json:"current_cycle"` + AppliedDiscounts []string `json:"applied_discounts,omitempty" gorm:"serializer:json"` + Metadata map[string]interface{} `json:"metadata,omitempty" gorm:"serializer:json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName returns the table name for RatePlanSubscription +func (RatePlanSubscription) TableName() string { + return "rate_plan_subscriptions" +} + +// SubscriptionStatus represents the status of a subscription +type SubscriptionStatus string + +const ( + SubscriptionStatusActive SubscriptionStatus = "active" + SubscriptionStatusCancelled SubscriptionStatus = "cancelled" + SubscriptionStatusExpired SubscriptionStatus = "expired" + SubscriptionStatusSuspended SubscriptionStatus = "suspended" +) + +// BillingCycle represents the billing cycle type +type BillingCycle string + +const ( + BillingCycleDaily BillingCycle = "daily" + BillingCycleWeekly BillingCycle = "weekly" + BillingCycleMonthly BillingCycle = "monthly" + BillingCycleQuarterly BillingCycle = "quarterly" + BillingCycleYearly BillingCycle = "yearly" +) + +// 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"` +} + +// RatePlan represents a rate plan +type RatePlan struct { + ID string `json:"id" gorm:"primaryKey"` + Name string `json:"name"` + Description string `json:"description"` + CarrierID string `json:"carrier_id" gorm:"index"` + Region string `json:"region" gorm:"index"` + PlanType PlanType `json:"plan_type"` + BasePrice float64 `json:"base_price"` + Currency string `json:"currency"` + BillingCycle BillingCycle `json:"billing_cycle"` + DataAllowance *DataAllowance `json:"data_allowance,omitempty" gorm:"serializer:json"` + VoiceAllowance *VoiceAllowance `json:"voice_allowance,omitempty" gorm:"serializer:json"` + SMSAllowance *SMSAllowance `json:"sms_allowance,omitempty" gorm:"serializer:json"` + OverageRates *OverageRates `json:"overage_rates,omitempty" gorm:"serializer:json"` + Discounts []*Discount `json:"discounts,omitempty" gorm:"serializer:json"` + ValidFrom time.Time `json:"valid_from"` + ValidTo *time.Time `json:"valid_to,omitempty"` + IsActive bool `json:"is_active"` + Status PlanStatus `json:"status"` + Metadata map[string]interface{} `json:"metadata,omitempty" gorm:"serializer:json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName returns the table name for RatePlan +func (RatePlan) TableName() string { + return "rate_plans" +} + +// PlanType represents the type of rate plan +type PlanType string + +const ( + PlanTypeData PlanType = "data" + PlanTypeVoice PlanType = "voice" + PlanTypeSMS PlanType = "sms" + PlanTypeBundle PlanType = "bundle" +) + +// PlanStatus represents the status of a rate plan +type PlanStatus string + +const ( + PlanStatusActive PlanStatus = "active" + PlanStatusInactive PlanStatus = "inactive" + PlanStatusDraft PlanStatus = "draft" +) + +// DataAllowance represents data allowance configuration +type DataAllowance struct { + Amount int64 `json:"amount"` + Unit string `json:"unit"` + Unlimited bool `json:"unlimited"` +} + +// VoiceAllowance represents voice allowance configuration +type VoiceAllowance struct { + Minutes int64 `json:"minutes"` + Unlimited bool `json:"unlimited"` +} + +// SMSAllowance represents SMS allowance configuration +type SMSAllowance struct { + Messages int64 `json:"messages"` + Unlimited bool `json:"unlimited"` +} + +// OverageRates represents overage rate configuration +type OverageRates struct { + DataRate float64 `json:"data_rate"` + VoiceRate float64 `json:"voice_rate"` + SMSRate float64 `json:"sms_rate"` +} + +// Discount represents a discount configuration +type Discount struct { + ID string `json:"id"` + Type DiscountType `json:"type"` + Value float64 `json:"value"` + IsActive bool `json:"is_active"` +} + +// DiscountType represents the type of discount +type DiscountType string + +const ( + DiscountTypePercentage DiscountType = "percentage" + DiscountTypeFixed DiscountType = "fixed" +) + +// 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"` + IsActive *bool `json:"is_active,omitempty"` + MinPrice float64 `json:"min_price,omitempty"` + MaxPrice float64 `json:"max_price,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"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + +// 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"` +} + +// SearchCriteria defines search criteria for rate plans +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"` +} + +// SubscribeRequest represents a request to subscribe to a rate plan +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"` +} + +// RecordUsageRequest represents a request to record usage +type RecordUsageRequest struct { + ProfileID string `json:"profile_id"` + DataUsed int64 `json:"data_used"` + VoiceUsed int64 `json:"voice_used"` + SMSUsed int64 `json:"sms_used"` +} + +// CalculateCostRequest represents a request to calculate cost +type CalculateCostRequest struct { + RatePlanID string `json:"rate_plan_id"` + DataUsed int64 `json:"data_used"` + VoiceUsed int64 `json:"voice_used"` + SMSUsed int64 `json:"sms_used"` + AppliedDiscounts []string `json:"applied_discounts,omitempty"` +} + +// RatePlanCostCalculation represents the result of a 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]interface{} `json:"breakdown"` + CalculatedAt time.Time `json:"calculated_at"` +} diff --git a/apps/carrier-connector/internal/services/analytics_service.go b/apps/carrier-connector/internal/services/analytics_service.go new file mode 100644 index 0000000..6535854 --- /dev/null +++ b/apps/carrier-connector/internal/services/analytics_service.go @@ -0,0 +1,244 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" + "github.com/sirupsen/logrus" +) + +// AnalyticsServiceImpl handles currency analytics operations +type AnalyticsServiceImpl struct { + repository currency.Repository + logger *logrus.Logger +} + +// NewAnalyticsService creates a new analytics service +func NewAnalyticsService(repository currency.Repository, logger *logrus.Logger) *AnalyticsServiceImpl { + return &AnalyticsServiceImpl{ + repository: repository, + logger: logger, + } +} + +// GetRevenueByCurrency calculates revenue breakdown by currency +func (s *AnalyticsServiceImpl) GetRevenueByCurrency(ctx context.Context, filter *currency.TransactionFilter) (map[string]float64, error) { + if filter == nil { + filter = ¤cy.TransactionFilter{} + } + + filter.Status = currency.TransactionStatusCompleted + + transactions, err := s.repository.ListTransactions(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get transactions for revenue analysis") + return nil, fmt.Errorf("failed to get transactions: %w", err) + } + + revenueByCurrency := make(map[string]float64) + + for _, tx := range transactions { + if tx.Type == currency.TransactionTypeSubscription || tx.Type == currency.TransactionTypeUsage || tx.Type == currency.TransactionTypeOverage { + revenueByCurrency[tx.Currency] += tx.Amount + } + } + + return revenueByCurrency, nil +} + +// GetTransactionVolumeByCurrency calculates transaction volume by currency +func (s *AnalyticsServiceImpl) GetTransactionVolumeByCurrency(ctx context.Context, filter *currency.TransactionFilter) (map[string]int64, error) { + if filter == nil { + filter = ¤cy.TransactionFilter{} + } + + transactions, err := s.repository.ListTransactions(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get transactions for volume analysis") + return nil, fmt.Errorf("failed to get transactions: %w", err) + } + + volumeByCurrency := make(map[string]int64) + + for _, tx := range transactions { + volumeByCurrency[tx.Currency]++ + } + + return volumeByCurrency, nil +} + +// GetExchangeRateTrends retrieves exchange rate trends for a currency pair +func (s *AnalyticsServiceImpl) GetExchangeRateTrends(ctx context.Context, fromCurrency, toCurrency string, days int) ([]*currency.ExchangeRate, error) { + filter := ¤cy.ExchangeRateFilter{ + FromCurrency: fromCurrency, + ToCurrency: toCurrency, + IsValid: &[]bool{false}[0], // Include historical rates + Limit: days, + SortBy: "valid_from", + SortOrder: "desc", + } + + rates, err := s.repository.ListExchangeRates(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get exchange rate trends") + return nil, fmt.Errorf("failed to get exchange rate trends: %w", err) + } + + return rates, nil +} + +// GetCurrencyUsageStats retrieves currency usage statistics +func (s *AnalyticsServiceImpl) GetCurrencyUsageStats(ctx context.Context) (*currency.CurrencyUsageStats, error) { + // Get total currencies + totalCurrencies, err := s.repository.CountCurrencies(ctx, ¤cy.CurrencyFilter{}) + if err != nil { + return nil, fmt.Errorf("failed to count currencies: %w", err) + } + + // Get active currencies + activeCurrencies, err := s.repository.CountCurrencies(ctx, ¤cy.CurrencyFilter{ + IsActive: &[]bool{true}[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to count active currencies: %w", err) + } + + // Get total transactions + totalTransactions, err := s.repository.CountTransactions(ctx, ¤cy.TransactionFilter{}) + if err != nil { + return nil, fmt.Errorf("failed to count transactions: %w", err) + } + + // Get total volume + transactions, err := s.repository.ListTransactions(ctx, ¤cy.TransactionFilter{ + Status: currency.TransactionStatusCompleted, + }) + if err != nil { + return nil, fmt.Errorf("failed to get transactions for volume: %w", err) + } + + totalVolume := 0.0 + currencyDistribution := make(map[string]int64) + + for _, tx := range transactions { + totalVolume += tx.BaseAmount + currencyDistribution[tx.Currency]++ + } + + // Find most used currency + mostUsedCurrency := "" + maxCount := int64(0) + + for currency, count := range currencyDistribution { + if count > maxCount { + maxCount = count + mostUsedCurrency = currency + } + } + + // Get exchange rate count (using ListExchangeRates for now since CountExchangeRates doesn't exist) + exchangeRates, err := s.repository.ListExchangeRates(ctx, ¤cy.ExchangeRateFilter{}) + if err != nil { + return nil, fmt.Errorf("failed to count exchange rates: %w", err) + } + + return ¤cy.CurrencyUsageStats{ + TotalCurrencies: totalCurrencies, + ActiveCurrencies: activeCurrencies, + TotalTransactions: int64(totalTransactions), + TotalVolume: totalVolume, + MostUsedCurrency: mostUsedCurrency, + CurrencyDistribution: currencyDistribution, + ExchangeRateCount: len(exchangeRates), + LastUpdated: time.Now(), + }, nil +} + +// GetMonthlyRevenueTrends calculates monthly revenue trends +func (s *AnalyticsServiceImpl) GetMonthlyRevenueTrends(ctx context.Context, months int) (map[string]float64, error) { + endDate := time.Now() + startDate := endDate.AddDate(0, -months, 0) + + filter := ¤cy.TransactionFilter{ + Status: currency.TransactionStatusCompleted, + FromDate: &startDate, + ToDate: &endDate, + } + + transactions, err := s.repository.ListTransactions(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get transactions for monthly trends") + return nil, fmt.Errorf("failed to get transactions: %w", err) + } + + monthlyRevenue := make(map[string]float64) + + for _, tx := range transactions { + monthKey := tx.CreatedAt.Format("2006-01") + monthlyRevenue[monthKey] += tx.BaseAmount + } + + return monthlyRevenue, nil +} + +// GetTopCurrenciesByRevenue returns top currencies by revenue +func (s *AnalyticsServiceImpl) GetTopCurrenciesByRevenue(ctx context.Context, limit int) ([]*currency.CurrencyRevenue, error) { + revenueByCurrency, err := s.GetRevenueByCurrency(ctx, ¤cy.TransactionFilter{ + Status: currency.TransactionStatusCompleted, + }) + if err != nil { + return nil, err + } + + // Convert to slice and sort + var currencyRevenues []*currency.CurrencyRevenue + for currCode, revenue := range revenueByCurrency { + currencyRevenues = append(currencyRevenues, ¤cy.CurrencyRevenue{ + Currency: currCode, + Revenue: revenue, + }) + } + + // Simple sort (in production, use proper sorting) + if len(currencyRevenues) > limit { + currencyRevenues = currencyRevenues[:limit] + } + + return currencyRevenues, nil +} + +// GetTransactionTypeAnalytics returns analytics by transaction type +func (s *AnalyticsServiceImpl) GetTransactionTypeAnalytics(ctx context.Context, filter *currency.TransactionFilter) (map[string]*currency.TransactionTypeStats, error) { + if filter == nil { + filter = ¤cy.TransactionFilter{} + } + + transactions, err := s.repository.ListTransactions(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get transactions for type analytics") + return nil, fmt.Errorf("failed to get transactions: %w", err) + } + + typeStats := make(map[string]*currency.TransactionTypeStats) + + for _, tx := range transactions { + typeKey := string(tx.Type) + + if _, exists := typeStats[typeKey]; !exists { + typeStats[typeKey] = ¤cy.TransactionTypeStats{ + Type: tx.Type, + Count: 0, + Amount: 0.0, + Currency: tx.Currency, + } + } + + stats := typeStats[typeKey] + stats.Count++ + stats.Amount += tx.BaseAmount + } + + return typeStats, nil +} diff --git a/apps/carrier-connector/internal/services/billing_core.go b/apps/carrier-connector/internal/services/billing_core.go new file mode 100644 index 0000000..311b747 --- /dev/null +++ b/apps/carrier-connector/internal/services/billing_core.go @@ -0,0 +1,149 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" + "github.com/sirupsen/logrus" +) + +// BillingServiceImpl handles multi-currency billing operations +type BillingServiceImpl struct { + repository currency.Repository + exchangeService currency.ExchangeRateService + logger *logrus.Logger + baseCurrency string +} + +// NewBillingService creates a new billing service +func NewBillingService(repository currency.Repository, exchangeService currency.ExchangeRateService, logger *logrus.Logger, baseCurrency string) *BillingServiceImpl { + return &BillingServiceImpl{ + repository: repository, + exchangeService: exchangeService, + logger: logger, + baseCurrency: baseCurrency, + } +} + +// ProcessBilling processes a billing request in multi-currency context +func (s *BillingServiceImpl) ProcessBilling(ctx context.Context, req *currency.BillingRequest) (*currency.BillingResponse, error) { + // Validate request + if err := s.validateBillingRequest(req); err != nil { + return nil, fmt.Errorf("invalid billing request: %w", err) + } + + // Convert to base currency if needed + baseAmount := req.Amount + exchangeRate := 1.0 + + if req.Currency != s.baseCurrency { + conversion, err := s.exchangeService.ConvertAmount(ctx, req.Amount, req.Currency, s.baseCurrency) + if err != nil { + s.logger.WithError(err).Error("Failed to convert currency for billing") + return nil, fmt.Errorf("currency conversion failed: %w", err) + } + baseAmount = conversion.ConvertedAmount + exchangeRate = conversion.ExchangeRate + } + + // Create transaction + transaction := ¤cy.Transaction{ + ID: uuid.New().String(), + ProfileID: req.ProfileID, + SubscriptionID: req.SubscriptionID, + Type: currency.TransactionTypeSubscription, + Amount: req.Amount, + Currency: req.Currency, + BaseAmount: baseAmount, + BaseCurrency: s.baseCurrency, + ExchangeRate: exchangeRate, + Description: req.Description, + Status: currency.TransactionStatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Save transaction + if err := s.repository.CreateTransaction(ctx, transaction); err != nil { + s.logger.WithError(err).Error("Failed to create billing transaction") + return nil, fmt.Errorf("failed to create transaction: %w", err) + } + + // Process payment (in real implementation, this would integrate with payment gateway) + transaction.Status = currency.TransactionStatusCompleted + if err := s.repository.UpdateTransaction(ctx, transaction); err != nil { + s.logger.WithError(err).Error("Failed to update transaction status") + return nil, fmt.Errorf("failed to update transaction: %w", err) + } + + s.logger.WithFields(logrus.Fields{ + "transaction_id": transaction.ID, + "profile_id": req.ProfileID, + "amount": req.Amount, + "currency": req.Currency, + "base_amount": baseAmount, + "base_currency": s.baseCurrency, + }).Info("Billing processed successfully") + + return ¤cy.BillingResponse{ + TransactionID: transaction.ID, + Amount: transaction.Amount, + Currency: transaction.Currency, + BaseAmount: transaction.BaseAmount, + BaseCurrency: transaction.BaseCurrency, + ExchangeRate: transaction.ExchangeRate, + Status: string(transaction.Status), + ProcessedAt: time.Now(), + }, nil +} + +// ConvertAmount converts an amount between currencies +func (s *BillingServiceImpl) ConvertAmount(ctx context.Context, req *currency.CurrencyConversionRequest) (*currency.CurrencyConversionResponse, error) { + return s.exchangeService.ConvertAmount(ctx, req.Amount, req.FromCurrency, req.ToCurrency) +} + +// GetBillingHistory retrieves billing history for a profile +func (s *BillingServiceImpl) GetBillingHistory(ctx context.Context, profileID string, filter *currency.TransactionFilter) ([]*currency.Transaction, error) { + if filter == nil { + filter = ¤cy.TransactionFilter{} + } + + filter.ProfileID = profileID + + transactions, err := s.repository.ListTransactions(ctx, filter) + if err != nil { + s.logger.WithError(err).Error("Failed to get billing history") + return nil, fmt.Errorf("failed to get billing history: %w", err) + } + + return transactions, nil +} + +// validateBillingRequest validates a billing request +func (s *BillingServiceImpl) validateBillingRequest(req *currency.BillingRequest) error { + if req.ProfileID == "" { + return fmt.Errorf("profile ID is required") + } + if req.SubscriptionID == "" { + return fmt.Errorf("subscription ID is required") + } + if req.Amount <= 0 { + return fmt.Errorf("amount must be positive") + } + if req.Currency == "" { + return fmt.Errorf("currency is required") + } + if req.BillingDate.IsZero() { + req.BillingDate = time.Now() + } + + // Validate currency + if err := s.exchangeService.ValidateCurrencyPair(context.Background(), req.Currency, s.baseCurrency); err != nil { + return fmt.Errorf("invalid currency: %w", err) + } + + return nil +} diff --git a/apps/carrier-connector/internal/services/exchange_rate_service.go b/apps/carrier-connector/internal/services/exchange_rate_service.go new file mode 100644 index 0000000..e521725 --- /dev/null +++ b/apps/carrier-connector/internal/services/exchange_rate_service.go @@ -0,0 +1,197 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" + "github.com/sirupsen/logrus" +) + +type ExchangeRateService struct { + repository currency.Repository + logger *logrus.Logger + providers []currency.ExchangeRateProvider + baseCurrency string +} + +func NewExchangeRateService(repository currency.Repository, logger *logrus.Logger, baseCurrency string) *ExchangeRateService { + return &ExchangeRateService{ + repository: repository, + logger: logger, + providers: make([]currency.ExchangeRateProvider, 0), + baseCurrency: baseCurrency, + } +} + +func (s *ExchangeRateService) AddProvider(provider currency.ExchangeRateProvider) { + s.providers = append(s.providers, provider) +} + +func (s *ExchangeRateService) GetExchangeRate(ctx context.Context, fromCurrency, toCurrency string) (*currency.ExchangeRate, error) { + if fromCurrency == toCurrency { + return ¤cy.ExchangeRate{ + FromCurrency: fromCurrency, + ToCurrency: toCurrency, + Rate: 1.0, + Source: "direct", + ValidFrom: time.Now(), + IsActive: true, + }, nil + } + + rate, err := s.repository.GetLatestExchangeRate(ctx, fromCurrency, toCurrency) + if err == nil { + return rate, nil + } + + for _, provider := range s.providers { + providerRate, err := provider.GetRate(ctx, fromCurrency, toCurrency) + if err == nil { + newRate := ¤cy.ExchangeRate{ + ID: fmt.Sprintf("%s_%s_%d", fromCurrency, toCurrency, time.Now().Unix()), + FromCurrency: fromCurrency, + ToCurrency: toCurrency, + Rate: providerRate, + Source: "provider", + ValidFrom: time.Now(), + IsActive: true, + } + + if err := s.repository.CreateExchangeRate(ctx, newRate); err != nil { + s.logger.WithError(err).Error("Failed to save exchange rate") + } + + return newRate, nil + } + } + + return nil, fmt.Errorf("exchange rate not found: %s to %s", fromCurrency, toCurrency) +} + +func (s *ExchangeRateService) ConvertAmount(ctx context.Context, amount float64, fromCurrency, toCurrency string) (*currency.CurrencyConversionResponse, error) { + rate, err := s.GetExchangeRate(ctx, fromCurrency, toCurrency) + if err != nil { + return nil, fmt.Errorf("failed to get exchange rate: %w", err) + } + + convertedAmount := amount * rate.Rate + + return ¤cy.CurrencyConversionResponse{ + OriginalAmount: amount, + OriginalCurrency: fromCurrency, + ConvertedAmount: convertedAmount, + ConvertedCurrency: toCurrency, + ExchangeRate: rate.Rate, + ConvertedAt: time.Now(), + }, nil +} + +func (s *ExchangeRateService) RefreshRates(ctx context.Context) error { + s.logger.Info("Refreshing exchange rates") + + for _, provider := range s.providers { + if err := provider.RefreshRates(ctx); err != nil { + s.logger.WithError(err).Error("Failed to refresh rates from provider") + continue + } + } + + s.logger.Info("Exchange rates refreshed successfully") + return nil +} + +func (s *ExchangeRateService) GetRateHistory(ctx context.Context, fromCurrency, toCurrency string, days int) ([]*currency.ExchangeRate, error) { + filter := ¤cy.ExchangeRateFilter{ + FromCurrency: fromCurrency, + ToCurrency: toCurrency, + IsValid: &[]bool{false}[0], // Include historical rates + Limit: days, + SortBy: "valid_from", + SortOrder: "desc", + } + + rates, err := s.repository.ListExchangeRates(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get rate history: %w", err) + } + + return rates, nil +} + +func (s *ExchangeRateService) UpdateExchangeRate(ctx context.Context, rate *currency.ExchangeRate) error { + if rate.Rate <= 0 { + return fmt.Errorf("invalid exchange rate: must be positive") + } + + if rate.FromCurrency == rate.ToCurrency { + return fmt.Errorf("invalid currency pair: from and to currencies cannot be the same") + } + + now := time.Now() + rate.ValidFrom = now + rate.IsActive = true + + filter := ¤cy.ExchangeRateFilter{ + FromCurrency: rate.FromCurrency, + ToCurrency: rate.ToCurrency, + IsValid: &[]bool{true}[0], + } + + oldRates, err := s.repository.ListExchangeRates(ctx, filter) + if err == nil { + for _, oldRate := range oldRates { + oldRate.IsActive = false + if err := s.repository.UpdateExchangeRate(ctx, oldRate); err != nil { + s.logger.WithError(err).Error("Failed to deactivate old exchange rate") + } + } + } + + rate.ID = fmt.Sprintf("%s_%s_%d", rate.FromCurrency, rate.ToCurrency, time.Now().Unix()) + if err := s.repository.CreateExchangeRate(ctx, rate); err != nil { + return fmt.Errorf("failed to create exchange rate: %w", err) + } + + s.logger.WithFields(logrus.Fields{ + "from_currency": rate.FromCurrency, + "to_currency": rate.ToCurrency, + "rate": rate.Rate, + "source": rate.Source, + }).Info("Exchange rate updated") + + return nil +} + +func (s *ExchangeRateService) GetSupportedCurrencies(ctx context.Context) ([]*currency.Currency, error) { + filter := ¤cy.CurrencyFilter{ + IsActive: &[]bool{true}[0], + } + + currencies, err := s.repository.ListCurrencies(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get supported currencies: %w", err) + } + + return currencies, nil +} + +func (s *ExchangeRateService) ValidateCurrencyPair(ctx context.Context, fromCurrency, toCurrency string) error { + _, err := s.repository.GetCurrency(ctx, fromCurrency) + if err != nil { + return fmt.Errorf("unsupported from currency: %s", fromCurrency) + } + + _, err = s.repository.GetCurrency(ctx, toCurrency) + if err != nil { + return fmt.Errorf("unsupported to currency: %s", toCurrency) + } + + _, err = s.GetExchangeRate(ctx, fromCurrency, toCurrency) + if err != nil { + return fmt.Errorf("no exchange rate available: %s to %s", fromCurrency, toCurrency) + } + + return nil +} diff --git a/apps/carrier-connector/internal/services/rateplan_core.go b/apps/carrier-connector/internal/services/rateplan_core.go new file mode 100644 index 0000000..a98d2f4 --- /dev/null +++ b/apps/carrier-connector/internal/services/rateplan_core.go @@ -0,0 +1,173 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/currency" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// RatePlanCurrencyIntegrator integrates currency system with rate plans +type RatePlanCurrencyIntegrator struct { + billingService currency.BillingService + exchangeService *ExchangeRateService + ratePlanService rateplan.Service + logger *logrus.Logger + baseCurrency string +} + +// NewRatePlanCurrencyIntegrator creates a new rate plan currency integrator +func NewRatePlanCurrencyIntegrator( + billingService currency.BillingService, + exchangeService *ExchangeRateService, + ratePlanService rateplan.Service, + logger *logrus.Logger, + baseCurrency string, +) *RatePlanCurrencyIntegrator { + return &RatePlanCurrencyIntegrator{ + billingService: billingService, + exchangeService: exchangeService, + ratePlanService: ratePlanService, + logger: logger, + baseCurrency: baseCurrency, + } +} + +// SubscribeToPlanWithCurrency subscribes to a rate plan with currency conversion +func (rpci *RatePlanCurrencyIntegrator) SubscribeToPlanWithCurrency(ctx context.Context, profileID string, planID string, targetCurrency string) (*rateplan.RatePlanSubscription, error) { + // Get the rate plan + plan, err := rpci.ratePlanService.GetRatePlan(ctx, planID) + if err != nil { + return nil, fmt.Errorf("failed to get rate plan: %w", err) + } + + // Convert price to requested currency if needed + subscriptionPrice := plan.BasePrice + exchangeRate := 1.0 + + if targetCurrency != plan.Currency { + conversion, err := rpci.exchangeService.ConvertAmount(ctx, plan.BasePrice, plan.Currency, targetCurrency) + if err != nil { + rpci.logger.WithError(err).Error("Failed to convert rate plan price") + return nil, fmt.Errorf("currency conversion failed: %w", err) + } + subscriptionPrice = conversion.ConvertedAmount + exchangeRate = conversion.ExchangeRate + } + + // Create subscription with currency information + subscription := &rateplan.RatePlanSubscription{ + ProfileID: profileID, + RatePlanID: planID, + Status: rateplan.SubscriptionStatusActive, + StartedAt: time.Now(), + Metadata: map[string]interface{}{ + "original_currency": plan.Currency, + "subscription_currency": targetCurrency, + "original_price": plan.BasePrice, + "subscription_price": subscriptionPrice, + "exchange_rate": exchangeRate, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Create subscription request + subscribeReq := &rateplan.SubscribeRequest{ + ProfileID: profileID, + RatePlanID: planID, + AutoRenew: true, + Metadata: subscription.Metadata, + } + + createdSubscription, err := rpci.ratePlanService.SubscribeToPlan(ctx, subscribeReq) + if err != nil { + return nil, fmt.Errorf("failed to create subscription: %w", err) + } + + // Process initial billing + billingReq := ¤cy.BillingRequest{ + ProfileID: profileID, + SubscriptionID: createdSubscription.ID, + Amount: subscriptionPrice, + Currency: targetCurrency, + Description: fmt.Sprintf("Initial subscription to %s", plan.Name), + BillingDate: time.Now(), + } + + _, err = rpci.billingService.ProcessBilling(ctx, billingReq) + if err != nil { + rpci.logger.WithError(err).Error("Failed to process initial billing") + // Don't fail the subscription if billing fails, but log it + } + + rpci.logger.WithFields(logrus.Fields{ + "profile_id": profileID, + "plan_id": planID, + "currency": targetCurrency, + "subscription_id": createdSubscription.ID, + }).Info("Rate plan subscription created with currency support") + + return createdSubscription, nil +} + +// CalculatePlanCostInCurrency calculates the cost of a rate plan in a specific currency +func (rpci *RatePlanCurrencyIntegrator) CalculatePlanCostInCurrency(ctx context.Context, planID string, targetCurrency string, usageData *rateplan.RatePlanUsage) (*currency.BillingSummary, error) { + // Get the rate plan + plan, err := rpci.ratePlanService.GetRatePlan(ctx, planID) + if err != nil { + return nil, fmt.Errorf("failed to get rate plan: %w", err) + } + + // Calculate base cost + baseCost := plan.BasePrice + + // Add overage costs if usage data is provided + if usageData != nil { + overageCost, err := rpci.calculateOverageCost(ctx, plan, usageData) + if err != nil { + rpci.logger.WithError(err).Warn("Failed to calculate overage cost") + } else { + baseCost += overageCost + } + } + + // Convert to requested currency + convertedCost := baseCost + exchangeRate := 1.0 + + if targetCurrency != plan.Currency { + conversion, err := rpci.exchangeService.ConvertAmount(ctx, baseCost, plan.Currency, targetCurrency) + if err != nil { + return nil, fmt.Errorf("currency conversion failed: %w", err) + } + convertedCost = conversion.ConvertedAmount + exchangeRate = conversion.ExchangeRate + } + + // Create billing summary + summary := ¤cy.BillingSummary{ + ProfileID: usageData.ProfileID, + TotalAmount: convertedCost, + Currency: targetCurrency, + BaseTotalAmount: baseCost, + BaseCurrency: plan.Currency, + TransactionCount: 1, + FromDate: time.Now().AddDate(0, -1, 0), + ToDate: time.Now(), + Breakdown: map[string]interface{}{ + "plan_id": planID, + "plan_name": plan.Name, + "base_cost": plan.BasePrice, + "overage_cost": baseCost - plan.BasePrice, + "exchange_rate": exchangeRate, + "original_currency": plan.Currency, + }, + } + + return summary, nil +} diff --git a/apps/carrier-connector/internal/services/rateplan_methods.go b/apps/carrier-connector/internal/services/rateplan_methods.go new file mode 100644 index 0000000..c05dc9f --- /dev/null +++ b/apps/carrier-connector/internal/services/rateplan_methods.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/rateplan" +) + +// GetPlansInCurrency gets rate plans with prices converted to a specific currency +func (rpci *RatePlanCurrencyIntegrator) GetPlansInCurrency(ctx context.Context, filter *rateplan.RatePlanFilter, targetCurrency string) ([]*rateplan.RatePlan, error) { + // Get plans using original filter + plans, err := rpci.ratePlanService.ListRatePlans(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to get rate plans: %w", err) + } + + // Convert prices to target currency + for _, plan := range plans { + if plan.Currency != targetCurrency { + conversion, err := rpci.exchangeService.ConvertAmount(ctx, plan.BasePrice, plan.Currency, targetCurrency) + if err != nil { + rpci.logger.WithError(err).WithFields(logrus.Fields{ + "plan_id": plan.ID, + "from_currency": plan.Currency, + "to_currency": targetCurrency, + }).Warn("Failed to convert plan price") + continue + } + + // Store original price and update with converted price + if plan.Metadata == nil { + plan.Metadata = make(map[string]interface{}) + } + plan.Metadata["original_price"] = plan.BasePrice + plan.Metadata["original_currency"] = plan.Currency + plan.Metadata["exchange_rate"] = conversion.ExchangeRate + plan.BasePrice = conversion.ConvertedAmount + plan.Currency = targetCurrency + } + } + + return plans, nil +} + +// UpdatePlanCurrency updates a rate plan's currency and converts prices +func (rpci *RatePlanCurrencyIntegrator) UpdatePlanCurrency(ctx context.Context, planID string, newCurrency string) error { + // Get the current plan + plan, err := rpci.ratePlanService.GetRatePlan(ctx, planID) + if err != nil { + return fmt.Errorf("failed to get rate plan: %w", err) + } + + // If currency is the same, no conversion needed + if plan.Currency == newCurrency { + return nil + } + + // Convert all monetary values to new currency + convertedPrice, err := rpci.exchangeService.ConvertAmount(ctx, plan.BasePrice, plan.Currency, newCurrency) + if err != nil { + return fmt.Errorf("failed to convert base price: %w", err) + } + + // Update plan with new currency and converted prices + plan.BasePrice = convertedPrice.ConvertedAmount + plan.Currency = newCurrency + + // Store conversion information in metadata + if plan.Metadata == nil { + plan.Metadata = make(map[string]interface{}) + } + plan.Metadata["currency_conversion"] = map[string]interface{}{ + "from_currency": plan.Metadata["original_currency"], + "to_currency": newCurrency, + "exchange_rate": convertedPrice.ExchangeRate, + "converted_at": time.Now(), + } + + // Update the plan + updatedPlan, err := rpci.ratePlanService.UpdateRatePlan(ctx, plan) + if err != nil { + return fmt.Errorf("failed to update rate plan: %w", err) + } + + // Use the updated plan for logging + plan = updatedPlan + + rpci.logger.WithFields(logrus.Fields{ + "plan_id": planID, + "from_currency": plan.Metadata["original_currency"], + "to_currency": newCurrency, + "exchange_rate": convertedPrice.ExchangeRate, + }).Info("Rate plan currency updated") + + return nil +} + +// calculateOverageCost calculates overage costs for usage +func (rpci *RatePlanCurrencyIntegrator) calculateOverageCost(ctx context.Context, plan *rateplan.RatePlan, usage *rateplan.RatePlanUsage) (float64, error) { + overageCost := 0.0 + + // Calculate data overage + if plan.DataAllowance != nil && usage.DataUsed > plan.DataAllowance.Amount { + dataOverage := usage.DataUsed - plan.DataAllowance.Amount + if plan.OverageRates != nil { + overageCost += float64(dataOverage) * plan.OverageRates.DataRate + } + } + + // Calculate voice overage + if plan.VoiceAllowance != nil && usage.VoiceUsed > plan.VoiceAllowance.Minutes { + voiceOverage := usage.VoiceUsed - plan.VoiceAllowance.Minutes + if plan.OverageRates != nil { + overageCost += float64(voiceOverage) * plan.OverageRates.VoiceRate + } + } + + // Calculate SMS overage + if plan.SMSAllowance != nil && usage.SMSUsed > plan.SMSAllowance.Messages { + smsOverage := usage.SMSUsed - plan.SMSAllowance.Messages + if plan.OverageRates != nil { + overageCost += float64(smsOverage) * plan.OverageRates.SMSRate + } + } + + return overageCost, nil +} + +// GetCurrencyUsageForPlan gets currency usage statistics for a specific rate plan +func (rpci *RatePlanCurrencyIntegrator) GetCurrencyUsageForPlan(ctx context.Context, planID string) (map[string]int64, error) { + // Get all subscriptions for this plan + filter := &rateplan.SubscriptionFilter{ + RatePlanID: planID, + Status: rateplan.SubscriptionStatusActive, + } + + subscriptions, err := rpci.ratePlanService.ListSubscriptions(ctx, "", filter) + if err != nil { + return nil, fmt.Errorf("failed to get subscriptions: %w", err) + } + + // Count currencies + currencyUsage := make(map[string]int64) + + for _, subscription := range subscriptions { + currency := rpci.baseCurrency // Default to base currency + if subscription.Metadata != nil { + if subCurrency, exists := subscription.Metadata["subscription_currency"]; exists { + if subCurrencyStr, ok := subCurrency.(string); ok { + currency = subCurrencyStr + } + } + } + currencyUsage[currency]++ + } + + return currencyUsage, nil +} diff --git a/apps/carrier-connector/internal/service/selection_analytics.go b/apps/carrier-connector/internal/services/selection_analytics.go similarity index 99% rename from apps/carrier-connector/internal/service/selection_analytics.go rename to apps/carrier-connector/internal/services/selection_analytics.go index 00004ff..f270d5d 100644 --- a/apps/carrier-connector/internal/service/selection_analytics.go +++ b/apps/carrier-connector/internal/services/selection_analytics.go @@ -1,4 +1,4 @@ -package service +package services import ( "context" diff --git a/apps/carrier-connector/internal/service/selection_service.go b/apps/carrier-connector/internal/services/selection_service.go similarity index 99% rename from apps/carrier-connector/internal/service/selection_service.go rename to apps/carrier-connector/internal/services/selection_service.go index 3a620cf..451ca3b 100644 --- a/apps/carrier-connector/internal/service/selection_service.go +++ b/apps/carrier-connector/internal/services/selection_service.go @@ -1,4 +1,4 @@ -package service +package services import ( "context" diff --git a/apps/carrier-connector/internal/rateplan/service.go b/apps/carrier-connector/internal/services/service.go similarity index 79% rename from apps/carrier-connector/internal/rateplan/service.go rename to apps/carrier-connector/internal/services/service.go index 97ed35b..63860fd 100644 --- a/apps/carrier-connector/internal/rateplan/service.go +++ b/apps/carrier-connector/internal/services/service.go @@ -1,4 +1,4 @@ -package rateplan +package services import ( "context" @@ -6,24 +6,25 @@ import ( "time" "github.com/google/uuid" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" "github.com/sirupsen/logrus" ) // Service provides business logic for rate plan operations type Service struct { - repo Repository + repo repository.Repository logger *logrus.Logger } // NewService creates a new rate plan service -func NewService(repo Repository, logger *logrus.Logger) *Service { +func NewService(repo repository.Repository, logger *logrus.Logger) *Service { return &Service{ repo: repo, logger: logger, } } -func (s *Service) CreateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) { +func (s *Service) CreateRatePlan(ctx context.Context, plan *repository.RatePlan) (*repository.RatePlan, error) { // Generate ID if not provided if plan.ID == "" { plan.ID = uuid.New().String() @@ -45,7 +46,7 @@ func (s *Service) CreateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan } // GetRatePlan retrieves a rate plan by ID -func (s *Service) GetRatePlan(ctx context.Context, id string) (*RatePlan, error) { +func (s *Service) GetRatePlan(ctx context.Context, id string) (*repository.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") @@ -56,7 +57,7 @@ func (s *Service) GetRatePlan(ctx context.Context, id string) (*RatePlan, error) } // UpdateRatePlan updates an existing rate plan -func (s *Service) UpdateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan, error) { +func (s *Service) UpdateRatePlan(ctx context.Context, plan *repository.RatePlan) (*repository.RatePlan, error) { // Validate rate plan if err := s.validateRatePlan(plan); err != nil { return nil, fmt.Errorf("validation failed: %w", err) @@ -81,9 +82,9 @@ func (s *Service) UpdateRatePlan(ctx context.Context, plan *RatePlan) (*RatePlan // 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{ + subscriptions, err := s.repo.ListSubscriptions(ctx, "", &repository.SubscriptionFilter{ RatePlanID: id, - Status: SubscriptionStatusActive, + Status: repository.SubscriptionStatusActive, Limit: 1, }) if err != nil { @@ -105,7 +106,7 @@ func (s *Service) DeleteRatePlan(ctx context.Context, id string) error { } // ListRatePlans retrieves rate plans with filtering -func (s *Service) ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([]*RatePlan, error) { +func (s *Service) ListRatePlans(ctx context.Context, filter *repository.RatePlanFilter) ([]*repository.RatePlan, error) { plans, err := s.repo.ListRatePlans(ctx, filter) if err != nil { s.logger.WithError(err).Error("Failed to list rate plans") @@ -116,12 +117,12 @@ func (s *Service) ListRatePlans(ctx context.Context, filter *RatePlanFilter) ([] } // SearchRatePlans searches for rate plans based on criteria -func (s *Service) SearchRatePlans(ctx context.Context, criteria SearchCriteria) ([]*RatePlan, error) { - filter := &RatePlanFilter{ +func (s *Service) SearchRatePlans(ctx context.Context, criteria repository.SearchCriteria) ([]*repository.RatePlan, error) { + filter := &repository.RatePlanFilter{ CarrierID: criteria.CarrierID, Region: criteria.Region, PlanType: criteria.PlanType, - Status: PlanStatusActive, + Status: repository.PlanStatusActive, IsActive: &[]bool{true}[0], MinPrice: criteria.MinPrice, MaxPrice: criteria.MaxPrice, @@ -133,8 +134,9 @@ func (s *Service) SearchRatePlans(ctx context.Context, criteria SearchCriteria) 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) { +func (s *Service) CalculateCost(ctx context.Context, req *repository.CalculateCostRequest) (*repository.RatePlanCostCalculation, error) { // Get the rate plan plan, err := s.repo.GetRatePlan(ctx, req.RatePlanID) if err != nil { @@ -166,9 +168,9 @@ func (s *Service) CalculateCost(ctx context.Context, req *CalculateCostRequest) for _, discount := range plan.Discounts { if discount.ID == discountID && discount.IsActive { switch discount.Type { - case DiscountTypePercentage: + case repository.DiscountTypePercentage: discountCost += baseCost * discount.Value / 100 - case DiscountTypeFixed: + case repository.DiscountTypeFixed: discountCost += discount.Value } } @@ -178,7 +180,7 @@ func (s *Service) CalculateCost(ctx context.Context, req *CalculateCostRequest) totalCost := baseCost + overageCost - discountCost - calculation := &RatePlanCostCalculation{ + calculation := &repository.RatePlanCostCalculation{ RatePlanID: req.RatePlanID, BaseCost: baseCost, OverageCost: overageCost, diff --git a/apps/carrier-connector/internal/rateplan/service_analytics.go b/apps/carrier-connector/internal/services/service_analytics.go similarity index 80% rename from apps/carrier-connector/internal/rateplan/service_analytics.go rename to apps/carrier-connector/internal/services/service_analytics.go index 0bb7d68..a951e6b 100644 --- a/apps/carrier-connector/internal/rateplan/service_analytics.go +++ b/apps/carrier-connector/internal/services/service_analytics.go @@ -1,11 +1,13 @@ -package rateplan +package services import ( "context" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" ) // GetUsage retrieves usage for a profile -func (s *Service) GetUsage(ctx context.Context, profileID string) (*RatePlanUsage, error) { +func (s *Service) GetUsage(ctx context.Context, profileID string) (*repository.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") @@ -14,8 +16,9 @@ func (s *Service) GetUsage(ctx context.Context, profileID string) (*RatePlanUsag return usage, nil } + // GetUsageHistory retrieves usage history for a profile -func (s *Service) GetUsageHistory(ctx context.Context, profileID string, limit int) ([]*RatePlanUsage, error) { +func (s *Service) GetUsageHistory(ctx context.Context, profileID string, limit int) ([]*repository.RatePlanUsage, error) { usageHistory, err := s.repo.ListUsageHistory(ctx, profileID, limit) if err != nil { s.logger.WithError(err).Error("Failed to get usage history") @@ -26,7 +29,7 @@ func (s *Service) GetUsageHistory(ctx context.Context, profileID string, limit i } // GetUsageAnalytics retrieves usage analytics -func (s *Service) GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsFilter) (*UsageAnalytics, error) { +func (s *Service) GetUsageAnalytics(ctx context.Context, filter *repository.UsageAnalyticsFilter) (*repository.UsageAnalytics, error) { analytics, err := s.repo.GetUsageAnalytics(ctx, filter) if err != nil { s.logger.WithError(err).Error("Failed to get usage analytics") @@ -37,7 +40,7 @@ func (s *Service) GetUsageAnalytics(ctx context.Context, filter *UsageAnalyticsF } // GetRevenueAnalytics retrieves revenue analytics -func (s *Service) GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyticsFilter) (*RevenueAnalytics, error) { +func (s *Service) GetRevenueAnalytics(ctx context.Context, filter *repository.RevenueAnalyticsFilter) (*repository.RevenueAnalytics, error) { analytics, err := s.repo.GetRevenueAnalytics(ctx, filter) if err != nil { s.logger.WithError(err).Error("Failed to get revenue analytics") @@ -48,7 +51,7 @@ func (s *Service) GetRevenueAnalytics(ctx context.Context, filter *RevenueAnalyt } // GetPopularPlans retrieves the most popular rate plans -func (s *Service) GetPopularPlans(ctx context.Context, limit int) ([]*RatePlan, error) { +func (s *Service) GetPopularPlans(ctx context.Context, limit int) ([]*repository.RatePlan, error) { plans, err := s.repo.GetPopularPlans(ctx, limit) if err != nil { s.logger.WithError(err).Error("Failed to get popular plans") diff --git a/apps/carrier-connector/internal/rateplan/service_methods.go b/apps/carrier-connector/internal/services/service_methods.go similarity index 62% rename from apps/carrier-connector/internal/rateplan/service_methods.go rename to apps/carrier-connector/internal/services/service_methods.go index 3a115f9..9fb58f5 100644 --- a/apps/carrier-connector/internal/rateplan/service_methods.go +++ b/apps/carrier-connector/internal/services/service_methods.go @@ -1,11 +1,13 @@ -package rateplan +package services import ( "fmt" "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" ) -func (s *Service) validateRatePlan(plan *RatePlan) error { +func (s *Service) validateRatePlan(plan *repository.RatePlan) error { if plan.Name == "" { return fmt.Errorf("rate plan name is required") } @@ -30,7 +32,7 @@ func (s *Service) validateRatePlan(plan *RatePlan) error { return nil } -func (s *Service) validateSubscribeRequest(req *SubscribeRequest) error { +func (s *Service) validateSubscribeRequest(req *repository.SubscribeRequest) error { if req.ProfileID == "" { return fmt.Errorf("profile ID is required") } @@ -40,37 +42,36 @@ func (s *Service) validateSubscribeRequest(req *SubscribeRequest) error { return nil } -func (s *Service) calculateNextBillingDate(cycle BillingCycle, from time.Time) time.Time { +func (s *Service) calculateNextBillingDate(cycle repository.BillingCycle, from time.Time) time.Time { switch cycle { - case BillingCycleDaily: + case repository.BillingCycleDaily: return from.AddDate(0, 0, 1) - case BillingCycleWeekly: + case repository.BillingCycleWeekly: return from.AddDate(0, 0, 7) - case BillingCycleMonthly: + case repository.BillingCycleMonthly: return from.AddDate(0, 1, 0) - case BillingCycleQuarterly: + case repository.BillingCycleQuarterly: return from.AddDate(0, 3, 0) - case BillingCycleYearly: + case repository.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 { +func (s *Service) calculateCycleEnd(cycle repository.BillingCycle, cycleStart time.Time) time.Time { switch cycle { - case BillingCycleDaily: + case repository.BillingCycleDaily: return cycleStart.AddDate(0, 0, 1).Add(-time.Nanosecond) - case BillingCycleWeekly: + case repository.BillingCycleWeekly: return cycleStart.AddDate(0, 0, 7).Add(-time.Nanosecond) - case BillingCycleMonthly: + case repository.BillingCycleMonthly: return cycleStart.AddDate(0, 1, 0).Add(-time.Nanosecond) - case BillingCycleQuarterly: + case repository.BillingCycleQuarterly: return cycleStart.AddDate(0, 3, 0).Add(-time.Nanosecond) - case BillingCycleYearly: + case repository.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/services/service_subscription.go similarity index 83% rename from apps/carrier-connector/internal/rateplan/service_subscription.go rename to apps/carrier-connector/internal/services/service_subscription.go index 2aecd60..d0c914e 100644 --- a/apps/carrier-connector/internal/rateplan/service_subscription.go +++ b/apps/carrier-connector/internal/services/service_subscription.go @@ -1,4 +1,4 @@ -package rateplan +package services import ( "context" @@ -6,10 +6,11 @@ import ( "time" "github.com/google/uuid" + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" "github.com/sirupsen/logrus" ) -func (s *Service) SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (*RatePlanSubscription, error) { +func (s *Service) SubscribeToPlan(ctx context.Context, req *repository.SubscribeRequest) (*repository.RatePlanSubscription, error) { if err := s.validateSubscribeRequest(req); err != nil { return nil, fmt.Errorf("validation failed: %w", err) } @@ -19,7 +20,7 @@ func (s *Service) SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (* return nil, err } - if !plan.IsActive || plan.Status != PlanStatusActive { + if !plan.IsActive || plan.Status != repository.PlanStatusActive { return nil, fmt.Errorf("rate plan is not available for subscription") } @@ -33,11 +34,11 @@ func (s *Service) SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (* } // Create subscription - subscription := &RatePlanSubscription{ + subscription := &repository.RatePlanSubscription{ ID: uuid.New().String(), ProfileID: req.ProfileID, RatePlanID: req.RatePlanID, - Status: SubscriptionStatusActive, + Status: repository.SubscriptionStatusActive, StartedAt: time.Now(), BillingCycle: plan.BillingCycle, NextBillingDate: s.calculateNextBillingDate(plan.BillingCycle, time.Now()), @@ -62,7 +63,7 @@ func (s *Service) SubscribeToPlan(ctx context.Context, req *SubscribeRequest) (* } // GetSubscription retrieves a subscription by ID -func (s *Service) GetSubscription(ctx context.Context, id string) (*RatePlanSubscription, error) { +func (s *Service) GetSubscription(ctx context.Context, id string) (*repository.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") @@ -73,7 +74,7 @@ func (s *Service) GetSubscription(ctx context.Context, id string) (*RatePlanSubs } // UpdateSubscription updates an existing subscription -func (s *Service) UpdateSubscription(ctx context.Context, subscription *RatePlanSubscription) (*RatePlanSubscription, error) { +func (s *Service) UpdateSubscription(ctx context.Context, subscription *repository.RatePlanSubscription) (*repository.RatePlanSubscription, error) { if err := s.repo.UpdateSubscription(ctx, subscription); err != nil { s.logger.WithError(err).Error("Failed to update subscription") return nil, err @@ -90,12 +91,12 @@ func (s *Service) CancelSubscription(ctx context.Context, subscriptionID string, return err } - if subscription.Status != SubscriptionStatusActive { + if subscription.Status != repository.SubscriptionStatusActive { return fmt.Errorf("subscription is not active") } now := time.Now() - subscription.Status = SubscriptionStatusCancelled + subscription.Status = repository.SubscriptionStatusCancelled subscription.EndedAt = &now subscription.UpdatedAt = now @@ -114,7 +115,7 @@ func (s *Service) CancelSubscription(ctx context.Context, subscriptionID string, } // GetActiveSubscription retrieves the active subscription for a profile -func (s *Service) GetActiveSubscription(ctx context.Context, profileID string) (*RatePlanSubscription, error) { +func (s *Service) GetActiveSubscription(ctx context.Context, profileID string) (*repository.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") @@ -125,7 +126,7 @@ func (s *Service) GetActiveSubscription(ctx context.Context, profileID string) ( } // ListSubscriptions retrieves subscriptions for a profile -func (s *Service) ListSubscriptions(ctx context.Context, profileID string, filter *SubscriptionFilter) ([]*RatePlanSubscription, error) { +func (s *Service) ListSubscriptions(ctx context.Context, profileID string, filter *repository.SubscriptionFilter) ([]*repository.RatePlanSubscription, error) { subscriptions, err := s.repo.ListSubscriptions(ctx, profileID, filter) if err != nil { s.logger.WithError(err).Error("Failed to list subscriptions") @@ -135,7 +136,7 @@ func (s *Service) ListSubscriptions(ctx context.Context, profileID string, filte return subscriptions, nil } -func (s *Service) RecordUsage(ctx context.Context, req *RecordUsageRequest) (*RatePlanUsage, error) { +func (s *Service) RecordUsage(ctx context.Context, req *repository.RecordUsageRequest) (*repository.RatePlanUsage, error) { // Get active subscription subscription, err := s.repo.GetActiveSubscription(ctx, req.ProfileID) if err != nil { @@ -153,10 +154,10 @@ func (s *Service) RecordUsage(ctx context.Context, req *RecordUsageRequest) (*Ra } // Create or update usage record - var usage *RatePlanUsage + var usage *repository.RatePlanUsage if currentUsage == nil { // Create new usage record - usage = &RatePlanUsage{ + usage = &repository.RatePlanUsage{ ID: uuid.New().String(), RatePlanID: subscription.RatePlanID, ProfileID: req.ProfileID, diff --git a/apps/carrier-connector/internal/service/smdp_service.go b/apps/carrier-connector/internal/services/smdp_service.go similarity index 99% rename from apps/carrier-connector/internal/service/smdp_service.go rename to apps/carrier-connector/internal/services/smdp_service.go index 3913873..b84f853 100644 --- a/apps/carrier-connector/internal/service/smdp_service.go +++ b/apps/carrier-connector/internal/services/smdp_service.go @@ -1,4 +1,4 @@ -package service +package services import ( "context"