From c82b0071bb28e277f9e0fdc755e3308131507b8f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 17 Jun 2026 06:34:51 +0300 Subject: [PATCH] fix(security): wallet race conditions - FOR UPDATE + atomic claims on payments, webhooks, bonuses --- .../main/ride/driverWallet/add300ToDriver.php | 10 +++-- .../main/ride/ecash/driver/ecash_verify.php | 33 ++++++++-------- .../v2/main/ride/ecash/webhook_ecash.php | 38 ++++++++++++------- .../process_wait_compensation.php | 8 ++-- .../v2/main/ride/siroWallet/add.php | 21 ++++------ .../syriatel/passenger/confirm_payment.php | 12 +++--- .../v2/main/sms_webhook/request_payout.php | 21 +++++----- 7 files changed, 77 insertions(+), 66 deletions(-) diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/driverWallet/add300ToDriver.php b/walletintaleq.intaleq.xyz/v2/main/ride/driverWallet/add300ToDriver.php index 38cb1d3..3846f93 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/driverWallet/add300ToDriver.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/driverWallet/add300ToDriver.php @@ -13,13 +13,16 @@ $phone = filterRequest("phone"); // ------------------------------------------------------------- -// 1) CHECK IF DRIVER ALREADY RECEIVED THIS PAYMENT BEFORE +// 1) ATOMIC CHECK + INSERT TO PREVENT RACE CONDITION // ------------------------------------------------------------- +$con->beginTransaction(); + $check = $con->prepare(" SELECT id FROM driverWallet WHERE driverID = :driverID AND paymentMethod = :paymentMethod LIMIT 1 + FOR UPDATE "); $check->execute([ @@ -28,12 +31,11 @@ $check->execute([ ]); if ($check->rowCount() > 0) { - // Driver already received this "New Driver" payment + $con->rollBack(); printFailure("لقد تم منح هذا الدفع للسائق مسبقاً — لا يمكن تكراره."); exit; } - // ------------------------------------------------------------- // 2) INSERT INTO driverWallet // ------------------------------------------------------------- @@ -57,6 +59,8 @@ $stmt->execute(array( ':paymentMethod' => $paymentMethod )); +$con->commit(); + if ($stmt->rowCount() > 0) { printSuccess("Record saved successfully"); diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/ecash/driver/ecash_verify.php b/walletintaleq.intaleq.xyz/v2/main/ride/ecash/driver/ecash_verify.php index d8cf292..30acbd7 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/ecash/driver/ecash_verify.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/ecash/driver/ecash_verify.php @@ -86,17 +86,13 @@ if (!$payment) { exit; } -// 4. تمت عملية الدفع بنجاح، لنقم بإضافة الرصيد -// 4. Payment successful, proceed to add balance. +// 4. Atomic status claim + wallet update (prevents double-processing) +// 4. معالجة ذرية لمنح الرصيد — تمنع التكرار في حال التزامن try { $driverId = $payment['user_id']; - // eCash لا تحتاج للقسمة على 100 - // eCash amount does not need division by 100. $originalAmount = floatval($payment['amount']); $paymentMethod = $payment['payment_method'] ?? 'ecash'; - // حساب المكافأة - // Calculate the bonus. $bonusAmount = match ((int)$originalAmount) { 80 => 80.0, 200 => 215.0, @@ -105,8 +101,18 @@ try { default => $originalAmount, }; - // --- تنفيذ منطق تحديث المحافظ --- - // --- Execute wallet update logic --- + // بدء معاملة: تحديث الحالة claim + إضافة المحافظ + $con->beginTransaction(); + + // محاولة ذرية لـ claim المعاملة (فقط إذا كانت لا تزال status = 1) + $claimStmt = $con->prepare("UPDATE paymentsLogSyriaDriver SET status = 2 WHERE order_ref = :ref AND status = 1"); + $claimStmt->execute([':ref' => $orderRef]); + if ($claimStmt->rowCount() === 0) { + $con->rollBack(); + error_log("VERIFY_RACE: Concurrent claim for OrderRef " . $orderRef); + echo "

تمت معالجة هذا الطلب مسبقاً

الدفعة قيد المعالجة، يرجى التحقق من رصيدك في التطبيق.

