chore: add crypto payment
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user