diff --git a/apps/carrier-connector/go.mod b/apps/carrier-connector/go.mod index a9124b4..88813df 100644 --- a/apps/carrier-connector/go.mod +++ b/apps/carrier-connector/go.mod @@ -4,11 +4,14 @@ go 1.26 require ( github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/prometheus/client_golang v1.20.5 github.com/rabbitmq/amqp091-go v1.11.0 github.com/rs/zerolog v1.35.0 + github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 + github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -57,7 +60,6 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.50.0 // indirect diff --git a/apps/carrier-connector/go.sum b/apps/carrier-connector/go.sum index 58b4f6e..f5e02ce 100644 --- a/apps/carrier-connector/go.sum +++ b/apps/carrier-connector/go.sum @@ -47,6 +47,8 @@ github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -124,6 +126,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/apps/carrier-connector/internal/currency/services/rateplan_core.go b/apps/carrier-connector/internal/currency/services/rateplan_core.go new file mode 100644 index 0000000..3e0c8d7 --- /dev/null +++ b/apps/carrier-connector/internal/currency/services/rateplan_core.go @@ -0,0 +1,208 @@ +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 currency.ExchangeRateService + ratePlanService rateplan.Service + logger *logrus.Logger + baseCurrency string +} + +// NewRatePlanCurrencyIntegrator creates a new rate plan currency integrator +func NewRatePlanCurrencyIntegrator( + billingService currency.BillingService, + exchangeService currency.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.billingService.ConvertAmount(ctx, ¤cy.CurrencyConversionRequest{ + Amount: plan.BasePrice, + FromCurrency: plan.Currency, + ToCurrency: 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 +} + +// 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 +} diff --git a/apps/carrier-connector/internal/repository/tenant_repository_crud.go b/apps/carrier-connector/internal/repository/tenant_repository_crud.go index 427206c..cd6b104 100644 --- a/apps/carrier-connector/internal/repository/tenant_repository_crud.go +++ b/apps/carrier-connector/internal/repository/tenant_repository_crud.go @@ -2,27 +2,23 @@ package repository import ( "context" - + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/tenant" "gorm.io/gorm" ) -// GormTenantRepository implements the tenant repository interface using GORM type GormTenantRepository struct { db *gorm.DB } -// NewGormTenantRepository creates a new GORM tenant repository func NewGormTenantRepository(db *gorm.DB) TenantRepository { return &GormTenantRepository{db: db} } -// CreateTenant creates a new tenant func (r *GormTenantRepository) CreateTenant(ctx context.Context, tenant *tenant.Tenant) error { return r.db.WithContext(ctx).Create(tenant).Error } -// GetTenant retrieves a tenant by ID func (r *GormTenantRepository) GetTenant(ctx context.Context, id string) (*tenant.Tenant, error) { var tenant tenant.Tenant err := r.db.WithContext(ctx).Where("id = ?", id).First(&tenant).Error @@ -56,7 +52,6 @@ func (r *GormTenantRepository) DeleteTenant(ctx context.Context, id string) erro func (r *GormTenantRepository) ListTenants(ctx context.Context, filter *tenant.TenantFilter) ([]*tenant.Tenant, error) { query := r.db.WithContext(ctx).Model(&tenant.Tenant{}) - // Apply filters if filter.ID != "" { query = query.Where("id = ?", filter.ID) } @@ -97,11 +92,9 @@ func (r *GormTenantRepository) ListTenants(ctx context.Context, filter *tenant.T return tenants, err } -// CountTenants counts tenants with filtering func (r *GormTenantRepository) CountTenants(ctx context.Context, filter *tenant.TenantFilter) (int, error) { query := r.db.WithContext(ctx).Model(&tenant.Tenant{}) - // Apply filters if filter.ID != "" { query = query.Where("id = ?", filter.ID) } @@ -123,12 +116,10 @@ func (r *GormTenantRepository) CountTenants(ctx context.Context, filter *tenant. return int(count), err } -// CreateTenantUser creates a new tenant user func (r *GormTenantRepository) CreateTenantUser(ctx context.Context, user *tenant.TenantUser) error { return r.db.WithContext(ctx).Create(user).Error } -// GetTenantUser retrieves a tenant user func (r *GormTenantRepository) GetTenantUser(ctx context.Context, tenantID, userID string) (*tenant.TenantUser, error) { var user tenant.TenantUser err := r.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&user).Error @@ -138,21 +129,17 @@ func (r *GormTenantRepository) GetTenantUser(ctx context.Context, tenantID, user return &user, nil } -// UpdateTenantUser updates a tenant user func (r *GormTenantRepository) UpdateTenantUser(ctx context.Context, user *tenant.TenantUser) error { return r.db.WithContext(ctx).Save(user).Error } -// DeleteTenantUser deletes a tenant user func (r *GormTenantRepository) DeleteTenantUser(ctx context.Context, tenantID, userID string) error { return r.db.WithContext(ctx).Delete(&tenant.TenantUser{}, "tenant_id = ? AND user_id = ?", tenantID, userID).Error } -// ListTenantUsers lists tenant users with filtering func (r *GormTenantRepository) ListTenantUsers(ctx context.Context, filter *tenant.TenantUserFilter) ([]*tenant.TenantUser, error) { query := r.db.WithContext(ctx).Model(&tenant.TenantUser{}) - // Apply filters if filter.TenantID != "" { query = query.Where("tenant_id = ?", filter.TenantID) } @@ -181,3 +168,27 @@ func (r *GormTenantRepository) ListTenantUsers(ctx context.Context, filter *tena err := query.Find(&users).Error return users, err } + +func (r *GormTenantRepository) CountTenantUsers(ctx context.Context, filter *tenant.TenantUserFilter) (int, error) { + query := r.db.WithContext(ctx).Model(&tenant.TenantUser{}) + + if filter.TenantID != "" { + query = query.Where("tenant_id = ?", filter.TenantID) + } + if filter.UserID != "" { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Email != "" { + query = query.Where("email ILIKE ?", "%"+filter.Email+"%") + } + if filter.Role != "" { + query = query.Where("role = ?", filter.Role) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + + var count int64 + err := query.Count(&count).Error + return int(count), err +}