chore: add crypto payment

This commit is contained in:
2026-03-18 18:41:07 +01:00
parent a82097a228
commit adf68201fd
12 changed files with 788 additions and 2 deletions
+30
View File
@@ -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)
+122
View File
@@ -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()
}
+28 -1
View File
@@ -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), &currencies); 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)
+107
View File
@@ -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,
})
}
+75
View File
@@ -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)
}
+1 -1
View File
@@ -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"},
+18
View File
@@ -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"`
}
+8
View File
@@ -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)
// ============================================
+182
View File
@@ -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)
}
}
}
}