getMessage()); } require_once __DIR__ . '/streak_helper.php'; // ‏نفس أسعار الكيلومتر والدقيقة التي تُحسب بها الرحلة في finish_ride_updates.php require_once __DIR__ . '/../pricing/pricing_helper.php'; $rideId = filterRequest("ride_id"); $driverId = filterRequest("driver_id"); $reason = filterRequest("reason"); $passengerToken = filterRequest("passenger_token"); // ‏هذا المسار ليس تغريماً للسائق — هو تعويض له عن عدم حضور الراكب: // ‏السائق وصل نقطة الالتقاط، انتظر المدة المقررة، ولم يحضر الراكب. فيُعوَّض // ‏عن المسافة التي قطعها نحوه + دقائق الانتظار، ويُخصم المبلغ من الراكب. // ‏عقوبة السائق كثير الإلغاء شيء آخر تماماً: إيقاف مؤقت، لا مال (القسم ٢). // // ‏المبلغ لا يُقرأ من الطلب إطلاقاً. كان سابقاً filterRequest("penalty_fee") // ‏فيُخصم من محفظة الراكب بلا سقف ولا تحقق — أي بيد العميل. if (filterRequest("penalty_fee") !== null) { error_log("[cancel_ride_by_driver] SECURITY: تجاهل penalty_fee من العميل" . " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم"); } // ‏سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً // ‏من تحويل التعويض إلى خصم كارثي. const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0; // ‏حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا // ‏يتحول انتظاره إلى فاتورة على الراكب. const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20; // تثبيت الحالة $statusText = "CancelFromDriverAfterApply"; if (!$rideId || !$driverId) { jsonError("Missing parameters"); exit; } try { $con->beginTransaction(); // --------------------------------------------------------- // 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق // ‏بدون هذا الفحص كان أي driver_id يقدر يلغي أي رحلة ويغرّم راكبها. // --------------------------------------------------------- // ‏SELECT * عمداً لا قائمة أعمدة: أعمدة التعويض (driverArrivedAt / // ‏pickupApproachKm) قد لا تكون مُرحَّلة بعد، واسم عمود نوع السيارة // ‏نفسه متضارب بين الملفات (carType في الschema، car_type في // ‏finish_ride_updates.php). النجمة تجعل الاستعلام يعمل في كل الحالات // ‏وتترك الغياب يُعالَج كقيمة مفقودة بدل أن يُسقط الإلغاء كله. $stmtOwn = $con->prepare("SELECT * FROM ride WHERE id = ? FOR UPDATE"); $stmtOwn->execute([$rideId]); $rideRow = $stmtOwn->fetch(PDO::FETCH_ASSOC); $assignedDriver = is_array($rideRow) ? $rideRow['driver_id'] : false; // ‏نوع السيارة تحت أي من الاسمين $rideCarType = (string) ($rideRow['carType'] ?? $rideRow['car_type'] ?? 'Speed'); // ‏مدخلا التعويض عاشا في Redis طوال الرحلة ولم يلمسا MySQL. نقرأهما // ‏هنا قراءةً واحدة يشترك فيها الحساب (القسم ٤٫١) والتوثيق (القسم ٣). $approachKm = 0.0; $arrivedTs = 0; try { if (isset($redisLocation) && $redisLocation) { $state = $redisLocation->hMGet("ride:$rideId:state", ['pickup_approach_km', 'driver_arrived_at']) ?: []; $approachKm = (float) ($state['pickup_approach_km'] ?? 0); $arrivedTs = (int) ($state['driver_arrived_at'] ?? 0); } } catch (Throwable $eState) { error_log("[cancel_ride_by_driver] تعذّرت قراءة حالة الرحلة من Redis: " . $eState->getMessage()); } if ($assignedDriver === false) { $con->rollBack(); jsonError("Ride not found"); exit; } if ((string) $assignedDriver !== (string) $driverId) { $con->rollBack(); error_log("[cancel_ride_by_driver] SECURITY: رفض إلغاء رحلة غير مُسندة" . " (ride=$rideId caller=$driverId assigned=$assignedDriver)"); jsonError("Ride is not assigned to this driver"); exit; } // --------------------------------------------------------- // 1. معالجة driver_orders (Insert or Update) // --------------------------------------------------------- $checkStmt = $con->prepare("SELECT order_id FROM driver_orders WHERE order_id = ? AND driver_id = ?"); $checkStmt->execute([$rideId, $driverId]); if ($checkStmt->rowCount() > 0) { // موجود: تحديث $stmtLog = $con->prepare("UPDATE driver_orders SET status = ?, notes = ?, created_at = NOW() WHERE order_id = ? AND driver_id = ?"); $stmtLog->execute([$statusText, $reason, $rideId, $driverId]); } else { // غير موجود: إدخال $stmtLog = $con->prepare("INSERT INTO driver_orders (driver_id, order_id, status, created_at, notes) VALUES (?, ?, ?, NOW(), ?)"); $stmtLog->execute([$driverId, $rideId, $statusText, $reason]); } // --------------------------------------------------------- // 2. منطق الحظر (Business Logic) // --------------------------------------------------------- $stmtCount = $con->prepare(" SELECT COUNT(*) FROM driver_orders WHERE driver_id = ? AND status = ? AND created_at >= NOW() - INTERVAL 1 DAY "); $stmtCount->execute([$driverId, $statusText]); $cancelCount = $stmtCount->fetchColumn(); $isBlocked = false; $blockUntil = ""; if ($cancelCount >= 3) { $isBlocked = true; $blockUntil = date('Y-m-d H:i:s', strtotime('+4 hours')); // ‏تثبيت الإيقاف على الخادم. كان يُكتب في تخزين تطبيق السائق وحده // ‏(statusDriverLocation='blocked')، فيُفكّ بمسح البيانات أو إعادة // ‏التثبيت. الآن acceptRide.php يرفض القبول ما دام المفتاح حياً. // // ‏Redis هو مسار الفحص (TTL ينهي الإيقاف تلقائياً بلا كرون تنظيف)، // ‏وMySQL سجل دائم للمراجعة الإدارية لا يُقرأ في المسار الحار. // ‏هذه الكتابة تحدث في الإلغاء الثالث فقط — لا على كل رحلة. try { if (isset($redisLocation) && $redisLocation) { $blockKey = "driver:$driverId:blocked"; $remaining = $redisLocation->ttl($blockKey); // ‏لا نُقصّر إيقافاً سارياً أطول بإلغاء جديد. if ($remaining < 4 * 3600) { $redisLocation->setex($blockKey, 4 * 3600, $blockUntil); } } } catch (Throwable $eRedisBlock) { error_log("[cancel_ride_by_driver] تعذّر تثبيت الإيقاف في Redis: " . $eRedisBlock->getMessage()); } try { $con->prepare("UPDATE driver SET blockedUntil = GREATEST(COALESCE(blockedUntil, ?), ?) WHERE id = ?") ->execute([$blockUntil, $blockUntil, $driverId]); } catch (PDOException $eBlock) { error_log("[cancel_ride_by_driver] تعذّر تسجيل الإيقاف في MySQL (شغّل" . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage()); } } // --------------------------------------------------------- // 3. تحديث حالة الرحلة في جدول ride // --------------------------------------------------------- // ‏التوثيق النهائي: بيانات التعويض التي عاشت في Redis طوال الرحلة // ‏تُثبَّت هنا داخل نفس جملة تحديث الحالة — لا كتابة إضافية. // ‏تُقرأ من Redis قبل هذه النقطة (القسم ٤٫١ يقرأها للحساب)، لذا نقرأها // ‏مرة واحدة هنا ونمرّرها للطرفين. $stampArrivedAt = $arrivedTs > 0 ? date('Y-m-d H:i:s', $arrivedTs) : null; $sqlRide = "UPDATE ride SET status = ?, driver_id = 0, pickupApproachKm = ?, driverArrivedAt = ? WHERE id = ?"; $sqlRideLegacy = "UPDATE ride SET status = ?, driver_id = 0 WHERE id = ?"; $rideParams = [$statusText, $approachKm, $stampArrivedAt, $rideId]; // Local DB try { $con->prepare($sqlRide)->execute($rideParams); } catch (PDOException $eCols) { // ‏أعمدة التعويض غير مُرحَّلة بعد — الإلغاء نفسه لا يتوقف. error_log("[cancel_ride_by_driver] أعمدة التعويض غير موجودة (شغّل" . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eCols->getMessage()); $con->prepare($sqlRideLegacy)->execute([$statusText, $rideId]); } // Remote DB (إن وجد) if (isset($con_ride)) { try { $con_ride->prepare($sqlRide)->execute($rideParams); } catch (PDOException $eCols) { $con_ride->prepare($sqlRideLegacy)->execute([$statusText, $rideId]); } } // 🆕 تصفير التتابع لأن السائق ألغى الرحلة handleDriverStreak($con, $driverId, 'reset'); // --------------------------------------------------------- // 4. إشعار الراكب // --------------------------------------------------------- // أ) Socket (يحتاج Passenger ID) $stmtPas = $con->prepare("SELECT passenger_id FROM ride WHERE id = ?"); $stmtPas->execute([$rideId]); $passenger_id = $stmtPas->fetchColumn(); if ($passenger_id) { $socketPayload = [ 'ride_id' => $rideId, 'status' => 'cancelled_by_driver', 'msg' => 'تم إلغاء الرحلة من قبل السائق' ]; if (function_exists('notifyPassengerOnRideServer')) { notifyPassengerOnRideServer($passenger_id, $socketPayload); } // ───────────────────────────────────────────────────────── // 4.1. تعويض السائق عن عدم حضور الراكب // ‏= مسافة الاقتراب × سعر الكيلومتر لنوع السيارة // + دقائق الانتظار × سعر الدقيقة حسب وقت اليوم // ‏كل المدخلات مسجَّلة على الخادم؛ لا شيء منها من العميل. // ───────────────────────────────────────────────────────── $stmtKazan = $con->prepare("SELECT * FROM kazan LIMIT 1"); $stmtKazan->execute(); $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; $country = $kazan['country'] ?? 'Jordan'; $compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0); $minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5); // ‏$approachKm و$arrivedTs قُرئا من Redis قراءةً واحدة في القسم صفر. $compensation = 0.0; $waitedMinutes = 0; if ($compensationCap <= 0) { // ‏التعويض معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة. error_log("[cancel_ride_by_driver] التعويض معطّل (cancelCompensationCap=0)" . " ride=$rideId"); } elseif ($arrivedTs <= 0) { // ‏لا ختم وصول ⇒ السائق ألغى قبل أن يصل، أو تعذّر إثبات وصوله. error_log("[cancel_ride_by_driver] إلغاء بلا ختم وصول — بلا تعويض" . " ride=$rideId driver=$driverId"); } else { $waitedMinutes = (int) floor((time() - $arrivedTs) / 60); if ($waitedMinutes < $minWaitMinutes) { error_log("[cancel_ride_by_driver] انتظار $waitedMinutes د < الحد" . " $minWaitMinutes د — بلا تعويض ride=$rideId"); } else { $billableMinutes = min($waitedMinutes, CANCEL_MAX_BILLABLE_WAIT_MINUTES); $perKm = getPerKmRate($rideCarType, $kazan); $perMin = getPerMinRate($kazan); $compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin); $compensation = round(min($compensation, $compensationCap, CANCEL_COMPENSATION_ABSOLUTE_CAP), 2); error_log("[cancel_ride_by_driver] تعويض ride=$rideId:" . " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin" . " = $compensation (سقف $compensationCap)"); } } if ($compensation > 0) { // ‏يُخصم من الراكب كدين سالب، ويُضاف للسائق كمكافأة — الطرفان معاً. $negativeDebt = -$compensation; $walletServer = "https://walletintaleq.intaleq.xyz"; if (strtolower($country) == 'jordan') { $walletServer = getenv('WALLET_SERVER_JORDAN') ?: "https://walletintaleq.intaleq.xyz"; } elseif (strtolower($country) == 'egypt') { $walletServer = getenv('WALLET_SERVER_EGYPT') ?: "https://walletintaleq.intaleq.xyz"; } else { $walletServer = getenv('WALLET_SERVER_SYRIA') ?: "https://walletintaleq.intaleq.xyz"; } // S2S call to add debt to passenger wallet on the payment server $walletUrl = "$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php"; $ch = curl_init($walletUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ "passengerID" => $passenger_id, "amount" => $negativeDebt ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Content-Type: application/x-www-form-urlencoded', 'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY') ] ]); $s2sRes = curl_exec($ch); $s2sCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($s2sCode !== 200) { error_log("Failed to add passenger debt via S2S: Code $s2sCode, Res: $s2sRes"); } // ───────────────────────────────────────────────────── // ‏إيصال التعويض لمحفظة السائق — النصف المفقود سابقاً. // ‏كان المبلغ يُخصم من الراكب ولا يصل السائق إطلاقاً. // ‏paymentID مشتق من الرحلة فيمنع ازدواج الصرف لو أُعيد الطلب. // ───────────────────────────────────────────────────── $rewardUrl = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php"; $chReward = curl_init($rewardUrl); curl_setopt_array($chReward, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query([ "driverID" => $driverId, "paymentID" => "no_show_$rideId", "amount" => $compensation, "paymentMethod" => "passenger_no_show_compensation", ]), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Content-Type: application/x-www-form-urlencoded', 'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY') ] ]); $rewardRes = curl_exec($chReward); $rewardCode = curl_getinfo($chReward, CURLINFO_HTTP_CODE); curl_close($chReward); if ($rewardCode !== 200) { // ‏الراكب خُصم والسائق لم يُرصد — فرق يجب أن يظهر في التسوية. error_log("[cancel_ride_by_driver] MONEY: خُصم $compensation من" . " الراكب $passenger_id ولم يصل السائق $driverId" . " (ride=$rideId code=$rewardCode res=$rewardRes)"); } // تخزين الدين في الـ Redis لمدة 6 شهور (15552000 ثانية) try { $redisInstance = null; if (isset($redis) && $redis !== null) { $redisInstance = $redis; } else if (extension_loaded('redis')) { $localRedis = new Redis(); $redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1'; $redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379); $redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH'); if ($localRedis->connect($redisHost, $redisPort, 1.5)) { if ($redisPass) $localRedis->auth($redisPass); $localRedis->setOption(Redis::OPT_PREFIX, 'siro:'); $redisInstance = $localRedis; } } if ($redisInstance !== null) { $redisKey = "passenger_debt_" . $passenger_id; // إضافة الدين الجديد إلى الدين السابق إن وجد $currentDebt = (float) $redisInstance->get($redisKey); $newDebt = $currentDebt + $negativeDebt; $redisInstance->setex($redisKey, 15552000, $newDebt); } } catch (Exception $e) { error_log("Redis Error in cancel_ride_by_driver: " . $e->getMessage()); } } } // ب) FCM (Internal) if (empty($passengerToken) && $passenger_id) { $stmtToken = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? ORDER BY id DESC LIMIT 1"); $stmtToken->execute([$passenger_id]); $rawToken = $stmtToken->fetchColumn(); if ($rawToken) { $passengerToken = $rawToken; if (!empty($encryptionHelper)) { try { $decrypted = $encryptionHelper->decryptData($rawToken); if ($decrypted !== false && !empty($decrypted)) { $passengerToken = trim($decrypted); } } catch (Exception $e) { // Fallback } } } } if (!empty($passengerToken)) { $fcmData = [ 'category' => 'Cancel Trip from driver', 'ride_id' => (string)$rideId ]; // 🔥 استخدام الدالة الجديدة sendFCM_Internal( $passengerToken, // الهدف "تم إلغاء الرحلة ❌", // العنوان "عذراً، قام السائق بإلغاء الرحلة.", // النص $fcmData, // البيانات "Cancel Trip from driver", // التصنيف (تأكد أنه يطابق ما في تطبيق الراكب) false // ليس Topic ); } $con->commit(); // 🆕 Cache ride state in Redis — use $statusText (the exact value just // written to MySQL above), not a hardcoded label, so a cache hit can // never disagree with the row it was seeded from. sendToLocationServer('update_ride_state', [ 'ride_id' => $rideId, 'status' => $statusText, 'driver_id' => $driverId, 'passenger_id' => $passenger_id ?? '', ]); // 5. الرد للفلاتر echo json_encode([ "status" => "success", "cancel_count" => $cancelCount, "is_blocked" => $isBlocked, "block_until" => $blockUntil, // ‏ما استحقه السائق فعلاً عن هذه الرحلة — يعرضه التطبيق كتعويض، // ‏لا كغرامة. صفر يعني أنه ألغى قبل الوصول أو قبل مدة الانتظار. "compensation" => $compensation ?? 0, "waited_minutes" => $waitedMinutes ?? 0, ]); } catch (PDOException $e) { if ($con->inTransaction()) $con->rollBack(); error_log("[cancel_ride_by_driver] " . $e->getMessage()); jsonError("DB Error"); } ?>