"; + exit; + } $tokenDriver = generateToken($con, $driverId, $bonusAmount); if (!$tokenDriver) throw new Exception('Failed to generate token for driver wallet.'); @@ -117,8 +123,6 @@ try { $paymentID = generatePaymentID($con, $driverId, $bonusAmount, $paymentMethod); if (!$paymentID) throw new Exception('Failed to generate payment ID.'); - // إضافة الرصيد إلى driverWallet - // Add balance to driverWallet $insertDriver = $con->prepare("INSERT INTO driverWallet (driverID, paymentID, amount, paymentMethod) VALUES (:driverID, :paymentID, :amount, :paymentMethod)"); $insertDriver->execute([':driverID' => $driverId, ':paymentID' => $paymentID, ':amount' => $bonusAmount, ':paymentMethod' => $paymentMethod]); if ($insertDriver->rowCount() === 0) throw new Exception('Failed to insert into driverWallet.'); @@ -126,21 +130,18 @@ try { $markTokenDriver = $con->prepare("UPDATE payment_tokens SET isUsed = TRUE WHERE token = :token"); $markTokenDriver->execute([':token' => $tokenDriver]); - // إضافة الرصيد إلى siroWallet - // Add balance to siroWallet $insertSiro = $con->prepare("INSERT INTO siroWallet (driverId, passengerId, amount, paymentMethod, token, createdAt) VALUES (:driverId, :passengerId, :amount, :paymentMethod, :token, CURRENT_TIMESTAMP)"); $insertSiro->execute([':driverId' => $driverId, ':passengerId' => 'driver', ':amount' => $originalAmount, ':paymentMethod' => $paymentMethod, ':token' => $tokenSiro]); $markTokenSiro = $con->prepare("UPDATE payment_tokens SET isUsed = TRUE WHERE token = :token"); $markTokenSiro->execute([':token' => $tokenSiro]); - // 5. عرض صفحة النجاح النهائية - // 5. Display final success page. + $con->commit(); + echo "

تمت العملية بنجاح

تمت إضافة الرصيد إلى محفظتك. يمكنك الآن العودة إلى التطبيق.

"; } catch (Throwable $e) { - // في حال حدوث خطأ، يتم تسجيله وعرض رسالة للمستخدم - // In case of an error, log it and display a message to the user. + if ($con->inTransaction()) { $con->rollBack(); } error_log("VERIFY_ERROR: " . $e->getMessage() . " | OrderRef: " . $orderRef); echo "

حدث خطأ

لقد تم استلام دفعتك بنجاح، ولكن حدث خطأ أثناء تحديث رصيدك. يرجى التواصل مع الدعم الفني وتزويدهم بالرقم المرجعي: " . htmlspecialchars($orderRef) . "

"; } diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/ecash/webhook_ecash.php b/walletintaleq.intaleq.xyz/v2/main/ride/ecash/webhook_ecash.php index 276aef2..35e31fc 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/ecash/webhook_ecash.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/ecash/webhook_ecash.php @@ -54,47 +54,57 @@ if ($isSuccess !== true) { } logError("2", "Payment reported as SUCCESS by ecash."); -// 4. Find the original transaction in your database using the Order Reference +// 4. Find and process the transaction atomically try { - $stmt = $con->prepare("SELECT * FROM ecash_transactions WHERE order_ref = ? LIMIT 1"); + $con->beginTransaction(); + + $stmt = $con->prepare("SELECT * FROM ecash_transactions WHERE order_ref = ? LIMIT 1 FOR UPDATE"); $stmt->execute([$orderRef]); $transaction = $stmt->fetch(PDO::FETCH_ASSOC); if (!$transaction) { + $con->rollBack(); logError("3", "OrderRef not found in our database.", ["orderRef" => $orderRef]); - http_response_code(404); // Not Found + http_response_code(404); exit; } - // Security Check: Ensure this transaction hasn't already been processed if ($transaction['status'] !== 'pending') { + $con->rollBack(); logError("3.1", "Transaction already processed.", ["orderRef" => $orderRef, "status" => $transaction['status']]); - http_response_code(200); // Acknowledge receipt, but prevent double-spending + http_response_code(200); + exit; + } + + // Atomically mark as processing to prevent concurrent webhooks + $lockStmt = $con->prepare("UPDATE ecash_transactions SET status = 'processing' WHERE order_ref = ? AND status = 'pending'"); + $lockStmt->execute([$orderRef]); + if ($lockStmt->rowCount() === 0) { + $con->rollBack(); + logError("3.2", "Concurrent webhook detected, transaction already claimed.", ["orderRef" => $orderRef]); + http_response_code(200); exit; } $passengerId = $transaction['passenger_id']; - $paidAmount = $transaction['amount']; // Use the amount from your DB as the source of truth + $paidAmount = $transaction['amount']; logError("3", "Transaction found in DB.", ["passengerId" => $passengerId, "amount" => $paidAmount]); - // 5. --- Start Wallet Update Logic (from your paymet_verfy.php) --- - - // Calculate bonus $finalAmount = calculateBonus($paidAmount); logError("4", "Bonus calculated.", ["original" => $paidAmount, "final" => $finalAmount]); - // Add to Passenger Wallet $passengerToken = generatePaymentToken($passengerId, $finalAmount); if ($passengerToken) { addToPassengerWallet($passengerId, $finalAmount, $passengerToken); } - // Add to Siro Wallet - $paymentMethod = 'ecash'; // Or another identifier + $paymentMethod = 'ecash'; addToSiroWallet($passengerId, $paidAmount, $paymentMethod); - // 6. Mark the transaction as 'success' in your database to prevent reprocessing - updateTransactionStatus($orderRef, 'success', $transactionNo); + $stmtUpdate = $con->prepare("UPDATE ecash_transactions SET status = 'success', ecash_transaction_no = ?, updated_at = NOW() WHERE order_ref = ?"); + $stmtUpdate->execute([$transactionNo, $orderRef]); + + $con->commit(); logError("7", "Process completed successfully."); } catch (PDOException $e) { diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/passengerWallet/process_wait_compensation.php b/walletintaleq.intaleq.xyz/v2/main/ride/passengerWallet/process_wait_compensation.php index 039c2eb..1ff2583 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/passengerWallet/process_wait_compensation.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/passengerWallet/process_wait_compensation.php @@ -27,8 +27,8 @@ try { // الخطوة 1: التحقق من التوكنات (Security Check) // --------------------------------------------------------- - // أ) فحص توكن السائق - $stmtCheckD = $con->prepare("SELECT id FROM payment_tokens WHERE token = ? AND isUsed = FALSE"); + // أ) فحص توكن السائق (مع FOR UPDATE) + $stmtCheckD = $con->prepare("SELECT id FROM payment_tokens WHERE token = ? AND isUsed = FALSE FOR UPDATE"); $stmtCheckD->execute([$tokenDriver]); $tokenDriverData = $stmtCheckD->fetch(); @@ -36,8 +36,8 @@ try { throw new Exception("Invalid or used Driver Token"); } - // ب) فحص توكن الراكب - $stmtCheckP = $con->prepare("SELECT id FROM payment_tokens_passenger WHERE token = ? AND isUsed = FALSE"); + // ب) فحص توكن الراكب (مع FOR UPDATE) + $stmtCheckP = $con->prepare("SELECT id FROM payment_tokens_passenger WHERE token = ? AND isUsed = FALSE FOR UPDATE"); $stmtCheckP->execute([$tokenPassenger]); $tokenPassengerData = $stmtCheckP->fetch(); diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/siroWallet/add.php b/walletintaleq.intaleq.xyz/v2/main/ride/siroWallet/add.php index a610def..31cb4d7 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/siroWallet/add.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/siroWallet/add.php @@ -19,14 +19,12 @@ if (!$token || !$passenger_id || !$amount || !$payment_method) { } try { -// logDebug("Checking token validity: $token with DriverID: $driver_id"); - - // Choose correct table based on driverId $table = ($driver_id == 'passenger') ? "payment_tokens_passenger" : "payment_tokens"; -// logDebug("table is: " . $table); - // Check if token is valid and not used - $stmt = $con->prepare("SELECT * FROM $table WHERE token = :token AND isUsed = FALSE"); + $con->beginTransaction(); + + // Check if token is valid and not used (locked row) + $stmt = $con->prepare("SELECT * FROM $table WHERE token = :token AND isUsed = FALSE FOR UPDATE"); $stmt->execute(array(':token' => $token)); $tokenData = $stmt->fetch(); @@ -60,24 +58,21 @@ try { $stmt->bindParam(':token', $token, PDO::PARAM_STR); if ($stmt->execute()) { - // logDebug("Wallet data saved successfully."); - - // Mark token as used in the correct table $stmt = $con->prepare("UPDATE $table SET isUsed = TRUE WHERE id = :tokenID"); $stmt->execute(array(':tokenID' => $tokenData['id'])); - // logDebug("Token marked as used in $table."); + $con->commit(); printSuccess("Wallet data saved successfully"); } else { - // logDebug("Failed to save wallet data."); + $con->rollBack(); printFailure("Failed to save wallet data"); } } else { - // logDebug("Invalid or already used token: $token"); + $con->rollBack(); printFailure("Invalid or already used token"); } } catch (Exception $e) { - // logDebug("Exception: " . $e->getMessage()); + if ($con->inTransaction()) { $con->rollBack(); } error_log("[siroWallet/add] " . $e->getMessage()); printFailure("An error occurred"); } diff --git a/walletintaleq.intaleq.xyz/v2/main/ride/syriatel/passenger/confirm_payment.php b/walletintaleq.intaleq.xyz/v2/main/ride/syriatel/passenger/confirm_payment.php index d66b8a3..cc518bd 100755 --- a/walletintaleq.intaleq.xyz/v2/main/ride/syriatel/passenger/confirm_payment.php +++ b/walletintaleq.intaleq.xyz/v2/main/ride/syriatel/passenger/confirm_payment.php @@ -96,24 +96,26 @@ try { // ============================================================ global $con; - // 4) التحقق من السجل في قاعدة البيانات - $chk = $con->prepare("SELECT status, user_id, amount, payment_method FROM paymentsLogSyria WHERE order_ref = :ref LIMIT 1"); + // بدء المعاملة أولاً (قبل SELECT للحماية من السباق) + $con->beginTransaction(); + + // 4) التحقق من السجل في قاعدة البيانات مع FOR UPDATE + $chk = $con->prepare("SELECT status, user_id, amount, payment_method FROM paymentsLogSyria WHERE order_ref = :ref LIMIT 1 FOR UPDATE"); $chk->execute([':ref' => $transactionID]); $payment = $chk->fetch(PDO::FETCH_ASSOC); if (!$payment) { + $con->rollBack(); printFailure($lang === 'ar' ? "لم يتم العثور على السجل." : "Payment row not found"); exit; } if ((int)$payment['status'] === 1) { + $con->rollBack(); printSuccess(['message' => 'Already confirmed', 'data' => ['order_ref' => $transactionID]]); exit; } - // بدء المعاملة (Transaction) - $con->beginTransaction(); - try { // أ) تحديث حالة الدفع في السجل $stmtUpdate = $con->prepare("UPDATE `paymentsLogSyria` SET status = 1, updated_at = NOW() WHERE order_ref = :ref"); diff --git a/walletintaleq.intaleq.xyz/v2/main/sms_webhook/request_payout.php b/walletintaleq.intaleq.xyz/v2/main/sms_webhook/request_payout.php index 8bb77ff..48bd2c8 100755 --- a/walletintaleq.intaleq.xyz/v2/main/sms_webhook/request_payout.php +++ b/walletintaleq.intaleq.xyz/v2/main/sms_webhook/request_payout.php @@ -20,30 +20,27 @@ if (empty($driverId) || $amount <= 0) { } try { - // --- 2. التحقق من بيانات السائق ورصيده --- + // --- Atomic balance check + insert with transaction --- + $con->beginTransaction(); + $stmt_driver = $con->prepare(" - SELECT SUM(amount) AS balance + SELECT COALESCE(SUM(amount), 0) AS balance FROM driverWallet WHERE driverID = :id - LIMIT 1 + FOR UPDATE "); $stmt_driver->execute([':id' => $driverId]); $driver = $stmt_driver->fetch(PDO::FETCH_ASSOC); - if (!$driver) { - printFailure("Driver not found."); - exit; - } - - $payout_fee = 3500.00; // عمولة السحب - $total_deduction = $amount + $payout_fee; // المبلغ المطلوب مع العمولة + $payout_fee = 3500.00; + $total_deduction = $amount + $payout_fee; if ($driver['balance'] < $total_deduction) { + $con->rollBack(); printFailure("Insufficient balance. Required: $total_deduction"); exit; } - // --- 3. إنشاء طلب السحب في قاعدة البيانات --- $sql = " INSERT INTO payout_requests (driver_id, driver_phone, amount, wallet_type) VALUES (:did, :phone, :amount, :wallet) @@ -56,6 +53,8 @@ try { ':wallet'=> $wallet_type ]); + $con->commit(); + if ($stmt->rowCount() > 0) { // --- 4. إرسال إشعار لخدمة العملاء --- $customerServicePhone = getenv('CUSTOMER_SERVICE_PHONE');