mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services/eshop"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaymentProvider represents a minimal interface for payment services used by checkout.
|
||||
// It is implemented by RevolutService, StripeService and can be satisfied by fakes in tests.
|
||||
type PaymentProvider interface {
|
||||
CreatePayment(order *models.EshopOrder) (*eshop.PaymentResult, error)
|
||||
}
|
||||
|
||||
type CheckoutController struct {
|
||||
DB *gorm.DB
|
||||
RevolutService PaymentProvider
|
||||
StripeService *eshop.StripeService
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func NewCheckoutController(db *gorm.DB, cfg *config.Config) *CheckoutController {
|
||||
return &CheckoutController{
|
||||
DB: db,
|
||||
RevolutService: eshop.NewRevolutService(cfg),
|
||||
StripeService: eshop.NewStripeService(cfg),
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
FirstName string `json:"first_name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone string `json:"phone"`
|
||||
BillingAddress json.RawMessage `json:"billing_address"`
|
||||
ShippingAddress json.RawMessage `json:"shipping_address"`
|
||||
ShippingMethod string `json:"shipping_method" binding:"required"`
|
||||
// For Packeta, we might receive packet_point_id in shipping_address or separately
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) Checkout(c *gin.Context) {
|
||||
var req CheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email address"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate phone format (basic Czech phone validation)
|
||||
if req.Phone != "" {
|
||||
// Remove spaces, dashes, parentheses
|
||||
phone := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(req.Phone, " ", ""), "-", ""), "(", "")
|
||||
phone = strings.ReplaceAll(phone, ")", "")
|
||||
|
||||
// Check if it starts with +420 or is 9 digits (Czech format)
|
||||
if !strings.HasPrefix(phone, "+420") && len(phone) != 9 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid phone number format"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate shipping method
|
||||
validShippingMethods := []string{"packeta", "courier"}
|
||||
isValidShipping := false
|
||||
for _, method := range validShippingMethods {
|
||||
if req.ShippingMethod == method {
|
||||
isValidShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValidShipping {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid shipping method"})
|
||||
return
|
||||
}
|
||||
|
||||
// For Packeta, validate that shipping address contains point ID
|
||||
if req.ShippingMethod == "packeta" {
|
||||
var pointData struct {
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(req.ShippingAddress, &pointData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Packeta point data"})
|
||||
return
|
||||
}
|
||||
if pointData.ID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Packeta point ID is required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Get Cart
|
||||
// Helper to find cart (duplicated from main.go - should be refactored to service)
|
||||
// For now, let's just assume we can get it via user or session
|
||||
userIDVal, _ := c.Get("userID")
|
||||
var userID *uint
|
||||
if u, ok := userIDVal.(uint); ok {
|
||||
userID = &u
|
||||
}
|
||||
sessionToken := c.GetHeader("X-Session-Token")
|
||||
if sessionToken == "" {
|
||||
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var cart models.EshopCart
|
||||
q := ctrl.DB.Preload("Items").Preload("Items.Product").Preload("Items.Variant").Where("completed = ?", false)
|
||||
if userID != nil {
|
||||
q = q.Where("user_id = ?", *userID)
|
||||
} else if sessionToken != "" {
|
||||
q = q.Where("session_token = ?", sessionToken)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No session identified"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := q.First(&cart).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cart not found or expired"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cart"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(cart.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cart is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate cart items and check inventory
|
||||
for _, item := range cart.Items {
|
||||
if item.Quantity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item quantity"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if product still exists and is active
|
||||
var product models.EshopProduct
|
||||
if err := ctrl.DB.Where("id = ? AND active = ?", item.ProductID, true).First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Product no longer available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check variant if exists
|
||||
if item.VariantID != nil {
|
||||
var variant models.EshopProductVariant
|
||||
if err := ctrl.DB.Where("id = ? AND product_id = ?", item.VariantID, item.ProductID).First(&variant).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Product variant no longer available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check stock for variant (negative values mean unlimited)
|
||||
if variant.StockQty >= 0 && variant.StockQty < item.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock for product variant"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify price hasn't changed
|
||||
if item.UnitPriceCents != product.PriceCents {
|
||||
// For MVP, we'll allow it but log it
|
||||
logger.Warn("Price mismatch for product %d: cart %d vs current %d", item.ProductID, item.UnitPriceCents, product.PriceCents)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Calculate totals
|
||||
var itemsTotal int64
|
||||
for _, item := range cart.Items {
|
||||
itemsTotal += item.UnitPriceCents * int64(item.Quantity)
|
||||
}
|
||||
|
||||
// Shipping price - simplistic logic for MVP
|
||||
var shippingPrice int64 = 0
|
||||
if req.ShippingMethod == "packeta" {
|
||||
shippingPrice = 7900 // 79 CZK
|
||||
} else if req.ShippingMethod == "courier" {
|
||||
shippingPrice = 9900 // 99 CZK
|
||||
}
|
||||
|
||||
totalAmount := itemsTotal + shippingPrice
|
||||
|
||||
// 3. Create Order
|
||||
// Generate order number (e.g. 202510001)
|
||||
orderNumber := fmt.Sprintf("%s%d", time.Now().Format("200601"), time.Now().Unix()%100000) // simplified
|
||||
|
||||
order := models.EshopOrder{
|
||||
OrderNumber: orderNumber,
|
||||
UserID: userID,
|
||||
SessionToken: sessionToken,
|
||||
Email: req.Email,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
BillingAddressJSON: string(req.BillingAddress),
|
||||
ShippingAddressJSON: string(req.ShippingAddress),
|
||||
Status: "awaiting_payment",
|
||||
TotalAmountCents: totalAmount,
|
||||
Currency: cart.Currency,
|
||||
ShippingMethod: req.ShippingMethod,
|
||||
ShippingPriceCents: shippingPrice,
|
||||
}
|
||||
|
||||
tx := ctrl.DB.Begin()
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create order: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create items
|
||||
for _, cartItem := range cart.Items {
|
||||
orderItem := models.EshopOrderItem{
|
||||
OrderID: order.ID,
|
||||
ProductID: cartItem.ProductID,
|
||||
VariantID: cartItem.VariantID,
|
||||
Name: cartItem.Product.Name,
|
||||
Quantity: cartItem.Quantity,
|
||||
UnitPriceCents: cartItem.UnitPriceCents,
|
||||
Currency: cartItem.Currency,
|
||||
VATRate: cartItem.Product.VATRate,
|
||||
}
|
||||
// Add variant name if exists
|
||||
if cartItem.Variant != nil {
|
||||
orderItem.Name += " (" + cartItem.Variant.Name + ")"
|
||||
orderItem.SKU = cartItem.Variant.SKU
|
||||
}
|
||||
if err := tx.Create(&orderItem).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create order item: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order items"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Choose payment provider (Stripe preferred, then Revolut)
|
||||
provider := ""
|
||||
if ctrl.Config.StripeEnabled {
|
||||
provider = "stripe"
|
||||
} else if ctrl.Config.RevolutEnabled {
|
||||
provider = "revolut"
|
||||
}
|
||||
|
||||
var result *eshop.PaymentResult
|
||||
var providerErr error
|
||||
|
||||
switch provider {
|
||||
case "stripe":
|
||||
result, providerErr = ctrl.StripeService.CreatePayment(&order)
|
||||
case "revolut":
|
||||
result, providerErr = ctrl.RevolutService.CreatePayment(&order)
|
||||
}
|
||||
|
||||
// Handle different payment provider responses
|
||||
if provider != "" && providerErr == nil && result != nil {
|
||||
payment := models.EshopPayment{
|
||||
OrderID: order.ID,
|
||||
Provider: provider,
|
||||
ProviderPaymentID: result.ProviderPaymentID,
|
||||
Status: "pending",
|
||||
AmountCents: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
RawPayloadJSON: result.RawPayloadJSON,
|
||||
}
|
||||
if err := tx.Create(&payment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create payment record: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
// Different response format based on provider
|
||||
response := gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_number": order.OrderNumber,
|
||||
"payment_provider": provider,
|
||||
}
|
||||
|
||||
if provider == "stripe" {
|
||||
// For Stripe, return the client secret for frontend confirmation
|
||||
var paymentData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.RawPayloadJSON), &paymentData); err == nil {
|
||||
if clientSecret, ok := paymentData["client_secret"].(string); ok {
|
||||
response["client_secret"] = clientSecret
|
||||
}
|
||||
}
|
||||
} else if strings.TrimSpace(result.RedirectURL) != "" {
|
||||
// For redirect-based providers (Revolut)
|
||||
response["payment_redirect_url"] = result.RedirectURL
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
// If provider was selected but failed or is not implemented yet, log the error
|
||||
if provider != "" && providerErr != nil {
|
||||
logger.Error("Payment provider error (%s): %v", provider, providerErr)
|
||||
}
|
||||
|
||||
// 5. No online payment provider available – fall back to manual email instructions
|
||||
supportEmail := ctrl.getSupportEmail()
|
||||
manualMeta := map[string]string{
|
||||
"reason": "no_online_provider",
|
||||
"contact_email": supportEmail,
|
||||
}
|
||||
if providerErr != nil {
|
||||
manualMeta["provider_error"] = providerErr.Error()
|
||||
}
|
||||
metaJSON, _ := json.Marshal(manualMeta)
|
||||
|
||||
manualPayment := models.EshopPayment{
|
||||
OrderID: order.ID,
|
||||
Provider: "manual_email",
|
||||
Status: "pending",
|
||||
AmountCents: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
RawPayloadJSON: string(metaJSON),
|
||||
}
|
||||
if err := tx.Create(&manualPayment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create manual payment record: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_number": order.OrderNumber,
|
||||
"manual_payment": true,
|
||||
"contact_email": supportEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// getSupportEmail returns the best support email for manual orders
|
||||
func (ctrl *CheckoutController) getSupportEmail() string {
|
||||
email := strings.TrimSpace(ctrl.Config.ContactEmail)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(ctrl.Config.AdminEmail)
|
||||
}
|
||||
|
||||
var settings models.EshopSettings
|
||||
if err := ctrl.DB.First(&settings).Error; err == nil {
|
||||
if se := strings.TrimSpace(settings.SupportEmail); se != "" {
|
||||
email = se
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
// Safe default to avoid returning empty email
|
||||
email = "info@example.com"
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// GetOrder returns a single order by ID, ensuring the user/session owns it
|
||||
func (ctrl *CheckoutController) GetOrder(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
userIDVal, _ := c.Get("userID")
|
||||
var userID *uint
|
||||
if u, ok := userIDVal.(uint); ok {
|
||||
userID = &u
|
||||
}
|
||||
sessionToken := c.GetHeader("X-Session-Token")
|
||||
if sessionToken == "" {
|
||||
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var order models.EshopOrder
|
||||
q := ctrl.DB.Preload("Items").Preload("Payments").Preload("Labels").Where("id = ?", id)
|
||||
|
||||
// Security check: only allow owner to view
|
||||
if userID != nil {
|
||||
q = q.Where("user_id = ?", *userID)
|
||||
} else if sessionToken != "" {
|
||||
q = q.Where("session_token = ?", sessionToken)
|
||||
} else {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := q.First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, order)
|
||||
}
|
||||
|
||||
// RevolutWebhook handles asynchronous notifications from Revolut about payment state changes.
|
||||
// It expects a JSON payload containing order status updates.
|
||||
func (ctrl *CheckoutController) RevolutWebhook(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] failed to read body: %v", err)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
signature := strings.TrimSpace(c.GetHeader("Revolut-Pay-Payload-Signature"))
|
||||
revolutService := eshop.NewRevolutService(ctrl.Config)
|
||||
valid, err := revolutService.VerifyWebhook(body, signature)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] signature verification failed: %v", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
logger.Error("[revolut-webhook] invalid signature")
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse webhook payload
|
||||
webhook, err := revolutService.ParseWebhook(body)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] failed to parse JSON: %v", err)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Find payment and related order
|
||||
tx := ctrl.DB.Begin()
|
||||
var payment models.EshopPayment
|
||||
if err := tx.Preload("Order").Where("provider = ? AND provider_payment_id = ?", "revolut", webhook.Order.ID).First(&payment).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] payment not found for id=%s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
order := payment.Order
|
||||
if order.ID == 0 {
|
||||
logger.Error("[revolut-webhook] loaded payment without order for id=%s", webhook.Order.ID)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Decide new statuses based on Revolut order status
|
||||
newPaymentStatus := ""
|
||||
newOrderStatus := ""
|
||||
|
||||
switch webhook.Order.Status {
|
||||
case "COMPLETED":
|
||||
newPaymentStatus = "paid"
|
||||
newOrderStatus = "paid"
|
||||
case "CANCELLED":
|
||||
newPaymentStatus = "cancelled"
|
||||
newOrderStatus = "cancelled"
|
||||
case "FAILED":
|
||||
newPaymentStatus = "failed"
|
||||
newOrderStatus = "cancelled"
|
||||
default:
|
||||
// For other states we just store the raw payload and return OK
|
||||
logger.Info("[revolut-webhook] unhandled status %s for payment %s", webhook.Order.Status, webhook.Order.ID)
|
||||
if err := tx.Model(&payment).Updates(map[string]interface{}{
|
||||
"raw_payload_json": string(body),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to store raw payload for payment %s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Update payment status
|
||||
updates := map[string]interface{}{
|
||||
"status": newPaymentStatus,
|
||||
"raw_payload_json": string(body),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := tx.Model(&payment).Updates(updates).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to update payment %s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status if needed
|
||||
if order.Status != newOrderStatus {
|
||||
if err := tx.Model(&models.EshopOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", newOrderStatus).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to update order %d status: %v", order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark cart as completed if order is paid
|
||||
if newOrderStatus == "paid" {
|
||||
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
|
||||
if order.UserID != nil {
|
||||
cartQ = cartQ.Where("user_id = ?", order.UserID)
|
||||
} else {
|
||||
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
|
||||
}
|
||||
if err := cartQ.Update("completed", true).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to mark cart as completed for order %d: %v", order.ID, err)
|
||||
// Non-fatal, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
logger.Info("[revolut-webhook] processed payment %s with status %s for order %d", webhook.Order.ID, webhook.Order.Status, order.ID)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// StripeWebhook handles Stripe webhook events
|
||||
func (ctrl *CheckoutController) StripeWebhook(c *gin.Context) {
|
||||
if ctrl.Config.StripeWebhookSecret == "" {
|
||||
logger.Error("[stripe-webhook] webhook secret not configured")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Webhook not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.Error("[stripe-webhook] failed to read webhook body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("Stripe-Signature")
|
||||
if signature == "" {
|
||||
logger.Error("[stripe-webhook] no signature provided")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No signature"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the event
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
logger.Error("[stripe-webhook] failed to parse event: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
eventType, ok := event["type"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no event type")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No event type"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[stripe-webhook] received event: %s", eventType)
|
||||
|
||||
switch eventType {
|
||||
case "payment_intent.succeeded":
|
||||
ctrl.handleStripePaymentSucceeded(event, c)
|
||||
case "payment_intent.payment_failed":
|
||||
ctrl.handleStripePaymentFailed(event, c)
|
||||
default:
|
||||
logger.Info("[stripe-webhook] unhandled event type: %s", eventType)
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) handleStripePaymentSucceeded(event map[string]interface{}, c *gin.Context) {
|
||||
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] invalid payment intent object")
|
||||
return
|
||||
}
|
||||
|
||||
paymentIntentID, ok := paymentIntent["id"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no payment intent ID")
|
||||
return
|
||||
}
|
||||
|
||||
metadata, _ := paymentIntent["metadata"].(map[string]interface{})
|
||||
orderIDStr, _ := metadata["order_id"].(string)
|
||||
|
||||
if orderIDStr == "" {
|
||||
logger.Error("[stripe-webhook] no order ID in payment intent metadata")
|
||||
return
|
||||
}
|
||||
|
||||
tx := ctrl.DB.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Find the payment record
|
||||
var payment models.EshopPayment
|
||||
if err := tx.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] payment record not found: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Update payment status
|
||||
payment.Status = "succeeded"
|
||||
if err := tx.Save(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status
|
||||
if err := tx.Model(&models.EshopOrder{}).Where("id = ?", payment.OrderID).Update("status", "paid").Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update order status: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Get order for cart completion
|
||||
var order models.EshopOrder
|
||||
if err := tx.First(&order, payment.OrderID).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to get order: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark cart as completed
|
||||
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
|
||||
if order.UserID != nil {
|
||||
cartQ = cartQ.Where("user_id = ?", *order.UserID)
|
||||
} else if strings.TrimSpace(order.SessionToken) != "" {
|
||||
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
|
||||
}
|
||||
if err := cartQ.Update("completed", true).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to mark cart as completed: %v", err)
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
logger.Info("[stripe-webhook] payment succeeded for order %d, payment intent %s", payment.OrderID, paymentIntentID)
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) handleStripePaymentFailed(event map[string]interface{}, c *gin.Context) {
|
||||
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] invalid payment intent object")
|
||||
return
|
||||
}
|
||||
|
||||
paymentIntentID, ok := paymentIntent["id"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no payment intent ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Find and update payment record
|
||||
var payment models.EshopPayment
|
||||
if err := ctrl.DB.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] payment record not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
payment.Status = "failed"
|
||||
if err := ctrl.DB.Save(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[stripe-webhook] payment failed for order %d, payment intent %s", payment.OrderID, paymentIntentID)
|
||||
}
|
||||
Reference in New Issue
Block a user