mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 11:02:54 +00:00
feat: Add currency analytics, billing, and exchange rate services with multi-currency support and transaction processing
- Add AnalyticsServiceImpl with revenue/volume analysis, exchange rate trends, usage stats, and transaction type analytics - Add GetRevenueByCurrency, GetTransactionVolumeByCurrency, GetExchangeRateTrends, GetCurrencyUsageStats methods - Add GetMonthlyRevenueTrends, GetTopCurrenciesByRevenue, GetTransactionTypeAnalytics for reporting - Add BillingServiceImpl with multi-currency billing, payment
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package services
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository"
|
||||
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository"
|
||||
)
|
||||
|
||||
func (s *Service) validateRatePlan(plan *repository.RatePlan) error {
|
||||
|
||||
Reference in New Issue
Block a user