mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-11-14 16:05:13 +00:00
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.
This commit is contained in:
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"time"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PaymentGateway interface
|
// PaymentGateway interface
|
||||||
@@ -135,486 +123,3 @@ const (
|
|||||||
PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD"
|
PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD"
|
||||||
PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT"
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"encoding/json"
|
"log"
|
||||||
"fmt"
|
"net/http"
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/nutcas3/telecom-platform/apps/api-server/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookEvent represents a payment webhook event
|
// 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"})
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user