From 3332edf9cae7d0025e88fc4c310f92bc8f7620bd Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Fri, 17 Apr 2026 15:53:57 +0300 Subject: [PATCH] feat: Add payment webhook handler with Stripe integration and transaction processing - Add WebhookHandler with Stripe webhook validation and event processing - Add webhook event handlers: payment succeeded, failed, canceled, and dispute created - Update transaction status and subscriber balance on successful payments - Create alerts for failed payments and disputes with appropriate severity levels - Add sendTopUpNotification to create notifications for successful top-ups - Add PaymentController --- apps/api-server/internal/payment/webhook.go | 594 ++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 apps/api-server/internal/payment/webhook.go diff --git a/apps/api-server/internal/payment/webhook.go b/apps/api-server/internal/payment/webhook.go new file mode 100644 index 0000000..2780c53 --- /dev/null +++ b/apps/api-server/internal/payment/webhook.go @@ -0,0 +1,594 @@ +package payment + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "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" +) + +// WebhookEvent represents a payment webhook event +type WebhookEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Object string `json:"object"` + Created int64 `json:"created"` + Livemode bool `json:"livemode"` + Data map[string]interface{} `json:"data"` +} + +// WebhookHandler handles payment webhooks +type WebhookHandler struct { + paymentService *PaymentService + db *database.Database +} + +// NewWebhookHandler creates a new webhook handler +func NewWebhookHandler(paymentService *PaymentService, db *database.Database) *WebhookHandler { + return &WebhookHandler{ + paymentService: paymentService, + db: db, + } +} + +// HandleStripeWebhook handles Stripe webhooks +func (wh *WebhookHandler) HandleStripeWebhook(c *gin.Context) { + body, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + signature := c.GetHeader("Stripe-Signature") + if signature == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing signature"}) + return + } + + // Validate webhook + valid, err := wh.paymentService.ValidateWebhook(c.Request.Context(), signature, body) + if err != nil { + log.Printf("Webhook validation error: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook"}) + return + } + + if !valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) + return + } + + // Parse event + var event WebhookEvent + if err := json.Unmarshal(body, &event); err != nil { + log.Printf("Failed to parse webhook event: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid event format"}) + return + } + + // Handle different event types + switch event.Type { + case "payment_intent.succeeded": + err = wh.handlePaymentSucceeded(c.Request.Context(), event) + case "payment_intent.payment_failed": + err = wh.handlePaymentFailed(c.Request.Context(), event) + case "payment_intent.canceled": + err = wh.handlePaymentCanceled(c.Request.Context(), event) + case "charge.dispute.created": + err = wh.handleDisputeCreated(c.Request.Context(), event) + default: + log.Printf("Unhandled webhook event type: %s", event.Type) + } + + if err != nil { + log.Printf("Error handling webhook event %s: %v", event.Type, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process webhook"}) + return + } + + 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]interface{}) + 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]interface{}) + 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]interface{}); 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]interface{}) + 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]interface{}) + 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) +}