package handlers import ( "fmt" "gestion/db" "gestion/models" "gestion/services" "gestion/utils" "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" ) type BasketsRequest struct { Username string `json:"username"` ProductID int `json:"product_id"` NameProduct string `json:"name_product"` Category string `json:"category"` Quantity float64 `json:"quantity"` } // ============================================ // ✅ SÉCURISÉ: AddProductsBasket // ============================================ // POST /api/v1/panier/add func AddProductsBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) var req BasketsRequest if err := c.ShouldBindJSON(&req); err != nil { utils.BindErr(c, err) return } username, ok := c.Get("username") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } req.Username = username.(string) if req.Quantity <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"}) return } if req.ProductID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"}) return } if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon { c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"}) return } panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity) if err != nil { log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err) if err.Error() == "stock insuffisant" { c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"}) return } if strings.Contains(err.Error(), "prix introuvable") { c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"}) return } utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Produit ajouté au panier avec succès", "panier": panier, }) } func GetAllBaskets(c *gin.Context) { database := c.MustGet("database").(*db.Database) username := c.Param("username") log.Printf("📦 [GET_PANIER] Requête pour: %s", username) if username == "" { log.Printf("❌ [GET_PANIER] Username manquant") c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"}) return } // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [GET_PANIER] Username manquant dans JWT") c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } authUsernameStr := authUsername.(string) // ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session if username != authUsernameStr { log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!") log.Printf(" Username du JWT: %s", authUsernameStr) log.Printf(" Username demandé: %s", username) c.JSON(http.StatusForbidden, gin.H{ "error": "Vous ne pouvez accéder qu'à votre panier", }) return } // ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT username = authUsernameStr // ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT _, err := database.GetClientByUsername(username) if err != nil { log.Printf("❌ [GET_PANIER] Client inexistant: %s", username) c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"}) return } baskets, err := database.GetAllProductsInBasket(username) if err != nil { utils.ServerErr(c, "Erreur lors de la récupération du panier", err) return } var totalAmount float64 for _, item := range baskets { totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) } log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Panier récupéré avec succès", "panier": baskets, "count": len(baskets), "total_amount": totalAmount, }) } func DeleteProductFromBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) var req struct { ID int `json:"id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { utils.BindErr(c, err) return } // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [DEL_PANIER] Username manquant dans JWT") c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } authUsernameStr := authUsername.(string) log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr) // ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client itemUsername, err := database.GetBasketItemOwner(req.ID) if err != nil { log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID) c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"}) return } if itemUsername != authUsernameStr { log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!") log.Printf(" Client JWT: %s", authUsernameStr) log.Printf(" Propriétaire article: %s", itemUsername) c.JSON(http.StatusForbidden, gin.H{ "error": "Vous ne pouvez supprimer que vos articles", }) return } // Supprimer l'article err = database.DeleteProductFromBasket(req.ID) if err != nil { utils.ServerErr(c, "Erreur lors de la suppression", err) return } log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Produit supprimé du panier avec succès", "item_id": req.ID, }) } func ClearBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT") c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) return } authUsernameStr := authUsername.(string) log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr) baskets, err := database.GetAllProductsInBasket(authUsernameStr) if err != nil { log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Erreur lors du vidage du panier", }) return } err = database.ClearBasket(authUsernameStr) if err != nil { utils.ServerErr(c, "Erreur lors du vidage du panier", err) return } log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets)) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Panier vidé avec succès", }) } // POST /api/v1/checkout func ValidateBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) geoService := c.MustGet("geoService").(*services.GeoService) username, exists := c.Get("username") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"}) return } usernameStr := username.(string) lockKey := fmt.Sprintf("checkout_lock:%s", usernameStr) locked, errLock := db.Redis.SetNX(db.RedisCtx, lockKey, "1", 30*time.Second).Result() if errLock != nil || !locked { c.JSON(http.StatusConflict, gin.H{"error": "Un checkout est déjà en cours pour ce compte"}) return } defer db.Redis.Del(db.RedisCtx, lockKey) var req struct { DeliveryAddress string `json:"delivery_address" binding:"required"` UseReferralBalance bool `json:"use_referral_balance"` PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto" PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto) } if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"}) return } cmd := &models.Command{DeliveryAddress: req.DeliveryAddress} if err := database.CheckAddress(cmd); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress}) return } req.DeliveryAddress = cmd.DeliveryAddress // Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées) if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked { c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"}) return } } log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr) // ============================================ // 1️⃣ Vérifier que le panier n'est pas vide // ============================================ items, err := database.GetBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Impossible de récupérer le panier", err) return } if len(items) == 0 { log.Printf("❌ [CHECKOUT] Panier vide") c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"}) return } log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) // ============================================ // 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant // ============================================ var cartTotal float64 for _, item := range items { if price, ok := item["price"].(float64); ok { cartTotal += price } } // Détecter si le panier contient un article récompense (prix 0) hasRewardItem := false for _, item := range items { if price, ok := item["price"].(float64); ok && price == 0 { hasRewardItem = true break } } // Si récompense présente mais aucun produit payant → refuser if hasRewardItem && cartTotal <= 0 { log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr) c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"}) return } // Récupérer les paramètres globaux (zones + parrainage) appSettings, _ := database.GetSettings() // Récupérer le solde parrainage disponible (seulement si le système est activé) var referralBalance float64 if req.UseReferralBalance && appSettings.ReferralEnabled { referralBalance, _ = database.GetClientReferralBalance(usernameStr) } zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones) if !zoneResult.OK { if zoneResult.ZoneName == "inconnue" { log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress) c.JSON(http.StatusBadRequest, gin.H{ "error": "Adresse invalide : aucun code postal détecté", }) } else if zoneResult.ZoneName == "hors zone" { log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode) c.JSON(http.StatusBadRequest, gin.H{ "error": "Livraison non disponible pour ce code postal", "postal_code": zoneResult.PostalCode, }) } else { log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount), "zone": zoneResult.ZoneName, "minimum": zoneResult.MinAmount, "cart_total": cartTotal, "missing": zoneResult.MinAmount - cartTotal, "postal_code": zoneResult.PostalCode, "referral_balance": referralBalance, }) } return } // Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone. // Ex : zone 50€, crédit 50€ → panier doit être >= 100€ var referralUsed float64 if req.UseReferralBalance && referralBalance > 0 { effectivePayment := cartTotal - referralBalance if effectivePayment < zoneResult.MinAmount { needed := zoneResult.MinAmount + referralBalance log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount), "zone": zoneResult.ZoneName, "minimum": needed, "cart_total": cartTotal, "missing": needed - cartTotal, "referral_balance": referralBalance, "postal_code": zoneResult.PostalCode, }) return } referralUsed = referralBalance } log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed) if referralUsed > 0 { if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil { log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err) c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"}) return } log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) } // Vérifier que tous les produits du panier ont encore un prix actif unavailable, err := database.GetUnavailableBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Erreur vérification produits", err) return } if len(unavailable) > 0 { log.Printf("❌ [CHECKOUT] Produits sans prix actif: %v", unavailable) c.JSON(http.StatusBadRequest, gin.H{ "error": "Certains produits de votre panier ne sont plus disponibles", "products": unavailable, }) return } // Vérification option crypto isCrypto := req.PaymentMethod == "crypto" if isCrypto { np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient) if !npOk || np == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"}) return } if req.PayCurrency == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"}) return } } command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress) if err != nil { if referralUsed > 0 { _ = database.CreditClientReferral(usernameStr, referralUsed) } log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"}) return } commandID := command.ID if referralUsed > 0 { if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil { log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err) } } log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) // Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments. if isCrypto { np := c.MustGet("nowpayments").(*services.NowPaymentsClient) ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c)) payReq := &services.CreatePaymentRequest{ PriceAmount: cartTotal, PriceCurrency: "eur", PayCurrency: req.PayCurrency, OrderID: fmt.Sprintf("%d", commandID), IPNCallbackURL: ipnURL, } payResp, err := np.CreatePayment(payReq) if err != nil { // Annuler la commande et restaurer le panier / parrainage _ = database.CancelCryptoCommand(commandID) if referralUsed > 0 { _ = database.CreditClientReferral(usernameStr, referralUsed) } log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err) c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"}) return } // Passer la commande en 'pending_payment' (attente confirmation) if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err) } priceAmt, _ := payResp.PriceAmount.Float64() payAmt, _ := payResp.PayAmount.Float64() _, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt) log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency) c.JSON(http.StatusCreated, gin.H{ "success": true, "command_id": commandID, "payment_method": "crypto", "payment_status": payResp.Status, "pay_address": payResp.PayAddress, "pay_amount": payAmt, "pay_currency": payResp.PayCurrency, "price_amount": priceAmt, "price_currency": payResp.PriceCurrency, "message": "Commande créée - En attente de paiement crypto", }) return } // Notifier immédiatement tous les admins et agents cabine go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) // ============================================ // 3️⃣ Décrémenter le stock et vider le panier // ============================================ err = database.ClearBasketOnCheckout(usernameStr) if err != nil { // Stock insuffisant au moment du checkout (concurrent) → annuler la commande if strings.Contains(err.Error(), "stock insuffisant") { _ = database.CancelCryptoCommand(commandID) if referralUsed > 0 { _ = database.CreditClientReferral(usernameStr, referralUsed) } log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"}) return } utils.ServerErr(c, "Impossible de valider le panier", err) return } log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé") // ============================================ // 4️⃣ Auto-assignation livreur (optionnel) // ============================================ var assigned bool var assignInfo gin.H location, err := geoService.GeocodeAddress(req.DeliveryAddress) if err == nil { log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude) usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID) if errEligible == nil && len(usernames) > 0 { log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames)) nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{ Latitude: location.Latitude, Longitude: location.Longitude, }, usernames) if err == nil { log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance) // ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic travelTime, distance, err := services.CalculateETAWithTomTom( nearest.Location, services.Coordinates{ Latitude: location.Latitude, Longitude: location.Longitude, }, ) if err != nil { // Fallback sur ETA simple travelTime = nearest.EstimatedTime distance = nearest.Distance log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime) } log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance) // Assigner la commande au livreur err = database.AssignCommandToDeliverymanQueueWithCoords( commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, req.DeliveryAddress, ) if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err) } else { // Mettre à jour le statut du livreur err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) } // Notifier le livreur de la nouvelle commande notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) if referralUsed > 0 { notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed) } if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) } // Notifier le client clientOrderID := database.GetClientOrderID(commandID) clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID) database.NotifyClient(usernameStr, commandID, "assigned", clientMsg) assigned = true assignInfo = gin.H{ "username": nearest.Username, "distance_km": distance, "travel_time": travelTime, } log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username) } } else { log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err) } } else { log.Printf("⚠️ [CHECKOUT] Aucun livreur éligible disponible") } } else { log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err) } // ============================================ // 5️⃣ Réponse // ============================================ newBalance, _ := database.GetClientReferralBalance(usernameStr) resp := gin.H{ "success": true, "command_id": commandID, "client_order_number": command.ClientOrderID, "delivery_address": req.DeliveryAddress, "status": "pending", "referral_used": referralUsed, "referral_balance": newBalance, } if assigned { resp["message"] = "Commande créée et livreur assigné automatiquement" resp["auto_assigned"] = true resp["assigned_to"] = assignInfo resp["status"] = "assigned" log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID) } else { resp["message"] = "Commande créée - En attente d'assignation" resp["auto_assigned"] = false log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID) } c.JSON(http.StatusCreated, resp) } // getBaseURL construit l'URL de base depuis la requête en cours func getBaseURL(c *gin.Context) string { scheme := "https" if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { scheme = "http" } return fmt.Sprintf("%s://%s", scheme, c.Request.Host) }