Files
MyClub/internal/controllers/eshop/checkout_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

694 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}