refactor: Add rate plan currency integration service and update tenant repository with user count method

- Add RatePlanCurrencyIntegrator with SubscribeToPlanWithCurrency, CalculatePlanCostInCurrency, calculateOverageCost methods
- Add NewRatePlanCurrencyIntegrator constructor with billingService, exchangeService, ratePlanService, logger, baseCurrency fields
- Add currency conversion support for rate plan subscriptions with exchange rate metadata tracking
- Add billing integration for initial subscription
This commit is contained in:
nutcas3
2026-05-02 03:20:53 +03:00
parent 17e31d7977
commit 31a8886bc0
4 changed files with 240 additions and 15 deletions
+3 -1
View File
@@ -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
+4
View File
@@ -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=
@@ -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, &currency.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 := &currency.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 := &currency.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
}
@@ -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
}