From c93dd6347434d4366ab81864ce5d63b68073701f Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Sat, 2 May 2026 00:38:29 +0300 Subject: [PATCH] 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 --- .../internal/currency/types.go | 149 ++++++++++ .../internal/handlers/currency_handlers.go | 267 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 apps/carrier-connector/internal/currency/types.go create mode 100644 apps/carrier-connector/internal/handlers/currency_handlers.go diff --git a/apps/carrier-connector/internal/currency/types.go b/apps/carrier-connector/internal/currency/types.go new file mode 100644 index 0000000..b20af60 --- /dev/null +++ b/apps/carrier-connector/internal/currency/types.go @@ -0,0 +1,149 @@ +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"` +} 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..1f50c22 --- /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, + }) +}