diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 093788e6..51db1936 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -162,6 +162,36 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err) } + // Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments) + if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil { + log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err) + } + + // Migration: table de suivi des paiements crypto + if _, err = database.Exec(` + CREATE TABLE IF NOT EXISTS crypto_payments ( + id SERIAL PRIMARY KEY, + command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE, + nowpayment_id TEXT NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'waiting', + price_amount NUMERIC(10,2) NOT NULL, + price_currency VARCHAR(10) NOT NULL DEFAULT 'eur', + pay_currency VARCHAR(20) NOT NULL, + pay_address TEXT NOT NULL, + pay_amount NUMERIC(20,8) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + log.Fatalf("❌ Erreur migration crypto_payments: %v", err) + } + if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil { + log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err) + } + if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil { + log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err) + } + // Migration: points extra pour tous les pools de points (stockage dynamique par clé) if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil { log.Fatalf("❌ Erreur migration clients.points_extra: %v", err) diff --git a/backend/gestion/db/db_payments.go b/backend/gestion/db/db_payments.go new file mode 100644 index 00000000..8325e07b --- /dev/null +++ b/backend/gestion/db/db_payments.go @@ -0,0 +1,122 @@ +package db + +import ( + "database/sql" + "fmt" + "gestion/models" + "log" +) + +func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) { + var p models.CryptoPayment + err := d.DB.QueryRow(` + INSERT INTO crypto_payments (command_id, nowpayment_id, status, price_amount, price_currency, pay_currency, pay_address, pay_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, command_id, nowpayment_id, status, price_amount, price_currency, pay_currency, pay_address, pay_amount, created_at, updated_at`, + commandID, nowPaymentID, status, priceAmount, priceCurrency, payCurrency, payAddress, payAmount, + ).Scan(&p.ID, &p.CommandID, &p.NowPaymentID, &p.Status, &p.PriceAmount, &p.PriceCurrency, &p.PayCurrency, &p.PayAddress, &p.PayAmount, &p.CreatedAt, &p.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("CreateCryptoPayment: %w", err) + } + return &p, nil +} + +func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) { + var p models.CryptoPayment + err := d.DB.QueryRow(` + SELECT id, command_id, nowpayment_id, status, price_amount, price_currency, pay_currency, pay_address, pay_amount, created_at, updated_at + FROM crypto_payments WHERE command_id = $1 + ORDER BY created_at DESC LIMIT 1`, commandID, + ).Scan(&p.ID, &p.CommandID, &p.NowPaymentID, &p.Status, &p.PriceAmount, &p.PriceCurrency, &p.PayCurrency, &p.PayAddress, &p.PayAmount, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) { + var p models.CryptoPayment + err := d.DB.QueryRow(` + SELECT id, command_id, nowpayment_id, status, price_amount, price_currency, pay_currency, pay_address, pay_amount, created_at, updated_at + FROM crypto_payments WHERE nowpayment_id = $1`, nowPaymentID, + ).Scan(&p.ID, &p.CommandID, &p.NowPaymentID, &p.Status, &p.PriceAmount, &p.PriceCurrency, &p.PayCurrency, &p.PayAddress, &p.PayAmount, &p.CreatedAt, &p.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) { + rows, err := d.DB.Query(` + SELECT id, command_id, nowpayment_id, status, price_amount, price_currency, pay_currency, pay_address, pay_amount, created_at, updated_at + FROM crypto_payments + WHERE status NOT IN ('finished', 'failed', 'expired', 'refunded') + ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []models.CryptoPayment + for rows.Next() { + var p models.CryptoPayment + if err := rows.Scan(&p.ID, &p.CommandID, &p.NowPaymentID, &p.Status, &p.PriceAmount, &p.PriceCurrency, &p.PayCurrency, &p.PayAddress, &p.PayAmount, &p.CreatedAt, &p.UpdatedAt); err != nil { + continue + } + payments = append(payments, p) + } + return payments, nil +} + +func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error { + _, err := d.DB.Exec(` + UPDATE crypto_payments SET status = $1, pay_amount = $2, updated_at = NOW() + WHERE id = $3`, status, payAmount, id) + return err +} + +// ActivateCryptoCommand passe la commande de 'pending_payment' → 'pending' une fois le paiement confirmé +func (d *Database) ActivateCryptoCommand(commandID int) error { + _, err := d.DB.Exec(` + UPDATE commandes SET status = 'pending', updated_at = NOW() + WHERE id = $1 AND status = 'pending_payment'`, commandID) + return err +} + +// CancelCryptoCommand annule une commande en attente de paiement et restaure le stock +func (d *Database) CancelCryptoCommand(commandID int) error { + tx, err := d.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + rows, err := tx.Query(`SELECT product_id, quantite FROM command_items WHERE command_id = $1`, commandID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var productID int + var qty float64 + if err := rows.Scan(&productID, &qty); err != nil { + continue + } + if _, err := tx.Exec(`UPDATE products SET stock = stock + $1 WHERE id = $2`, qty, productID); err != nil { + log.Printf("[CANCEL CRYPTO] erreur restauration stock produit %d: %v", productID, err) + } + } + + if _, err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND status = 'pending_payment'`, commandID); err != nil { + return err + } + + return tx.Commit() +} diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 90b31d3e..ad92a4bd 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -72,7 +72,11 @@ type AppSettings struct { ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés - ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage + ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage + CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto + NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments + NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments + NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande } @@ -151,6 +155,17 @@ func (d *Database) GetSettings() (AppSettings, error) { } case "referral_enabled": settings.ReferralEnabled = value == "true" + case "crypto_payment_enabled": + settings.CryptoPaymentEnabled = value == "true" + case "nowpayments_api_key": + settings.NowPaymentsAPIKey = value + case "nowpayments_ipn_secret": + settings.NowPaymentsIPNSecret = value + case "nowpayments_currencies": + var currencies []string + if err := json.Unmarshal([]byte(value), ¤cies); err == nil { + settings.NowPaymentsCurrencies = currencies + } case "delivery_schedule": var sched DeliverySchedule if err := json.Unmarshal([]byte(value), &sched); err == nil { @@ -202,12 +217,24 @@ func (d *Database) UpdateSettings(s AppSettings) error { upsert := `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + if s.NowPaymentsCurrencies == nil { + s.NowPaymentsCurrencies = []string{} + } + currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies) + if err != nil { + return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err) + } + pairs := [][2]string{ {"penalties_enabled", boolStr(s.PenaltiesEnabled)}, {"show_amende_score", boolStr(s.ShowAmendeScore)}, {"points_enabled", boolStr(s.PointsEnabled)}, {"points_pools", string(poolsJSON)}, {"referral_enabled", boolStr(s.ReferralEnabled)}, + {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, + {"nowpayments_api_key", s.NowPaymentsAPIKey}, + {"nowpayments_ipn_secret", s.NowPaymentsIPNSecret}, + {"nowpayments_currencies", string(currenciesJSON)}, } schedJSON, err := json.Marshal(s.DeliverySchedule) diff --git a/backend/gestion/handlers/crypto_payment.go b/backend/gestion/handlers/crypto_payment.go new file mode 100644 index 00000000..b22eb3a9 --- /dev/null +++ b/backend/gestion/handlers/crypto_payment.go @@ -0,0 +1,107 @@ +package handlers + +import ( + "encoding/json" + "gestion/db" + "gestion/services" + "io" + "log" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// IPNWebhook - POST /api/v1/webhooks/nowpayments +// Reçoit les callbacks de NowPayments lors des changements de statut +func IPNWebhook(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient) + if !ok || np == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"}) + return + } + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"}) + return + } + + sig := c.GetHeader("x-nowpayments-sig") + if !np.VerifyIPN(body, sig) { + log.Printf("[IPN] signature invalide") + c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"}) + return + } + + var payload services.IPNPayload + if err := json.Unmarshal(body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"}) + return + } + + payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String()) + if err != nil || payment == nil { + log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String()) + // 200 pour éviter les retries NowPayments + c.JSON(http.StatusOK, gin.H{"ok": true}) + return + } + + payAmount, _ := payload.PayAmount.Float64() + if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil { + log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"}) + return + } + + switch payload.PaymentStatus { + case "finished", "confirmed": + if err := database.ActivateCryptoCommand(payment.CommandID); err != nil { + log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err) + } else { + log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String()) + } + case "failed", "expired": + if err := database.CancelCryptoCommand(payment.CommandID); err != nil { + log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err) + } else { + log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus) + } + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status +// Retourne le statut du paiement crypto d'une commande (polling côté client) +func GetCommandPaymentStatus(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + commandID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "id de commande invalide"}) + return + } + + payment, err := database.GetCryptoPaymentByCommandID(commandID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"}) + return + } + if payment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "command_id": payment.CommandID, + "payment_status": payment.Status, + "pay_address": payment.PayAddress, + "pay_amount": payment.PayAmount, + "pay_currency": payment.PayCurrency, + "price_amount": payment.PriceAmount, + "price_currency": payment.PriceCurrency, + }) +} diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index 9b07e9ec..1c40ba53 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -295,6 +295,8 @@ func ValidateBasket(c *gin.Context) { 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"}) @@ -411,6 +413,20 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) } + // 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 { @@ -430,6 +446,56 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) + // ============================================ + // PAIEMENT CRYPTO - créer le paiement 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.RestoreReferralBalance(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.StatusOK, 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) @@ -566,3 +632,12 @@ func ValidateBasket(c *gin.Context) { c.JSON(http.StatusOK, 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) +} diff --git a/backend/gestion/main.go b/backend/gestion/main.go index c18c84fb..0c8c9591 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -104,7 +104,7 @@ func main() { // Configuration CORS r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"}, + AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"}, ExposeHeaders: []string{"Content-Length"}, diff --git a/backend/gestion/models/payment.go b/backend/gestion/models/payment.go new file mode 100644 index 00000000..4693002b --- /dev/null +++ b/backend/gestion/models/payment.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// CryptoPayment suit un paiement crypto NowPayments lié à une commande +type CryptoPayment struct { + ID int `json:"id"` + CommandID int `json:"command_id"` + NowPaymentID string `json:"nowpayment_id"` + Status string `json:"status"` // waiting, confirming, confirmed, sending, partially_paid, finished, failed, refunded, expired + PriceAmount float64 `json:"price_amount"` + PriceCurrency string `json:"price_currency"` + PayCurrency string `json:"pay_currency"` + PayAddress string `json:"pay_address"` + PayAmount float64 `json:"pay_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 7392c8cd..aca5b234 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -107,8 +107,16 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // 🎁 PARRAINAGE CLIENT cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) + + // 💸 STATUT PAIEMENT CRYPTO + cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus) } + // ============================================ + // 💸 WEBHOOK NOWPAYMENTS (v1) - PUBLIC (pas d'auth, vérifié par HMAC) + // ============================================ + router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook) + // ============================================ // 🌍 GÉOCODAGE PUBLIC (v1) // ============================================ diff --git a/backend/gestion/services/nowpayments.go b/backend/gestion/services/nowpayments.go new file mode 100644 index 00000000..4a778c28 --- /dev/null +++ b/backend/gestion/services/nowpayments.go @@ -0,0 +1,182 @@ +package services + +import ( + "bytes" + "crypto/hmac" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +type NowPaymentsClient struct { + APIKey string + IPNSecret string + BaseURL string +} + +func NewNowPaymentsClient(apiKey, ipnSecret string) *NowPaymentsClient { + return &NowPaymentsClient{ + APIKey: apiKey, + IPNSecret: ipnSecret, + BaseURL: "https://api.nowpayments.io/v1", + } +} + +type CreatePaymentRequest struct { + PriceAmount float64 `json:"price_amount"` + PriceCurrency string `json:"price_currency"` + PayCurrency string `json:"pay_currency"` + OrderID string `json:"order_id"` + IPNCallbackURL string `json:"ipn_callback_url"` +} + +type CreatePaymentResponse struct { + PaymentID json.Number `json:"payment_id"` + PayAddress string `json:"pay_address"` + PayAmount json.Number `json:"pay_amount"` + PayCurrency string `json:"pay_currency"` + PriceAmount json.Number `json:"price_amount"` + PriceCurrency string `json:"price_currency"` + Status string `json:"payment_status"` + OrderID string `json:"order_id"` +} + +type IPNPayload struct { + PaymentID json.Number `json:"payment_id"` + PaymentStatus string `json:"payment_status"` + PayAddress string `json:"pay_address"` + PriceAmount json.Number `json:"price_amount"` + PriceCurrency string `json:"price_currency"` + PayAmount json.Number `json:"pay_amount"` + PayCurrency string `json:"pay_currency"` + OrderID string `json:"order_id"` +} + +func (c *NowPaymentsClient) CreatePayment(req *CreatePaymentRequest) (*CreatePaymentResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequest("POST", c.BaseURL+"/payment", bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("x-api-key", c.APIKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send 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 != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("nowpayments error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result CreatePaymentResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +type GetPaymentStatusResponse struct { + PaymentID json.Number `json:"payment_id"` + PaymentStatus string `json:"payment_status"` + PayAddress string `json:"pay_address"` + PriceAmount json.Number `json:"price_amount"` + PriceCurrency string `json:"price_currency"` + PayAmount json.Number `json:"pay_amount"` + ActuallyPaid json.Number `json:"actually_paid"` + PayCurrency string `json:"pay_currency"` + OrderID string `json:"order_id"` +} + +func (c *NowPaymentsClient) GetPaymentStatus(paymentID string) (*GetPaymentStatusResponse, error) { + httpReq, err := http.NewRequest("GET", c.BaseURL+"/payment/"+paymentID, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("x-api-key", c.APIKey) + + resp, err := httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send 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 != http.StatusOK { + return nil, fmt.Errorf("nowpayments error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result GetPaymentStatusResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *NowPaymentsClient) VerifyIPN(body []byte, signature string) bool { + if c.IPNSecret == "" || signature == "" { + return false + } + + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return false + } + + sorted := sortedJSON(payload) + + mac := hmac.New(sha512.New, []byte(c.IPNSecret)) + mac.Write(sorted) + expected := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expected), []byte(signature)) +} + +func sortedJSON(data map[string]interface{}) []byte { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + ordered := make([]byte, 0, 256) + ordered = append(ordered, '{') + for i, k := range keys { + if i > 0 { + ordered = append(ordered, ',') + } + key, _ := json.Marshal(k) + val, _ := json.Marshal(data[k]) + ordered = append(ordered, key...) + ordered = append(ordered, ':') + ordered = append(ordered, val...) + } + ordered = append(ordered, '}') + return ordered +} diff --git a/backend/gestion/workers/payment_checker.go b/backend/gestion/workers/payment_checker.go new file mode 100644 index 00000000..75c09765 --- /dev/null +++ b/backend/gestion/workers/payment_checker.go @@ -0,0 +1,61 @@ +package workers + +import ( + "gestion/db" + "gestion/services" + "log" + "time" +) + +func StartPaymentChecker(database *db.Database, np *services.NowPaymentsClient, interval time.Duration) { + go func() { + for { + time.Sleep(interval) + checkPendingCryptoPayments(database, np) + } + }() + log.Printf("[CRON] payment checker démarré (toutes les %s)", interval) +} + +func checkPendingCryptoPayments(database *db.Database, np *services.NowPaymentsClient) { + payments, err := database.GetPendingCryptoPayments() + if err != nil { + log.Printf("[CRON] erreur récupération paiements en attente: %v", err) + return + } + + for _, payment := range payments { + status, err := np.GetPaymentStatus(payment.NowPaymentID) + if err != nil { + log.Printf("[CRON] erreur vérification paiement %d: %v", payment.ID, err) + continue + } + + if status.PaymentStatus == payment.Status { + continue + } + + log.Printf("[CRON] paiement %d (cmd %d): %s → %s", payment.ID, payment.CommandID, payment.Status, status.PaymentStatus) + + payAmount, _ := status.PayAmount.Float64() + if err := database.UpdateCryptoPaymentStatus(payment.ID, status.PaymentStatus, payAmount); err != nil { + log.Printf("[CRON] erreur mise à jour paiement %d: %v", payment.ID, err) + continue + } + + switch status.PaymentStatus { + case "finished", "confirmed": + if err := database.ActivateCryptoCommand(payment.CommandID); err != nil { + log.Printf("[CRON] erreur activation commande %d: %v", payment.CommandID, err) + } else { + log.Printf("[CRON] commande %d activée après paiement crypto", payment.CommandID) + } + case "failed", "expired": + if err := database.CancelCryptoCommand(payment.CommandID); err != nil { + log.Printf("[CRON] erreur annulation commande %d: %v", payment.CommandID, err) + } else { + log.Printf("[CRON] commande %d annulée (paiement %s)", payment.CommandID, status.PaymentStatus) + } + } + } +} diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index e20085f8..1a48c128 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -755,6 +755,10 @@ export interface AppSettings { referral_enabled: boolean; delivery_schedule: DeliverySchedule; postal_zones: PostalZone[]; + crypto_payment_enabled: boolean; + nowpayments_api_key: string; + nowpayments_ipn_secret: string; + nowpayments_currencies: string[]; } export const getSettings = async (): Promise<{ diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index c4a006d9..34344d11 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -21,6 +21,19 @@ import { useAlert } from "../../hooks/useAlert"; const POOL_COLORS = ["#10b981", "#9333ea", "#f97316", "#3b82f6", "#ef4444"]; +const CRYPTO_CURRENCIES = [ + { id: "btc", label: "BTC", icon: "₿", color: "#f7931a" }, + { id: "eth", label: "ETH", icon: "Ξ", color: "#627eea" }, + { id: "ltc", label: "LTC", icon: "Ł", color: "#bebebe" }, + { id: "usdt", label: "USDT", icon: "₮", color: "#26a17b" }, + { id: "usdttrc20",label: "USDT TRC20",icon: "₮", color: "#c23631" }, + { id: "bnb", label: "BNB", icon: "B", color: "#f3ba2f" }, + { id: "sol", label: "SOL", icon: "◎", color: "#9945ff" }, + { id: "doge", label: "DOGE", icon: "Ð", color: "#c2a633" }, + { id: "trx", label: "TRX", icon: "T", color: "#c23631" }, + { id: "xmr", label: "XMR", icon: "ɱ", color: "#ff6600" }, +]; + const DAYS: { key: keyof DeliverySchedule; label: string }[] = [ { key: "monday", label: "Lundi" }, { key: "tuesday", label: "Mardi" }, @@ -641,7 +654,13 @@ export default function SettingsScreen() { referral_enabled: true, delivery_schedule: DEFAULT_DELIVERY_SCHEDULE, postal_zones: DEFAULT_POSTAL_ZONES, + crypto_payment_enabled: false, + nowpayments_api_key: "", + nowpayments_ipn_secret: "", + nowpayments_currencies: [], }); + const [showApiKey, setShowApiKey] = useState(false); + const [showIpnSecret, setShowIpnSecret] = useState(false); const [categories, setCategories] = useState([]); // Refs pour l'auto-sauvegarde au départ de la page @@ -858,6 +877,16 @@ export default function SettingsScreen() { textAlign: "center", }, thresholdSep: { fontSize: fontSize.md, color: colors.textMuted }, + input: { + backgroundColor: colors.bgPrimary, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.m, + paddingVertical: spacing.s, + color: colors.textPrimary, + fontSize: fontSize.sm, + }, saveButton: { backgroundColor: colors.accent, borderRadius: borderRadius.lg, @@ -1124,6 +1153,129 @@ export default function SettingsScreen() { s={s} /> + {/* Paiement crypto */} + + Paiement crypto + + {/* Toggle activation */} + + + Paiement crypto activé + + Les clients peuvent payer leurs commandes en cryptomonnaie via NowPayments. + + + + setSettings((prev) => ({ ...prev, crypto_payment_enabled: v })) + } + trackColor={{ false: colors.border, true: colors.accent }} + thumbColor="#fff" + /> + + + {/* Config NowPayments */} + + {/* Clé API */} + + Clé API NowPayments + + setSettings((p) => ({ ...p, nowpayments_api_key: v }))} + placeholder="Votre clé API NowPayments" + placeholderTextColor={colors.textSecondary} + secureTextEntry={!showApiKey} + autoCapitalize="none" + autoCorrect={false} + /> + setShowApiKey((x) => !x)} style={{ padding: spacing.xs }}> + + + + + + {/* Secret IPN */} + + Secret IPN + + setSettings((p) => ({ ...p, nowpayments_ipn_secret: v }))} + placeholder="Secret IPN pour la vérification des webhooks" + placeholderTextColor={colors.textSecondary} + secureTextEntry={!showIpnSecret} + autoCapitalize="none" + autoCorrect={false} + /> + setShowIpnSecret((x) => !x)} style={{ padding: spacing.xs }}> + + + + + URL IPN à configurer sur NowPayments :{"\n"} + + https://5.181.0.112.nip.io/api/v1/webhooks/nowpayments + + + + + {/* Cryptos acceptées */} + + Cryptomonnaies acceptées + + Sélectionnez les cryptos que vos clients peuvent utiliser pour payer. + + + {CRYPTO_CURRENCIES.map((coin) => { + const selected = (settings.nowpayments_currencies ?? []).includes(coin.id); + return ( + { + setSettings((prev) => { + const current = prev.nowpayments_currencies ?? []; + const updated = selected + ? current.filter((c) => c !== coin.id) + : [...current, coin.id]; + return { ...prev, nowpayments_currencies: updated }; + }); + }} + style={{ + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + paddingHorizontal: spacing.m, + paddingVertical: spacing.s, + borderRadius: borderRadius.full, + borderWidth: 1.5, + borderColor: selected ? coin.color : colors.border, + backgroundColor: selected ? coin.color + "22" : colors.bgSecondary, + }} + > + {coin.icon} + + {coin.label} + + + ); + })} + + {(settings.nowpayments_currencies ?? []).length === 0 && ( + + ⚠ Sélectionnez au moins une cryptomonnaie. + + )} + + + +