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) }