From a00dd475f333be7ffa51078d739aa28b82e7d232 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Wed, 18 Mar 2026 19:20:37 +0100 Subject: [PATCH] chore: add crypto payment --- backend/gestion/handlers/crypto_payment.go | 25 + backend/gestion/handlers/settings.go | 20 +- backend/gestion/main.go | 20 + backend/gestion/workers/payment_checker.go | 17 + mobile/src/api/api.ts | 36 +- mobile/src/api/api_types.ts | 8 + mobile/src/screens/client/CheckoutScreen.tsx | 597 +++++++++++------- .../src/screens/client/OrderHistoryScreen.tsx | 2 +- 8 files changed, 471 insertions(+), 254 deletions(-) diff --git a/backend/gestion/handlers/crypto_payment.go b/backend/gestion/handlers/crypto_payment.go index b22eb3a9..eeb5e96b 100644 --- a/backend/gestion/handlers/crypto_payment.go +++ b/backend/gestion/handlers/crypto_payment.go @@ -76,6 +76,7 @@ func IPNWebhook(c *gin.Context) { // GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status // Retourne le statut du paiement crypto d'une commande (polling côté client) +// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente func GetCommandPaymentStatus(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -95,6 +96,30 @@ func GetCommandPaymentStatus(c *gin.Context) { return } + // Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence) + switch payment.Status { + case "waiting", "confirming", "confirmed", "sending": + np, npOk := c.Get("nowpayments") + if npOk && np != nil { + npClient := np.(*services.NowPaymentsClient) + npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID) + if err == nil && npStatus.PaymentStatus != payment.Status { + payAmount, _ := npStatus.PayAmount.Float64() + if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil { + payment.Status = npStatus.PaymentStatus + payment.PayAmount = payAmount + log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus) + } + switch npStatus.PaymentStatus { + case "finished", "confirmed": + _ = database.ActivateCryptoCommand(commandID) + case "failed", "expired": + _ = database.CancelCryptoCommand(commandID) + } + } + } + } + c.JSON(http.StatusOK, gin.H{ "command_id": payment.CommandID, "payment_status": payment.Status, diff --git a/backend/gestion/handlers/settings.go b/backend/gestion/handlers/settings.go index 9c171975..3a9ee9e8 100644 --- a/backend/gestion/handlers/settings.go +++ b/backend/gestion/handlers/settings.go @@ -27,15 +27,17 @@ func GetPublicSettings(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "success": true, - "penalties_enabled": settings.PenaltiesEnabled, - "show_amende_score": settings.ShowAmendeScore, - "points_enabled": settings.PointsEnabled, - "points_separated": len(settings.PointsPools) > 1, - "pool_names": poolNames, - "pool_keys": poolKeys, - "referral_enabled": settings.ReferralEnabled, - "delivery_schedule": settings.DeliverySchedule, + "success": true, + "penalties_enabled": settings.PenaltiesEnabled, + "show_amende_score": settings.ShowAmendeScore, + "points_enabled": settings.PointsEnabled, + "points_separated": len(settings.PointsPools) > 1, + "pool_names": poolNames, + "pool_keys": poolKeys, + "referral_enabled": settings.ReferralEnabled, + "delivery_schedule": settings.DeliverySchedule, + "crypto_payment_enabled": settings.CryptoPaymentEnabled, + "nowpayments_currencies": settings.NowPaymentsCurrencies, }) } diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 0c8c9591..57b2ff40 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -87,6 +87,16 @@ func main() { // Démarrage des workers Redis en arrière-plan go workers.StartRedisWorkers(database) log.Println("✅ Workers Redis démarrés") + + // Worker de vérification des paiements crypto (recharge les clés dynamiquement) + workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient { + s, err := database.GetSettings() + if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" { + return nil + } + return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret) + }, 2*time.Minute) + log.Println("✅ Worker paiements crypto démarré (2 min)") gin.SetMode(gin.ReleaseMode) r := gin.Default() @@ -118,6 +128,16 @@ func main() { c.Next() }) + // Middleware NowPayments : injecte le client si crypto activé dans les settings + r.Use(func(c *gin.Context) { + settings, err := database.GetSettings() + if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" { + np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret) + c.Set("nowpayments", np) + } + c.Next() + }) + // Fichiers statiques r.Static("/uploads", "./uploads") diff --git a/backend/gestion/workers/payment_checker.go b/backend/gestion/workers/payment_checker.go index 75c09765..f9504bc3 100644 --- a/backend/gestion/workers/payment_checker.go +++ b/backend/gestion/workers/payment_checker.go @@ -17,6 +17,23 @@ func StartPaymentChecker(database *db.Database, np *services.NowPaymentsClient, log.Printf("[CRON] payment checker démarré (toutes les %s)", interval) } +// StartDynamicPaymentChecker démarre un checker qui recharge le client NowPayments à chaque tick +// (permet de prendre en compte les changements de clé API sans redémarrer) +func StartDynamicPaymentChecker(database *db.Database, clientFn func() *services.NowPaymentsClient, interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + np := clientFn() + if np == nil { + continue + } + checkPendingCryptoPayments(database, np) + } + }() + log.Printf("[CRON] payment checker dynamique démarré (toutes les %s)", interval) +} + func checkPendingCryptoPayments(database *db.Database, np *services.NowPaymentsClient) { payments, err := database.GetPendingCryptoPayments() if err != nil { diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index e19c856d..e2e962df 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -292,6 +292,8 @@ export const checkoutCart = async ( prenom?: string, telephone?: string, use_referral_balance?: boolean, + payment_method?: string, + pay_currency?: string, ): Promise => { const jwtUsername = await getJwtUsername(); if (!jwtUsername) return { success: false, message: "Session invalide" }; @@ -309,6 +311,8 @@ export const checkoutCart = async ( if (nom) payload.nom = nom; if (prenom) payload.prenom = prenom; if (telephone) payload.telephone = telephone; + if (payment_method) payload.payment_method = payment_method; + if (pay_currency) payload.pay_currency = pay_currency; const { data } = await apiClient.post(`${V1}/checkout`, payload); return { success: true, @@ -320,6 +324,13 @@ export const checkoutCart = async ( queue_info: data.queue_info, referral_used: data.referral_used, referral_balance: data.referral_balance, + payment_method: data.payment_method, + payment_status: data.payment_status, + pay_address: data.pay_address, + pay_amount: data.pay_amount, + pay_currency: data.pay_currency, + price_amount: data.price_amount, + price_currency: data.price_currency, }; } catch (error: any) { const errMsg: string = error.response?.data?.error || "Erreur serveur"; @@ -756,10 +767,12 @@ export interface PublicSettings { points_separated: boolean; referral_enabled: boolean; pool_names: string[]; + crypto_payment_enabled: boolean; + nowpayments_currencies: string[]; } export const getPublicSettings = async (): Promise => { - const defaults: PublicSettings = { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true, pool_names: ['Pool 1', 'Pool 2'] }; + const defaults: PublicSettings = { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true, pool_names: ['Pool 1', 'Pool 2'], crypto_payment_enabled: false, nowpayments_currencies: [] }; try { const { data } = await apiClient.get(`${V1}/app-settings`); return { @@ -771,12 +784,33 @@ export const getPublicSettings = async (): Promise => { pool_names: Array.isArray(data.pool_names) && data.pool_names.length > 0 ? data.pool_names : defaults.pool_names, + crypto_payment_enabled: data.crypto_payment_enabled ?? false, + nowpayments_currencies: Array.isArray(data.nowpayments_currencies) ? data.nowpayments_currencies : [], }; } catch { return defaults; } }; +export interface CryptoPaymentStatus { + command_id: number; + payment_status: string; + pay_address: string; + pay_amount: number; + pay_currency: string; + price_amount: number; + price_currency: string; +} + +export const getCryptoPaymentStatus = async (commandId: number): Promise => { + try { + const { data } = await apiClient.get(`${V1}/commands/${commandId}/payment-status`); + return data; + } catch { + return null; + } +}; + export const registerPushToken = async (token: string): Promise => { try { await apiClient.post(`${V1}/push-token`, { token }); diff --git a/mobile/src/api/api_types.ts b/mobile/src/api/api_types.ts index 984ef0e5..fab2a353 100644 --- a/mobile/src/api/api_types.ts +++ b/mobile/src/api/api_types.ts @@ -764,6 +764,14 @@ export interface CheckoutCartResponse { }; referral_used?: number; referral_balance?: number; + // Crypto payment fields + payment_method?: string; + payment_status?: string; + pay_address?: string; + pay_amount?: number; + pay_currency?: string; + price_amount?: number; + price_currency?: string; } export interface ConfirmReceptionResponse { diff --git a/mobile/src/screens/client/CheckoutScreen.tsx b/mobile/src/screens/client/CheckoutScreen.tsx index 88964e96..9f5d6060 100644 --- a/mobile/src/screens/client/CheckoutScreen.tsx +++ b/mobile/src/screens/client/CheckoutScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { View, Text, @@ -8,13 +8,21 @@ import { Platform, Switch, TouchableOpacity, + Clipboard, + Alert, } from "react-native"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { Ionicons } from "@expo/vector-icons"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useCart } from "../../context/CartContext"; -import { checkoutCart, getReferralBalance } from "../../api/api"; +import { + checkoutCart, + getReferralBalance, + getPublicSettings, + getCryptoPaymentStatus, +} from "../../api/api"; +import type { CryptoPaymentStatus } from "../../api/api"; import type { ClientStackParamList } from "../../navigation/types"; import TextInput from "../../components/ui/TextInput"; import Button from "../../components/ui/Button"; @@ -24,6 +32,8 @@ import { spacing, borderRadius, fontSize, fontWeight } from "../../theme"; type Nav = NativeStackNavigationProp; +const CRYPTO_COLOR = "#f7931a"; + export default function CheckoutScreen() { const navigation = useNavigation