mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 11:02:54 +00:00
- Replace map[string]interface{} with map[string]any in GraphQL, metrics, payment gateway, and webhook handlers
- Replace []byte(fmt.Sprintf()) with fmt.Appendf(nil, ...) for cursor encoding in GraphQL resolvers and tests
- Replace strings.HasPrefix/TrimPrefix with strings.CutPrefix for webhook signature parsing
- Replace stripe.Int64/Bool with new() constructor for Stripe payment parameters
- Rename Error
595 lines
17 KiB
Go
595 lines
17 KiB
Go
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]any `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]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)
|
|
}
|