fix(security): wallet race conditions - FOR UPDATE + atomic claims on payments, webhooks, bonuses

This commit is contained in:
Hamza-Ayed
2026-06-17 06:34:51 +03:00
parent 0ceb67ee56
commit c82b0071bb
7 changed files with 77 additions and 66 deletions

View File

@@ -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 "<h1>تمت معالجة هذا الطلب مسبقاً</h1><p>الدفعة قيد المعالجة، يرجى التحقق من رصيدك في التطبيق.</p>";
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 "<h1>تمت العملية بنجاح</h1><p>تمت إضافة الرصيد إلى محفظتك. يمكنك الآن العودة إلى التطبيق.</p>";
} 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 "<h1>حدث خطأ</h1><p>لقد تم استلام دفعتك بنجاح، ولكن حدث خطأ أثناء تحديث رصيدك. يرجى التواصل مع الدعم الفني وتزويدهم بالرقم المرجعي: " . htmlspecialchars($orderRef) . "</p>";
}

View File

@@ -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) {