From cb1d7171e087306e8f230fc02115d71bf1dbd056 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Fri, 24 Apr 2026 08:53:34 +0300 Subject: [PATCH] feat: Add PaymentController with modular handlers for payment operations, refunds, and webhook processing - Add PaymentController struct with paymentService/db dependencies and NewPaymentController constructor - Add SetupRoutes with /payments endpoints (POST /top-up, POST/DELETE/GET /payment-methods, GET /transactions/:id, POST /transactions/:id/refund) and /webhooks/stripe endpoint - Add ProcessTopUp handler with amount/paymentMethodId validation, subscriberID authentication, and PaymentService. --- .../api-server/internal/payment/controller.go | 143 +++++ .../internal/payment/controller_queries.go | 61 +++ .../internal/payment/controller_refund.go | 87 +++ apps/api-server/internal/payment/gateway.go | 499 +---------------- .../internal/payment/gateway_service.go | 122 +++++ .../internal/payment/gateway_stripe.go | 175 ++++++ .../payment/gateway_stripe_helpers.go | 103 ++++ .../payment/gateway_stripe_methods.go | 90 +++ apps/api-server/internal/payment/webhook.go | 512 +----------------- .../internal/payment/webhook_handlers.go | 175 ++++++ 10 files changed, 964 insertions(+), 1003 deletions(-) create mode 100644 apps/api-server/internal/payment/controller.go create mode 100644 apps/api-server/internal/payment/controller_queries.go create mode 100644 apps/api-server/internal/payment/controller_refund.go create mode 100644 apps/api-server/internal/payment/gateway_service.go create mode 100644 apps/api-server/internal/payment/gateway_stripe.go create mode 100644 apps/api-server/internal/payment/gateway_stripe_helpers.go create mode 100644 apps/api-server/internal/payment/gateway_stripe_methods.go create mode 100644 apps/api-server/internal/payment/webhook_handlers.go diff --git a/apps/api-server/internal/payment/controller.go b/apps/api-server/internal/payment/controller.go new file mode 100644 index 0000000..7900f2b --- /dev/null +++ b/apps/api-server/internal/payment/controller.go @@ -0,0 +1,143 @@ +package payment + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" +) + +// PaymentController handles payment-related HTTP endpoints. +type PaymentController struct { + paymentService *PaymentService + db *database.Database +} + +// NewPaymentController creates a new payment controller. +func NewPaymentController(paymentService *PaymentService, db *database.Database) *PaymentController { + return &PaymentController{paymentService: paymentService, db: db} +} + +// SetupRoutes registers payment and webhook routes on the given group. +func (pc *PaymentController) SetupRoutes(router *gin.RouterGroup) { + payments := router.Group("/payments") + { + payments.POST("/top-up", pc.ProcessTopUp) + payments.POST("/payment-methods", pc.CreatePaymentMethod) + payments.DELETE("/payment-methods/:id", pc.DeletePaymentMethod) + payments.GET("/payment-methods", pc.ListPaymentMethods) + payments.GET("/transactions", pc.ListTransactions) + payments.GET("/transactions/:id", pc.GetTransaction) + payments.POST("/transactions/:id/refund", pc.RefundTransaction) + } + webhook := router.Group("/webhooks") + { + webhook.POST("/stripe", pc.HandleStripeWebhook) + } +} + +// ProcessTopUp processes a top-up payment. +func (pc *PaymentController) ProcessTopUp(c *gin.Context) { + var req struct { + Amount float64 `json:"amount" binding:"required,min=0.01"` + PaymentMethodID string `json:"paymentMethodId" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + transaction, err := pc.paymentService.ProcessTopUp(c.Request.Context(), subscriberID, req.Amount, req.PaymentMethodID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "transaction": transaction, + "message": "Top-up processed successfully", + }) +} + +// CreatePaymentMethod creates a new payment method. +func (pc *PaymentController) CreatePaymentMethod(c *gin.Context) { + var req struct { + Type string `json:"type" binding:"required"` + Token string `json:"token" binding:"required"` + IsDefault bool `json:"isDefault"` + Metadata map[string]string `json:"metadata"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + createReq := &CreatePaymentMethodRequest{ + Type: PaymentMethodType(req.Type), + Token: req.Token, + IsDefault: req.IsDefault, + Metadata: req.Metadata, + } + + paymentMethod, err := pc.paymentService.CreatePaymentMethod(c.Request.Context(), subscriberID, createReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "paymentMethod": paymentMethod, + "message": "Payment method created successfully", + }) +} + +// DeletePaymentMethod deletes a payment method, enforcing ownership. +func (pc *PaymentController) DeletePaymentMethod(c *gin.Context) { + paymentMethodID := c.Param("id") + if paymentMethodID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Payment method ID required"}) + return + } + + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + pm, err := pc.db.GetPaymentMethod(c.Request.Context(), paymentMethodID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Payment method not found"}) + return + } + + if pm.SubscriberID != uint(subscriberID) { + c.JSON(http.StatusForbidden, gin.H{"error": "Payment method does not belong to subscriber"}) + return + } + + if err := pc.paymentService.gateway.DeletePaymentMethod(c.Request.Context(), pm.GatewayID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := pc.db.DeletePaymentMethod(c.Request.Context(), paymentMethodID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Payment method deleted successfully"}) +} diff --git a/apps/api-server/internal/payment/controller_queries.go b/apps/api-server/internal/payment/controller_queries.go new file mode 100644 index 0000000..9a8d86f --- /dev/null +++ b/apps/api-server/internal/payment/controller_queries.go @@ -0,0 +1,61 @@ +package payment + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ListPaymentMethods lists payment methods for the authenticated subscriber. +func (pc *PaymentController) ListPaymentMethods(c *gin.Context) { + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + paymentMethods, err := pc.db.ListPaymentMethods(c.Request.Context(), uint(subscriberID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"paymentMethods": paymentMethods}) +} + +// ListTransactions lists transactions for the authenticated subscriber. +func (pc *PaymentController) ListTransactions(c *gin.Context) { + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + transactions, err := pc.db.ListTransactions(c.Request.Context(), uint(subscriberID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"transactions": transactions}) +} + +// GetTransaction returns a specific transaction, enforcing ownership. +func (pc *PaymentController) GetTransaction(c *gin.Context) { + transactionID := c.Param("id") + if transactionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) + return + } + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + return + } + if transaction.SubscriberID != uint(subscriberID) { + c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) + return + } + c.JSON(http.StatusOK, gin.H{"transaction": transaction}) +} diff --git a/apps/api-server/internal/payment/controller_refund.go b/apps/api-server/internal/payment/controller_refund.go new file mode 100644 index 0000000..eeea99a --- /dev/null +++ b/apps/api-server/internal/payment/controller_refund.go @@ -0,0 +1,87 @@ +package payment + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// RefundTransaction refunds a completed transaction, creating a reverse +// Transaction record and adjusting subscriber balance. +func (pc *PaymentController) RefundTransaction(c *gin.Context) { + transactionID := c.Param("id") + if transactionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) + return + } + + var req struct { + Amount float64 `json:"amount" binding:"required,min=0.01"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + subscriberID := c.GetInt("subscriberID") + if subscriberID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + return + } + if transaction.SubscriberID != uint(subscriberID) { + c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) + return + } + if transaction.Status != "COMPLETED" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot refund non-completed transaction"}) + return + } + + refundResp, err := pc.paymentService.gateway.RefundPayment(c.Request.Context(), transaction.TransactionID, req.Amount) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + refund := &models.Transaction{ + SubscriberID: transaction.SubscriberID, + TransactionID: refundResp.RefundID, + Type: "REFUND", + Amount: req.Amount, + Currency: refundResp.Currency, + Status: string(refundResp.Status), + Description: fmt.Sprintf("Refund for transaction %s", transactionID), + ParentID: &transaction.ID, + CreatedAt: refundResp.ProcessedAt, + UpdatedAt: refundResp.ProcessedAt, + } + + if err := pc.db.CreateTransaction(c.Request.Context(), refund); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save refund"}) + return + } + + if err := pc.db.UpdateSubscriberBalance(c.Request.Context(), transaction.SubscriberID, -req.Amount); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "refund": refund, + "message": "Refund processed successfully", + }) +} + +// HandleStripeWebhook delegates to WebhookHandler. +func (pc *PaymentController) HandleStripeWebhook(c *gin.Context) { + NewWebhookHandler(pc.paymentService, pc.db).HandleStripeWebhook(c) +} diff --git a/apps/api-server/internal/payment/gateway.go b/apps/api-server/internal/payment/gateway.go index 6172f7f..fe72c7a 100644 --- a/apps/api-server/internal/payment/gateway.go +++ b/apps/api-server/internal/payment/gateway.go @@ -1,20 +1,8 @@ package payment import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +"context" +"time" ) // PaymentGateway interface @@ -135,486 +123,3 @@ const ( PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD" PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT" ) - -// StripeGateway implements PaymentGateway for Stripe -type StripeGateway struct { - apiKey string - webhookSecret string - client *http.Client - config *config.PaymentConfig -} - -// NewStripeGateway creates a new Stripe payment gateway -func NewStripeGateway(cfg *config.PaymentConfig) *StripeGateway { - return &StripeGateway{ - apiKey: cfg.StripeAPIKey, - webhookSecret: cfg.StripeWebhookSecret, - client: &http.Client{Timeout: 30 * time.Second}, - config: cfg, - } -} - -// ProcessPayment processes a payment via Stripe -func (s *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) { - // Create payment intent - intentData := map[string]any{ - "amount": int64(req.Amount * 100), // Stripe uses cents - "currency": strings.ToLower(req.Currency), - "description": req.Description, - "customer": req.CustomerID, - "payment_method": req.PaymentMethodID, - "confirmation_method": "manual", - "confirm": true, - "metadata": req.Metadata, - } - - if req.WebhookURL != "" { - intentData["transfer_data"] = map[string]string{ - "destination": req.WebhookURL, - } - } - - intentResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_intents", intentData) - if err != nil { - return nil, fmt.Errorf("failed to create payment intent: %w", err) - } - - intent, ok := intentResp.(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid payment intent response") - } - - status := s.mapStripeStatus(intent["status"].(string)) - - var failureReason *string - if status == PaymentStatusFailed { - if lastPaymentError, ok := intent["last_payment_error"].(map[string]any); ok { - if message, ok := lastPaymentError["message"].(string); ok { - failureReason = &message - } - } - } - - var redirectURL *string - if status == PaymentStatusPending { - if nextAction, ok := intent["next_action"].(map[string]any); ok { - if url, ok := nextAction["redirect_to_url"].(map[string]any); ok { - if redirect, ok := url["url"].(string); ok { - redirectURL = &redirect - } - } - } - } - - return &PaymentResponse{ - TransactionID: intent["id"].(string), - Status: status, - Amount: req.Amount, - Currency: req.Currency, - ProcessedAt: time.Now(), - FailureReason: failureReason, - Metadata: req.Metadata, - RedirectURL: redirectURL, - }, nil -} - -// RefundPayment refunds a payment via Stripe -func (s *StripeGateway) RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) { - refundData := map[string]any{ - "payment_intent": transactionID, - "amount": int64(amount * 100), // Stripe uses cents - } - - refundResp, err := s.makeStripeRequest(ctx, "POST", "/v1/refunds", refundData) - if err != nil { - return nil, fmt.Errorf("failed to create refund: %w", err) - } - - refund, ok := refundResp.(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid refund response") - } - - status := s.mapStripeStatus(refund["status"].(string)) - - var failureReason *string - if status == PaymentStatusFailed { - if failureMsg, ok := refund["failure_reason"].(string); ok && failureMsg != "" { - failureReason = &failureMsg - } - } - - return &RefundResponse{ - RefundID: refund["id"].(string), - TransactionID: transactionID, - Amount: amount, - Currency: "USD", // Default currency, should be configurable - Status: status, - ProcessedAt: time.Now(), - FailureReason: failureReason, - }, nil -} - -// GetPaymentStatus gets payment status from Stripe -func (s *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { - intentResp, err := s.makeStripeRequest(ctx, "GET", "/v1/payment_intents/"+transactionID, nil) - if err != nil { - return nil, fmt.Errorf("failed to get payment intent: %w", err) - } - - intent, ok := intentResp.(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid payment intent response") - } - - // Get refunds for this payment intent - refundsResp, err := s.makeStripeRequest(ctx, "GET", "/v1/refunds?payment_intent="+transactionID, nil) - if err != nil { - return nil, fmt.Errorf("failed to get refunds: %w", err) - } - - refundsData, _ := refundsResp.(map[string]any) - refundsList, _ := refundsData["data"].([]any) - - refunds := make([]RefundInfo, len(refundsList)) - for i, refundInterface := range refundsList { - refund, _ := refundInterface.(map[string]any) - createdAt, _ := time.Parse(time.RFC3339, refund["created"].(string)) - refunds[i] = RefundInfo{ - RefundID: refund["id"].(string), - Amount: float64(refund["amount"].(int64)) / 100, - Status: refund["status"].(string), - CreatedAt: createdAt, - } - } - - createdAt, _ := time.Parse(time.RFC3339, intent["created"].(string)) - - var failureReason *string - if lastPaymentError, ok := intent["last_payment_error"].(map[string]any); ok { - if message, ok := lastPaymentError["message"].(string); ok { - failureReason = &message - } - } - - return &PaymentStatus{ - TransactionID: transactionID, - Status: s.mapStripeStatus(intent["status"].(string)), - Amount: float64(intent["amount"].(int64)) / 100, - Currency: strings.ToUpper(intent["currency"].(string)), - CreatedAt: createdAt, - UpdatedAt: time.Now(), - FailureReason: failureReason, - Refunds: refunds, - }, nil -} - -// CreatePaymentMethod creates a payment method in Stripe -func (s *StripeGateway) CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { - pmData := map[string]any{ - "type": s.mapPaymentMethodType(req.Type), - "card": map[string]string{ - "token": req.Token, - }, - "metadata": req.Metadata, - } - - pmResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_methods", pmData) - if err != nil { - return nil, fmt.Errorf("failed to create payment method: %w", err) - } - - pm, ok := pmResp.(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid payment method response") - } - - // Attach payment method to customer - attachData := map[string]any{ - "customer": req.CustomerID, - } - _, err = s.makeStripeRequest(ctx, "POST", "/v1/payment_methods/"+pm["id"].(string)+"/attach", attachData) - if err != nil { - return nil, fmt.Errorf("failed to attach payment method: %w", err) - } - - // Set as default if requested - if req.IsDefault { - customerData := map[string]any{ - "invoice_settings": map[string]string{ - "default_payment_method": pm["id"].(string), - }, - } - _, err = s.makeStripeRequest(ctx, "POST", "/v1/customers/"+req.CustomerID, customerData) - if err != nil { - return nil, fmt.Errorf("failed to set default payment method: %w", err) - } - } - - card, _ := pm["card"].(map[string]any) - createdAt, _ := time.Parse(time.RFC3339, pm["created"].(string)) - - return &PaymentMethod{ - ID: pm["id"].(string), - Type: req.Type, - CustomerID: req.CustomerID, - Last4: card["last4"].(string), - Brand: card["brand"].(string), - ExpiryMonth: int(card["exp_month"].(float64)), - ExpiryYear: int(card["exp_year"].(float64)), - IsDefault: req.IsDefault, - CreatedAt: createdAt, - Metadata: req.Metadata, - }, nil -} - -// DeletePaymentMethod deletes a payment method from Stripe -func (s *StripeGateway) DeletePaymentMethod(ctx context.Context, paymentMethodID string) error { - _, err := s.makeStripeRequest(ctx, "DELETE", "/v1/payment_methods/"+paymentMethodID, nil) - if err != nil { - return fmt.Errorf("failed to delete payment method: %w", err) - } - return nil -} - -// ValidateWebhook validates a Stripe webhook -func (s *StripeGateway) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { - if s.webhookSecret == "" { - return false, fmt.Errorf("webhook secret not configured") - } - - // Parse signature - parts := strings.Split(signature, ",") - var timestamp, v1Signature string - for _, part := range parts { - part = strings.TrimSpace(part) - if after, ok := strings.CutPrefix(part, "t="); ok { - timestamp = after - } else if after, ok := strings.CutPrefix(part, "v1="); ok { - v1Signature = after - } - } - - if timestamp == "" || v1Signature == "" { - return false, fmt.Errorf("invalid signature format") - } - - // Create expected signature - expectedSignature := s.computeHMACSHA256(timestamp, string(body), s.webhookSecret) - - return hmac.Equal([]byte(v1Signature), []byte(expectedSignature)), nil -} - -// Helper methods -func (s *StripeGateway) makeStripeRequest(ctx context.Context, method, path string, data any) (any, error) { - url := "https://api.stripe.com" + path - - var body io.Reader - if data != nil { - jsonData, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("failed to marshal request data: %w", err) - } - body = strings.NewReader(string(jsonData)) - } - - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+s.apiKey) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Stripe-Version", "2023-10-16") - - resp, err := s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode >= 400 { - var errorResp map[string]any - if err := json.Unmarshal(respBody, &errorResp); err == nil { - if errorMsg, ok := errorResp["error"].(map[string]any); ok { - if message, ok := errorMsg["message"].(string); ok { - return nil, fmt.Errorf("stripe error: %s", message) - } - } - } - return nil, fmt.Errorf("stripe error: %s", string(respBody)) - } - - var result any - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - return result, nil -} - -func (s *StripeGateway) mapStripeStatus(status string) PaymentStatusType { - switch status { - case "requires_payment_method", "requires_confirmation", "requires_action": - return PaymentStatusPending - case "processing": - return PaymentStatusProcessing - case "succeeded": - return PaymentStatusCompleted - case "canceled": - return PaymentStatusCancelled - default: - return PaymentStatusFailed - } -} - -func (s *StripeGateway) mapPaymentMethodType(pmt PaymentMethodType) string { - switch pmt { - case PaymentMethodTypeCreditCard: - return "card" - case PaymentMethodTypeBankAccount: - return "bank_account" - default: - return "card" - } -} - -func (s *StripeGateway) computeHMACSHA256(timestamp, payload, secret string) string { - data := timestamp + "." + payload - h := hmac.New(sha256.New, []byte(secret)) - h.Write([]byte(data)) - return hex.EncodeToString(h.Sum(nil)) -} - -// PaymentService handles payment operations -type PaymentService struct { - gateway PaymentGateway - db *database.Database - config *config.PaymentConfig -} - -// NewPaymentService creates a new payment service -func NewPaymentService(db *database.Database, cfg *config.PaymentConfig) *PaymentService { - var gateway PaymentGateway - - switch cfg.Provider { - case "stripe": - gateway = NewStripeGateway(cfg) - default: - gateway = NewStripeGateway(cfg) // Default to Stripe - } - - return &PaymentService{ - gateway: gateway, - db: db, - config: cfg, - } -} - -// ProcessTopUp processes a top-up payment -func (ps *PaymentService) ProcessTopUp(ctx context.Context, subscriberID int, amount float64, paymentMethodID string) (*models.Transaction, error) { - // Get subscriber - subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) - if err != nil { - return nil, fmt.Errorf("failed to get subscriber: %w", err) - } - - // Create payment request - req := &PaymentRequest{ - Amount: amount, - Currency: "USD", - Description: fmt.Sprintf("Top-up for subscriber %s", subscriber.MSISDN), - CustomerID: subscriber.MSISDN, - PaymentMethodID: paymentMethodID, - Metadata: map[string]string{ - "subscriber_id": fmt.Sprintf("%d", subscriberID), - "type": "top_up", - }, - } - - // Process payment - resp, err := ps.gateway.ProcessPayment(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to process payment: %w", err) - } - - // Create transaction record - transaction := &models.Transaction{ - SubscriberID: uint(subscriberID), - TransactionID: resp.TransactionID, - Type: "TOP_UP", - Amount: amount, - Currency: resp.Currency, - Status: string(resp.Status), - Description: req.Description, - CreatedAt: resp.ProcessedAt, - UpdatedAt: resp.ProcessedAt, - } - - // Save transaction - err = ps.db.CreateTransaction(ctx, transaction) - if err != nil { - return nil, fmt.Errorf("failed to save transaction: %w", err) - } - - // If payment was successful, update subscriber balance - if resp.Status == PaymentStatusCompleted { - err = ps.db.UpdateSubscriberBalance(ctx, uint(subscriberID), amount) - if err != nil { - return nil, fmt.Errorf("failed to update subscriber balance: %w", err) - } - } - - return transaction, nil -} - -// GetPaymentStatus gets payment status -func (ps *PaymentService) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { - return ps.gateway.GetPaymentStatus(ctx, transactionID) -} - -// CreatePaymentMethod creates a payment method -func (ps *PaymentService) CreatePaymentMethod(ctx context.Context, subscriberID int, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { - subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) - if err != nil { - return nil, fmt.Errorf("failed to get subscriber: %w", err) - } - - req.CustomerID = subscriber.MSISDN - - pm, err := ps.gateway.CreatePaymentMethod(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to create payment method: %w", err) - } - - // Save payment method to database - dbPM := &models.PaymentMethod{ - SubscriberID: uint(subscriberID), - GatewayID: pm.ID, - Type: models.PaymentMethodType(pm.Type), - Last4: pm.Last4, - Brand: pm.Brand, - ExpiryMonth: pm.ExpiryMonth, - ExpiryYear: pm.ExpiryYear, - IsDefault: pm.IsDefault, - CreatedAt: pm.CreatedAt, - } - - err = ps.db.CreatePaymentMethod(ctx, dbPM) - if err != nil { - return nil, fmt.Errorf("failed to save payment method: %w", err) - } - - pm.ID = fmt.Sprintf("%s", dbPM.ID) // Return database ID - return pm, nil -} - -// ValidateWebhook validates a webhook -func (ps *PaymentService) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { - return ps.gateway.ValidateWebhook(ctx, signature, body) -} diff --git a/apps/api-server/internal/payment/gateway_service.go b/apps/api-server/internal/payment/gateway_service.go new file mode 100644 index 0000000..516f921 --- /dev/null +++ b/apps/api-server/internal/payment/gateway_service.go @@ -0,0 +1,122 @@ +package payment + +import ( + "context" + "fmt" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// PaymentService orchestrates payment flows using a configured PaymentGateway. +type PaymentService struct { + gateway PaymentGateway + db *database.Database + config *config.PaymentConfig +} + +// NewPaymentService creates a new payment service. Defaults to Stripe gateway. +func NewPaymentService(db *database.Database, cfg *config.PaymentConfig) *PaymentService { + var gateway PaymentGateway + switch cfg.Provider { + case "stripe": + gateway = NewStripeGateway(cfg) + default: + gateway = NewStripeGateway(cfg) + } + return &PaymentService{gateway: gateway, db: db, config: cfg} +} + +// ProcessTopUp processes a top-up payment and records the transaction. +func (ps *PaymentService) ProcessTopUp(ctx context.Context, subscriberID int, amount float64, paymentMethodID string) (*models.Transaction, error) { + subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + + req := &PaymentRequest{ + Amount: amount, + Currency: "USD", + Description: fmt.Sprintf("Top-up for subscriber %s", subscriber.MSISDN), + CustomerID: subscriber.MSISDN, + PaymentMethodID: paymentMethodID, + Metadata: map[string]string{ + "subscriber_id": fmt.Sprintf("%d", subscriberID), + "type": "top_up", + }, + } + + resp, err := ps.gateway.ProcessPayment(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to process payment: %w", err) + } + + transaction := &models.Transaction{ + SubscriberID: uint(subscriberID), + TransactionID: resp.TransactionID, + Type: "TOP_UP", + Amount: amount, + Currency: resp.Currency, + Status: string(resp.Status), + Description: req.Description, + CreatedAt: resp.ProcessedAt, + UpdatedAt: resp.ProcessedAt, + } + + if err := ps.db.CreateTransaction(ctx, transaction); err != nil { + return nil, fmt.Errorf("failed to save transaction: %w", err) + } + + if resp.Status == PaymentStatusCompleted { + if err := ps.db.UpdateSubscriberBalance(ctx, uint(subscriberID), amount); err != nil { + return nil, fmt.Errorf("failed to update subscriber balance: %w", err) + } + } + + return transaction, nil +} + +// GetPaymentStatus returns a payment status by transaction ID. +func (ps *PaymentService) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { + return ps.gateway.GetPaymentStatus(ctx, transactionID) +} + +// CreatePaymentMethod creates a payment method on the gateway and persists it. +func (ps *PaymentService) CreatePaymentMethod(ctx context.Context, subscriberID int, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { + subscriber, err := ps.db.GetSubscriber(ctx, uint(subscriberID)) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + + req.CustomerID = subscriber.MSISDN + + pm, err := ps.gateway.CreatePaymentMethod(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create payment method: %w", err) + } + + dbPM := &models.PaymentMethod{ + SubscriberID: uint(subscriberID), + GatewayID: pm.ID, + Type: models.PaymentMethodType(pm.Type), + Last4: pm.Last4, + Brand: pm.Brand, + ExpiryMonth: pm.ExpiryMonth, + ExpiryYear: pm.ExpiryYear, + IsDefault: pm.IsDefault, + CreatedAt: pm.CreatedAt, + } + + if err := ps.db.CreatePaymentMethod(ctx, dbPM); err != nil { + return nil, fmt.Errorf("failed to save payment method: %w", err) + } + + pm.ID = fmt.Sprintf("%s", dbPM.ID) + return pm, nil +} + +// ValidateWebhook validates a webhook using the configured gateway. +func (ps *PaymentService) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { + return ps.gateway.ValidateWebhook(ctx, signature, body) +} diff --git a/apps/api-server/internal/payment/gateway_stripe.go b/apps/api-server/internal/payment/gateway_stripe.go new file mode 100644 index 0000000..60307bf --- /dev/null +++ b/apps/api-server/internal/payment/gateway_stripe.go @@ -0,0 +1,175 @@ +package payment + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" +) + +// StripeGateway implements PaymentGateway for Stripe. +type StripeGateway struct { + apiKey string + webhookSecret string + client *http.Client + config *config.PaymentConfig +} + +// NewStripeGateway creates a new Stripe payment gateway. +func NewStripeGateway(cfg *config.PaymentConfig) *StripeGateway { + return &StripeGateway{ + apiKey: cfg.StripeAPIKey, + webhookSecret: cfg.StripeWebhookSecret, + client: &http.Client{Timeout: 30 * time.Second}, + config: cfg, + } +} + +// ProcessPayment processes a payment via Stripe. +func (s *StripeGateway) ProcessPayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) { + intentData := map[string]any{ + "amount": int64(req.Amount * 100), + "currency": strings.ToLower(req.Currency), + "description": req.Description, + "customer": req.CustomerID, + "payment_method": req.PaymentMethodID, + "confirmation_method": "manual", + "confirm": true, + "metadata": req.Metadata, + } + + if req.WebhookURL != "" { + intentData["transfer_data"] = map[string]string{"destination": req.WebhookURL} + } + + intentResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_intents", intentData) + if err != nil { + return nil, fmt.Errorf("failed to create payment intent: %w", err) + } + intent, ok := intentResp.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid payment intent response") + } + + status := s.mapStripeStatus(intent["status"].(string)) + + var failureReason *string + if status == PaymentStatusFailed { + if lastErr, ok := intent["last_payment_error"].(map[string]any); ok { + if message, ok := lastErr["message"].(string); ok { + failureReason = &message + } + } + } + + var redirectURL *string + if status == PaymentStatusPending { + if nextAction, ok := intent["next_action"].(map[string]any); ok { + if url, ok := nextAction["redirect_to_url"].(map[string]any); ok { + if redirect, ok := url["url"].(string); ok { + redirectURL = &redirect + } + } + } + } + + return &PaymentResponse{ + TransactionID: intent["id"].(string), + Status: status, + Amount: req.Amount, + Currency: req.Currency, + ProcessedAt: time.Now(), + FailureReason: failureReason, + Metadata: req.Metadata, + RedirectURL: redirectURL, + }, nil +} + +// RefundPayment refunds a payment via Stripe. +func (s *StripeGateway) RefundPayment(ctx context.Context, transactionID string, amount float64) (*RefundResponse, error) { + refundData := map[string]any{ + "payment_intent": transactionID, + "amount": int64(amount * 100), + } + refundResp, err := s.makeStripeRequest(ctx, "POST", "/v1/refunds", refundData) + if err != nil { + return nil, fmt.Errorf("failed to create refund: %w", err) + } + refund, ok := refundResp.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid refund response") + } + + status := s.mapStripeStatus(refund["status"].(string)) + + var failureReason *string + if status == PaymentStatusFailed { + if failureMsg, ok := refund["failure_reason"].(string); ok && failureMsg != "" { + failureReason = &failureMsg + } + } + + return &RefundResponse{ + RefundID: refund["id"].(string), + TransactionID: transactionID, + Amount: amount, + Currency: "USD", + Status: status, + ProcessedAt: time.Now(), + FailureReason: failureReason, + }, nil +} + +// GetPaymentStatus gets payment status from Stripe. +func (s *StripeGateway) GetPaymentStatus(ctx context.Context, transactionID string) (*PaymentStatus, error) { + intentResp, err := s.makeStripeRequest(ctx, "GET", "/v1/payment_intents/"+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get payment intent: %w", err) + } + intent, ok := intentResp.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid payment intent response") + } + + refundsResp, err := s.makeStripeRequest(ctx, "GET", "/v1/refunds?payment_intent="+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get refunds: %w", err) + } + refundsData, _ := refundsResp.(map[string]any) + refundsList, _ := refundsData["data"].([]any) + + refunds := make([]RefundInfo, len(refundsList)) + for i, refundInterface := range refundsList { + refund, _ := refundInterface.(map[string]any) + createdAt, _ := time.Parse(time.RFC3339, refund["created"].(string)) + refunds[i] = RefundInfo{ + RefundID: refund["id"].(string), + Amount: float64(refund["amount"].(int64)) / 100, + Status: refund["status"].(string), + CreatedAt: createdAt, + } + } + + createdAt, _ := time.Parse(time.RFC3339, intent["created"].(string)) + + var failureReason *string + if lastErr, ok := intent["last_payment_error"].(map[string]any); ok { + if message, ok := lastErr["message"].(string); ok { + failureReason = &message + } + } + + return &PaymentStatus{ + TransactionID: transactionID, + Status: s.mapStripeStatus(intent["status"].(string)), + Amount: float64(intent["amount"].(int64)) / 100, + Currency: strings.ToUpper(intent["currency"].(string)), + CreatedAt: createdAt, + UpdatedAt: time.Now(), + FailureReason: failureReason, + Refunds: refunds, + }, nil +} diff --git a/apps/api-server/internal/payment/gateway_stripe_helpers.go b/apps/api-server/internal/payment/gateway_stripe_helpers.go new file mode 100644 index 0000000..75a072c --- /dev/null +++ b/apps/api-server/internal/payment/gateway_stripe_helpers.go @@ -0,0 +1,103 @@ +package payment + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// makeStripeRequest issues an authenticated HTTP request to the Stripe API and +// decodes the JSON response (or returns a structured Stripe error). +func (s *StripeGateway) makeStripeRequest(ctx context.Context, method, path string, data any) (any, error) { + url := "https://api.stripe.com" + path + + var body io.Reader + if data != nil { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal request data: %w", err) + } + body = strings.NewReader(string(jsonData)) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Stripe-Version", "2023-10-16") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + var errorResp map[string]any + if err := json.Unmarshal(respBody, &errorResp); err == nil { + if errorMsg, ok := errorResp["error"].(map[string]any); ok { + if message, ok := errorMsg["message"].(string); ok { + return nil, fmt.Errorf("stripe error: %s", message) + } + } + } + return nil, fmt.Errorf("stripe error: %s", string(respBody)) + } + + var result any + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return result, nil +} + +// mapStripeStatus converts a Stripe-specific status into a PaymentStatusType. +func (s *StripeGateway) mapStripeStatus(status string) PaymentStatusType { + switch status { + case "requires_payment_method", "requires_confirmation", "requires_action": + return PaymentStatusPending + case "processing": + return PaymentStatusProcessing + case "succeeded": + return PaymentStatusCompleted + case "canceled": + return PaymentStatusCancelled + default: + return PaymentStatusFailed + } +} + +// mapPaymentMethodType converts a PaymentMethodType to Stripe's string identifier. +func (s *StripeGateway) mapPaymentMethodType(pmt PaymentMethodType) string { + switch pmt { + case PaymentMethodTypeCreditCard: + return "card" + case PaymentMethodTypeBankAccount: + return "bank_account" + default: + return "card" + } +} + +// computeHMACSHA256 returns the hex-encoded HMAC-SHA256 for timestamped payloads. +func (s *StripeGateway) computeHMACSHA256(timestamp, payload, secret string) string { + data := timestamp + "." + payload + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/apps/api-server/internal/payment/gateway_stripe_methods.go b/apps/api-server/internal/payment/gateway_stripe_methods.go new file mode 100644 index 0000000..aa48df1 --- /dev/null +++ b/apps/api-server/internal/payment/gateway_stripe_methods.go @@ -0,0 +1,90 @@ +package payment + +import ( + "context" + "crypto/hmac" + "fmt" + "strings" + "time" +) + +// CreatePaymentMethod creates a payment method in Stripe and attaches it to the customer. +func (s *StripeGateway) CreatePaymentMethod(ctx context.Context, req *CreatePaymentMethodRequest) (*PaymentMethod, error) { + pmData := map[string]any{ + "type": s.mapPaymentMethodType(req.Type), + "card": map[string]string{"token": req.Token}, + "metadata": req.Metadata, + } + + pmResp, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_methods", pmData) + if err != nil { + return nil, fmt.Errorf("failed to create payment method: %w", err) + } + pm, ok := pmResp.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid payment method response") + } + + attachData := map[string]any{"customer": req.CustomerID} + if _, err := s.makeStripeRequest(ctx, "POST", "/v1/payment_methods/"+pm["id"].(string)+"/attach", attachData); err != nil { + return nil, fmt.Errorf("failed to attach payment method: %w", err) + } + + if req.IsDefault { + customerData := map[string]any{ + "invoice_settings": map[string]string{"default_payment_method": pm["id"].(string)}, + } + if _, err := s.makeStripeRequest(ctx, "POST", "/v1/customers/"+req.CustomerID, customerData); err != nil { + return nil, fmt.Errorf("failed to set default payment method: %w", err) + } + } + + card, _ := pm["card"].(map[string]any) + createdAt, _ := time.Parse(time.RFC3339, pm["created"].(string)) + + return &PaymentMethod{ + ID: pm["id"].(string), + Type: req.Type, + CustomerID: req.CustomerID, + Last4: card["last4"].(string), + Brand: card["brand"].(string), + ExpiryMonth: int(card["exp_month"].(float64)), + ExpiryYear: int(card["exp_year"].(float64)), + IsDefault: req.IsDefault, + CreatedAt: createdAt, + Metadata: req.Metadata, + }, nil +} + +// DeletePaymentMethod deletes (detaches) a payment method from Stripe. +func (s *StripeGateway) DeletePaymentMethod(ctx context.Context, paymentMethodID string) error { + if _, err := s.makeStripeRequest(ctx, "DELETE", "/v1/payment_methods/"+paymentMethodID, nil); err != nil { + return fmt.Errorf("failed to delete payment method: %w", err) + } + return nil +} + +// ValidateWebhook validates a Stripe webhook signature using the configured secret. +func (s *StripeGateway) ValidateWebhook(ctx context.Context, signature string, body []byte) (bool, error) { + if s.webhookSecret == "" { + return false, fmt.Errorf("webhook secret not configured") + } + + parts := strings.Split(signature, ",") + var timestamp, v1Signature string + for _, part := range parts { + part = strings.TrimSpace(part) + if after, ok := strings.CutPrefix(part, "t="); ok { + timestamp = after + } else if after, ok := strings.CutPrefix(part, "v1="); ok { + v1Signature = after + } + } + + if timestamp == "" || v1Signature == "" { + return false, fmt.Errorf("invalid signature format") + } + + expectedSignature := s.computeHMACSHA256(timestamp, string(body), s.webhookSecret) + return hmac.Equal([]byte(v1Signature), []byte(expectedSignature)), nil +} diff --git a/apps/api-server/internal/payment/webhook.go b/apps/api-server/internal/payment/webhook.go index 9542021..47d9907 100644 --- a/apps/api-server/internal/payment/webhook.go +++ b/apps/api-server/internal/payment/webhook.go @@ -1,16 +1,13 @@ package payment import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "time" +"encoding/json" +"log" +"net/http" - "github.com/gin-gonic/gin" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +"github.com/gin-gonic/gin" + +"github.com/nutcas3/telecom-platform/apps/api-server/internal/database" ) // WebhookEvent represents a payment webhook event @@ -95,500 +92,3 @@ func (wh *WebhookHandler) HandleStripeWebhook(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "received"}) } -// handlePaymentSucceeded handles successful payment events -func (wh *WebhookHandler) handlePaymentSucceeded(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]any) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - amount := float64(paymentIntent["amount"].(int64)) / 100 - currency := paymentIntent["currency"].(string) - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "COMPLETED" - transaction.UpdatedAt = time.Now() - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - // If it's a top-up, update subscriber balance - if transaction.Type == "TOP_UP" { - err = wh.db.UpdateSubscriberBalance(ctx, transaction.SubscriberID, amount) - if err != nil { - return fmt.Errorf("failed to update subscriber balance: %w", err) - } - - // Send notification to subscriber - err = wh.sendTopUpNotification(ctx, transaction.SubscriberID, amount, currency) - if err != nil { - log.Printf("Failed to send top-up notification: %v", err) - } - } - - log.Printf("Payment succeeded: %s, amount: %.2f %s", transactionID, amount, currency) - return nil -} - -// handlePaymentFailed handles failed payment events -func (wh *WebhookHandler) handlePaymentFailed(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]any) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "FAILED" - transaction.UpdatedAt = time.Now() - - // Add failure reason if available - if lastPaymentError, ok := paymentIntent["last_payment_error"].(map[string]any); ok { - if message, ok := lastPaymentError["message"].(string); ok { - transaction.Description += " - Failed: " + message - } - } - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - // Create alert for failed payment - subID := int(transaction.SubscriberID) - alert := &models.Alert{ - SubscriberID: &subID, - Type: models.AlertTypePaymentFailed, - Severity: models.AlertSeverityMedium, - Message: fmt.Sprintf("Payment of %.2f %s failed", transaction.Amount, transaction.Currency), - Resolved: false, - Timestamp: time.Now(), - } - - err = wh.db.CreateAlert(ctx, alert) - if err != nil { - log.Printf("Failed to create payment failure alert: %v", err) - } - - log.Printf("Payment failed: %s", transactionID) - return nil -} - -// handlePaymentCanceled handles canceled payment events -func (wh *WebhookHandler) handlePaymentCanceled(ctx context.Context, event WebhookEvent) error { - paymentIntent, ok := event.Data["object"].(map[string]any) - if !ok { - return fmt.Errorf("invalid payment intent object") - } - - transactionID, ok := paymentIntent["id"].(string) - if !ok { - return fmt.Errorf("missing transaction ID") - } - - // Get transaction from database - transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) - if err != nil { - return fmt.Errorf("failed to get transaction: %w", err) - } - - // Update transaction status - transaction.Status = "CANCELLED" - transaction.UpdatedAt = time.Now() - - err = wh.db.UpdateTransaction(ctx, transaction) - if err != nil { - return fmt.Errorf("failed to update transaction: %w", err) - } - - log.Printf("Payment canceled: %s", transactionID) - return nil -} - -// handleDisputeCreated handles dispute events -func (wh *WebhookHandler) handleDisputeCreated(ctx context.Context, event WebhookEvent) error { - dispute, ok := event.Data["object"].(map[string]any) - if !ok { - return fmt.Errorf("invalid dispute object") - } - - chargeID, ok := dispute["charge"].(string) - if !ok { - return fmt.Errorf("missing charge ID") - } - - amount := float64(dispute["amount"].(int64)) / 100 - reason, _ := dispute["reason"].(string) - - // Find transaction by charge ID - transaction, err := wh.db.GetTransactionByChargeID(ctx, chargeID) - if err != nil { - return fmt.Errorf("failed to find transaction for charge %s: %w", chargeID, err) - } - - // Create high-priority alert for dispute - dispSubID := int(transaction.SubscriberID) - alert := &models.Alert{ - SubscriberID: &dispSubID, - Type: models.AlertTypePaymentFailed, - Severity: models.AlertSeverityHigh, - Message: fmt.Sprintf("Payment dispute created for %.2f %s. Reason: %s", amount, transaction.Currency, reason), - Resolved: false, - Timestamp: time.Now(), - } - - err = wh.db.CreateAlert(ctx, alert) - if err != nil { - return fmt.Errorf("failed to create dispute alert: %w", err) - } - - log.Printf("Dispute created for charge %s, amount: %.2f", chargeID, amount) - return nil -} - -// sendTopUpNotification sends a notification for successful top-up -func (wh *WebhookHandler) sendTopUpNotification(ctx context.Context, subscriberID uint, amount float64, currency string) error { - // Get subscriber details - subscriber, err := wh.db.GetSubscriber(ctx, subscriberID) - if err != nil { - return fmt.Errorf("failed to get subscriber: %w", err) - } - - // Create notification - notification := &models.Notification{ - SubscriberID: subscriberID, - Type: "PAYMENT_SUCCESS", - Title: "Top-up Successful", - Message: fmt.Sprintf("Your account has been topped up with %.2f %s. New balance: %.2f %s", - amount, currency, subscriber.Balance, currency), - Read: false, - CreatedAt: time.Now(), - } - - err = wh.db.CreateNotification(ctx, notification) - if err != nil { - return fmt.Errorf("failed to create notification: %w", err) - } - - // Send email notification (implementation would depend on email service) - // This is a placeholder for email sending logic - log.Printf("Top-up notification sent to subscriber %d: %.2f %s", subscriberID, amount, currency) - - return nil -} - -// PaymentController handles payment-related HTTP endpoints -type PaymentController struct { - paymentService *PaymentService - db *database.Database -} - -// NewPaymentController creates a new payment controller -func NewPaymentController(paymentService *PaymentService, db *database.Database) *PaymentController { - return &PaymentController{ - paymentService: paymentService, - db: db, - } -} - -// SetupRoutes sets up payment routes -func (pc *PaymentController) SetupRoutes(router *gin.RouterGroup) { - payments := router.Group("/payments") - { - payments.POST("/top-up", pc.ProcessTopUp) - payments.POST("/payment-methods", pc.CreatePaymentMethod) - payments.DELETE("/payment-methods/:id", pc.DeletePaymentMethod) - payments.GET("/payment-methods", pc.ListPaymentMethods) - payments.GET("/transactions", pc.ListTransactions) - payments.GET("/transactions/:id", pc.GetTransaction) - payments.POST("/transactions/:id/refund", pc.RefundTransaction) - } - - webhook := router.Group("/webhooks") - { - webhook.POST("/stripe", pc.HandleStripeWebhook) - } -} - -// ProcessTopUp processes a top-up payment -func (pc *PaymentController) ProcessTopUp(c *gin.Context) { - var req struct { - Amount float64 `json:"amount" binding:"required,min=0.01"` - PaymentMethodID string `json:"paymentMethodId" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Get subscriber ID from context (would be set by auth middleware) - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transaction, err := pc.paymentService.ProcessTopUp(c.Request.Context(), subscriberID, req.Amount, req.PaymentMethodID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "transaction": transaction, - "message": "Top-up processed successfully", - }) -} - -// CreatePaymentMethod creates a new payment method -func (pc *PaymentController) CreatePaymentMethod(c *gin.Context) { - var req struct { - Type string `json:"type" binding:"required"` - Token string `json:"token" binding:"required"` - IsDefault bool `json:"isDefault"` - Metadata map[string]string `json:"metadata"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - createReq := &CreatePaymentMethodRequest{ - Type: PaymentMethodType(req.Type), - Token: req.Token, - IsDefault: req.IsDefault, - Metadata: req.Metadata, - } - - paymentMethod, err := pc.paymentService.CreatePaymentMethod(c.Request.Context(), subscriberID, createReq) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "paymentMethod": paymentMethod, - "message": "Payment method created successfully", - }) -} - -// DeletePaymentMethod deletes a payment method -func (pc *PaymentController) DeletePaymentMethod(c *gin.Context) { - paymentMethodID := c.Param("id") - if paymentMethodID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Payment method ID required"}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - // Verify payment method belongs to subscriber - pm, err := pc.db.GetPaymentMethod(c.Request.Context(), paymentMethodID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Payment method not found"}) - return - } - - if pm.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Payment method does not belong to subscriber"}) - return - } - - err = pc.paymentService.gateway.DeletePaymentMethod(c.Request.Context(), pm.GatewayID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Delete from database - err = pc.db.DeletePaymentMethod(c.Request.Context(), paymentMethodID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Payment method deleted successfully"}) -} - -// ListPaymentMethods lists payment methods for a subscriber -func (pc *PaymentController) ListPaymentMethods(c *gin.Context) { - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - paymentMethods, err := pc.db.ListPaymentMethods(c.Request.Context(), uint(subscriberID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"paymentMethods": paymentMethods}) -} - -// ListTransactions lists transactions for a subscriber -func (pc *PaymentController) ListTransactions(c *gin.Context) { - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transactions, err := pc.db.ListTransactions(c.Request.Context(), uint(subscriberID)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"transactions": transactions}) -} - -// GetTransaction gets a specific transaction -func (pc *PaymentController) GetTransaction(c *gin.Context) { - transactionID := c.Param("id") - if transactionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) - return - } - - if transaction.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) - return - } - - c.JSON(http.StatusOK, gin.H{"transaction": transaction}) -} - -// RefundTransaction refunds a transaction -func (pc *PaymentController) RefundTransaction(c *gin.Context) { - transactionID := c.Param("id") - if transactionID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Transaction ID required"}) - return - } - - var req struct { - Amount float64 `json:"amount" binding:"required,min=0.01"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - subscriberID := c.GetInt("subscriberID") - if subscriberID == 0 { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - // Get transaction - transaction, err := pc.db.GetTransaction(c.Request.Context(), transactionID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) - return - } - - if transaction.SubscriberID != uint(subscriberID) { - c.JSON(http.StatusForbidden, gin.H{"error": "Transaction does not belong to subscriber"}) - return - } - - if transaction.Status != "COMPLETED" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot refund non-completed transaction"}) - return - } - - // Process refund - refundResp, err := pc.paymentService.gateway.RefundPayment(c.Request.Context(), transaction.TransactionID, req.Amount) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Create refund record - refund := &models.Transaction{ - SubscriberID: transaction.SubscriberID, - TransactionID: refundResp.RefundID, - Type: "REFUND", - Amount: req.Amount, - Currency: refundResp.Currency, - Status: string(refundResp.Status), - Description: fmt.Sprintf("Refund for transaction %s", transactionID), - ParentID: &transaction.ID, - CreatedAt: refundResp.ProcessedAt, - UpdatedAt: refundResp.ProcessedAt, - } - - err = pc.db.CreateTransaction(c.Request.Context(), refund) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save refund"}) - return - } - - // Update subscriber balance - err = pc.db.UpdateSubscriberBalance(c.Request.Context(), transaction.SubscriberID, -req.Amount) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "refund": refund, - "message": "Refund processed successfully", - }) -} - -// HandleStripeWebhook handles Stripe webhooks (delegates to webhook handler) -func (pc *PaymentController) HandleStripeWebhook(c *gin.Context) { - webhookHandler := NewWebhookHandler(pc.paymentService, pc.db) - webhookHandler.HandleStripeWebhook(c) -} diff --git a/apps/api-server/internal/payment/webhook_handlers.go b/apps/api-server/internal/payment/webhook_handlers.go new file mode 100644 index 0000000..1f551cd --- /dev/null +++ b/apps/api-server/internal/payment/webhook_handlers.go @@ -0,0 +1,175 @@ +package payment + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// handlePaymentSucceeded handles successful payment events. +func (wh *WebhookHandler) handlePaymentSucceeded(ctx context.Context, event WebhookEvent) error { + paymentIntent, ok := event.Data["object"].(map[string]any) + if !ok { + return fmt.Errorf("invalid payment intent object") + } + transactionID, ok := paymentIntent["id"].(string) + if !ok { + return fmt.Errorf("missing transaction ID") + } + amount := float64(paymentIntent["amount"].(int64)) / 100 + currency := paymentIntent["currency"].(string) + + transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) + if err != nil { + return fmt.Errorf("failed to get transaction: %w", err) + } + + transaction.Status = "COMPLETED" + transaction.UpdatedAt = time.Now() + if err := wh.db.UpdateTransaction(ctx, transaction); err != nil { + return fmt.Errorf("failed to update transaction: %w", err) + } + + if transaction.Type == "TOP_UP" { + if err := wh.db.UpdateSubscriberBalance(ctx, transaction.SubscriberID, amount); err != nil { + return fmt.Errorf("failed to update subscriber balance: %w", err) + } + if err := wh.sendTopUpNotification(ctx, transaction.SubscriberID, amount, currency); err != nil { + log.Printf("Failed to send top-up notification: %v", err) + } + } + + log.Printf("Payment succeeded: %s, amount: %.2f %s", transactionID, amount, currency) + return nil +} + +// handlePaymentFailed handles failed payment events. +func (wh *WebhookHandler) handlePaymentFailed(ctx context.Context, event WebhookEvent) error { + paymentIntent, ok := event.Data["object"].(map[string]any) + if !ok { + return fmt.Errorf("invalid payment intent object") + } + transactionID, ok := paymentIntent["id"].(string) + if !ok { + return fmt.Errorf("missing transaction ID") + } + + transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) + if err != nil { + return fmt.Errorf("failed to get transaction: %w", err) + } + + transaction.Status = "FAILED" + transaction.UpdatedAt = time.Now() + if lastErr, ok := paymentIntent["last_payment_error"].(map[string]any); ok { + if message, ok := lastErr["message"].(string); ok { + transaction.Description += " - Failed: " + message + } + } + if err := wh.db.UpdateTransaction(ctx, transaction); err != nil { + return fmt.Errorf("failed to update transaction: %w", err) + } + + subID := int(transaction.SubscriberID) + alert := &models.Alert{ + SubscriberID: &subID, + Type: models.AlertTypePaymentFailed, + Severity: models.AlertSeverityMedium, + Message: fmt.Sprintf("Payment of %.2f %s failed", transaction.Amount, transaction.Currency), + Resolved: false, + Timestamp: time.Now(), + } + if err := wh.db.CreateAlert(ctx, alert); err != nil { + log.Printf("Failed to create payment failure alert: %v", err) + } + + log.Printf("Payment failed: %s", transactionID) + return nil +} + +// handlePaymentCanceled handles canceled payment events. +func (wh *WebhookHandler) handlePaymentCanceled(ctx context.Context, event WebhookEvent) error { + paymentIntent, ok := event.Data["object"].(map[string]any) + if !ok { + return fmt.Errorf("invalid payment intent object") + } + transactionID, ok := paymentIntent["id"].(string) + if !ok { + return fmt.Errorf("missing transaction ID") + } + + transaction, err := wh.db.GetTransactionByGatewayID(ctx, transactionID) + if err != nil { + return fmt.Errorf("failed to get transaction: %w", err) + } + transaction.Status = "CANCELLED" + transaction.UpdatedAt = time.Now() + if err := wh.db.UpdateTransaction(ctx, transaction); err != nil { + return fmt.Errorf("failed to update transaction: %w", err) + } + + log.Printf("Payment canceled: %s", transactionID) + return nil +} + +// handleDisputeCreated handles dispute events. +func (wh *WebhookHandler) handleDisputeCreated(ctx context.Context, event WebhookEvent) error { + dispute, ok := event.Data["object"].(map[string]any) + if !ok { + return fmt.Errorf("invalid dispute object") + } + chargeID, ok := dispute["charge"].(string) + if !ok { + return fmt.Errorf("missing charge ID") + } + amount := float64(dispute["amount"].(int64)) / 100 + reason, _ := dispute["reason"].(string) + + transaction, err := wh.db.GetTransactionByChargeID(ctx, chargeID) + if err != nil { + return fmt.Errorf("failed to find transaction for charge %s: %w", chargeID, err) + } + + dispSubID := int(transaction.SubscriberID) + alert := &models.Alert{ + SubscriberID: &dispSubID, + Type: models.AlertTypePaymentFailed, + Severity: models.AlertSeverityHigh, + Message: fmt.Sprintf("Payment dispute created for %.2f %s. Reason: %s", amount, transaction.Currency, reason), + Resolved: false, + Timestamp: time.Now(), + } + if err := wh.db.CreateAlert(ctx, alert); err != nil { + return fmt.Errorf("failed to create dispute alert: %w", err) + } + + log.Printf("Dispute created for charge %s, amount: %.2f", chargeID, amount) + return nil +} + +// sendTopUpNotification sends a notification for successful top-up. +func (wh *WebhookHandler) sendTopUpNotification(ctx context.Context, subscriberID uint, amount float64, currency string) error { + subscriber, err := wh.db.GetSubscriber(ctx, subscriberID) + if err != nil { + return fmt.Errorf("failed to get subscriber: %w", err) + } + + notification := &models.Notification{ + SubscriberID: subscriberID, + Type: "PAYMENT_SUCCESS", + Title: "Top-up Successful", + Message: fmt.Sprintf("Your account has been topped up with %.2f %s. New balance: %.2f %s", + amount, currency, subscriber.Balance, currency), + Read: false, + CreatedAt: time.Now(), + } + if err := wh.db.CreateNotification(ctx, notification); err != nil { + return fmt.Errorf("failed to create notification: %w", err) + } + + log.Printf("Top-up notification sent to subscriber %d: %.2f %s", subscriberID, amount, currency) + return nil +}