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
This commit is contained in:
nutcas3
2026-05-02 00:37:52 +03:00
parent 7dbabbb8db
commit 077e381995
5 changed files with 737 additions and 0 deletions
@@ -0,0 +1,162 @@
package currency
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
}
@@ -0,0 +1,190 @@
package currency
import (
"context"
"fmt"
"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) 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, error) {
var model 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) 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(&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 *CurrencyFilter) ([]*Currency, error) {
query := r.db.WithContext(ctx).Model(&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 []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, 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 *CurrencyFilter) (int, error) {
query := r.db.WithContext(ctx).Model(&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 *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) (*ExchangeRate, error) {
var model 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 *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(&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
}
@@ -0,0 +1,126 @@
package currency
import (
"encoding/json"
"fmt"
"strings"
)
// currencyToModel converts currency domain model to database model
func (r *GormRepository) currencyToModel(currency *Currency) *CurrencyModel {
return &CurrencyModel{
Code: currency.Code,
Name: currency.Name,
Symbol: currency.Symbol,
DecimalPlaces: currency.DecimalPlaces,
IsActive: currency.IsActive,
SupportedRegions: strings.Join(currency.SupportedRegions, ","),
CreatedAt: currency.CreatedAt,
UpdatedAt: currency.UpdatedAt,
}
}
// modelToCurrency converts database model to currency domain model
func (r *GormRepository) modelToCurrency(model *CurrencyModel) (*Currency, error) {
var regions []string
if model.SupportedRegions != "" {
regions = strings.Split(model.SupportedRegions, ",")
}
return &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 *ExchangeRate) *ExchangeRateModel {
return &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 *ExchangeRateModel) (*ExchangeRate, error) {
return &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 *Transaction) *TransactionModel {
metadata := ""
if transaction.Metadata != nil {
if data, err := json.Marshal(transaction.Metadata); err == nil {
metadata = string(data)
}
}
return &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 *TransactionModel) (*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 &Transaction{
ID: model.ID,
ProfileID: model.ProfileID,
SubscriptionID: model.SubscriptionID,
Type: TransactionType(model.Type),
Amount: model.Amount,
Currency: model.Currency,
BaseAmount: model.BaseAmount,
BaseCurrency: model.BaseCurrency,
ExchangeRate: model.ExchangeRate,
Description: model.Description,
Status: TransactionStatus(model.Status),
Metadata: metadata,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}, nil
}
@@ -0,0 +1,90 @@
package currency
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
)
// ListExchangeRates retrieves exchange rates based on filter
func (r *GormRepository) ListExchangeRates(ctx context.Context, filter *ExchangeRateFilter) ([]*ExchangeRate, error) {
query := r.db.WithContext(ctx).Model(&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 []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([]*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) (*ExchangeRate, error) {
now := time.Now()
var model 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)
}
@@ -0,0 +1,169 @@
package currency
import (
"context"
"fmt"
"gorm.io/gorm"
)
// CreateTransaction creates a new transaction
func (r *GormRepository) CreateTransaction(ctx context.Context, transaction *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) (*Transaction, error) {
var model 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 *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(&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 *TransactionFilter) ([]*Transaction, error) {
query := r.db.WithContext(ctx).Model(&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 []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([]*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 *TransactionFilter) (int, error) {
query := r.db.WithContext(ctx).Model(&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
}