diff --git a/backend/migrations/2026_08_07_passenger_cancel_policy.sql b/backend/migrations/2026_08_07_passenger_cancel_policy.sql new file mode 100644 index 00000000..d55507a0 --- /dev/null +++ b/backend/migrations/2026_08_07_passenger_cancel_policy.sql @@ -0,0 +1,41 @@ +-- 2026_08_07_passenger_cancel_policy.sql +-- +-- ‏رسوم إلغاء الراكب بعد قبول السائق. +-- +-- ‏متماثلة مع تعويض عدم الحضور (ترحيل 2026_08_07_driver_cancel_compensation): +-- ‏نفس المبدأ — من تسبب بضياع وقت الآخر يعوّضه، والمال يذهب **للسائق** لا +-- ‏للشركة. الفرق أن الزمن المحتسب هنا يبدأ من لحظة القبول لا من لحظة الوصول، +-- ‏لأن السائق يكون في طريقه لا واقفاً ينتظر. +-- +-- ‏ثلاثة إعفاءات مبنية عمداً، لأن رسماً بلا إعفاء يعاقب الراكب على أخطاء +-- ‏ليست خطأه ويطرده من التطبيق: +-- ١. لا سائق قُبِل بعد → مجاني دائماً +-- ٢. خلال أول دقائق من القبول → مجاني (passengerCancelFreeMinutes) +-- ٣. السائق تأخّر ولم يصل → مجاني (driverLateExemptMinutes) +-- +-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها — kazan يُقرأ عبر $con. + +ALTER TABLE `kazan` + ADD COLUMN `passengerCancelCap` VARCHAR(10) + CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci + NOT NULL DEFAULT '0' + COMMENT 'سقف رسم إلغاء الراكب بعملة الدولة. 0 = الرسم معطّل بالكامل' + AFTER `cancelMinWaitMinutes`, + ADD COLUMN `passengerCancelFreeMinutes` TINYINT UNSIGNED + NOT NULL DEFAULT 2 + COMMENT 'دقائق الإلغاء المجاني بعد قبول السائق' + AFTER `passengerCancelCap`, + ADD COLUMN `driverLateExemptMinutes` TINYINT UNSIGNED + NOT NULL DEFAULT 10 + COMMENT 'إن مرّت هذه الدقائق على القبول ولم يصل السائق، الإلغاء مجاني'; + +-- ‏عدّاد عدم الحضور للراكب — سجل دائم للمراجعة الإدارية. +-- ‏(الفحص السريع يتم من Redis، وهذا العمود للتقارير وقرارات الحظر اليدوية.) +ALTER TABLE `passengers` + ADD COLUMN `noShowCount` SMALLINT UNSIGNED NOT NULL DEFAULT 0 + COMMENT 'مرات إلغاء الراكب بعد قبول السائق ودفعه رسماً'; + +-- ‏للتراجع: +-- ALTER TABLE `kazan` DROP COLUMN `passengerCancelCap`, +-- DROP COLUMN `passengerCancelFreeMinutes`, DROP COLUMN `driverLateExemptMinutes`; +-- ALTER TABLE `passengers` DROP COLUMN `noShowCount`; diff --git a/backend/ride/rides/cancel_fee_helper.php b/backend/ride/rides/cancel_fee_helper.php new file mode 100644 index 00000000..0e080718 --- /dev/null +++ b/backend/ride/rides/cancel_fee_helper.php @@ -0,0 +1,181 @@ +prepare("SELECT * FROM kazan LIMIT 1"); + $stmtKazan->execute(); + $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; + + $cap = (float) ($kazan['passengerCancelCap'] ?? 0); + if ($cap <= 0) { + // ‏الرسم معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة. + $exemptReason = 'policy_disabled'; + return 0.0; + } + + $freeMinutes = (int) ($kazan['passengerCancelFreeMinutes'] ?? 2); + $lateExemptMinutes = (int) ($kazan['driverLateExemptMinutes'] ?? 10); + + // ── الزمن منذ القبول ──────────────────────────────────── + // ‏acceptRide.php يكتب rideTimeStart = NOW() لحظة فوز السائق بالقفل. + $acceptedAt = !empty($ride['rideTimeStart']) ? strtotime($ride['rideTimeStart']) : 0; + if ($acceptedAt <= 0) { + $exemptReason = 'accept_time_unknown'; + return 0.0; + } + $minutesSinceAccept = (int) floor((time() - $acceptedAt) / 60); + + // ── إعفاء ٢: نافذة الإلغاء المجاني ────────────────────── + // ‏الراكب يغيّر رأيه أو يخطئ الوجهة — دقيقتان لتصحيح ذلك بلا عقاب. + if ($minutesSinceAccept < $freeMinutes) { + $exemptReason = 'within_free_window'; + return 0.0; + } + + // ── حالة الوصول من Redis ──────────────────────────────── + $approachKm = 0.0; + $arrivedTs = 0; + if ($redisLocation) { + try { + $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 $e) { + error_log("[cancel_fee] تعذّرت قراءة حالة الرحلة: " . $e->getMessage()); + } + } + + // ── إعفاء ٣: السائق تأخّر ولم يصل ─────────────────────── + // ‏أهم الإعفاءات الثلاثة: بدونه نعاقب الراكب على بطء السائق، وهذا + // ‏يطرد الركاب أسرع مما يوفّره الرسم. + if ($arrivedTs <= 0 && $minutesSinceAccept >= $lateExemptMinutes) { + $exemptReason = 'driver_late'; + return 0.0; + } + + // ── الحساب ────────────────────────────────────────────── + $billableMinutes = min($minutesSinceAccept, PASSENGER_CANCEL_MAX_BILLABLE_MINUTES); + $carType = (string) ($ride['carType'] ?? $ride['car_type'] ?? 'Speed'); + + $fee = ($approachKm * getPerKmRate($carType, $kazan)) + + ($billableMinutes * getPerMinRate($kazan)); + + $fee = round(min($fee, $cap, PASSENGER_CANCEL_ABSOLUTE_CAP), 2); + + error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم" + . " + {$billableMinutes}د = $fee (سقف $cap)"); + + return max(0.0, $fee); +} + +/** + * ‏ينفّذ التحويل: خصم من الراكب وإضافة للسائق. + * + * ‏يُستدعى بعد الـcommit — نداءات S2S شبكية لا تُترك داخل معاملة مفتوحة. + * ‏لا يرمي أبداً: فشل التحويل يُسجَّل للتسوية ولا يُبطل إلغاءً تمّ فعلاً. + */ +function applyCancelFeeTransfer(PDO $con, $passengerId, $driverId, $rideId, float $fee): void +{ + if ($fee <= 0 || empty($passengerId) || empty($driverId)) { + return; + } + + try { + $stmtKazan = $con->prepare("SELECT country FROM kazan LIMIT 1"); + $stmtKazan->execute(); + $country = strtolower((string) ($stmtKazan->fetchColumn() ?: 'jordan')); + } catch (PDOException $e) { + $country = 'jordan'; + } + + $walletServer = "https://walletintaleq.intaleq.xyz"; + if ($country === 'jordan') { + $walletServer = getenv('WALLET_SERVER_JORDAN') ?: $walletServer; + } elseif ($country === 'egypt') { + $walletServer = getenv('WALLET_SERVER_EGYPT') ?: $walletServer; + } else { + $walletServer = getenv('WALLET_SERVER_SYRIA') ?: $walletServer; + } + + $s2s = function (string $url, array $fields): int { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($fields), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/x-www-form-urlencoded', + 'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'), + ], + ]); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return (int) $code; + }; + + // ‏خصم من الراكب (دين سالب) + $debtCode = $s2s( + "$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php", + ['passengerID' => $passengerId, 'amount' => -$fee] + ); + + // ‏إضافة للسائق — paymentID مشتق من الرحلة فيمنع ازدواج الصرف + $rewardCode = $s2s( + "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php", + [ + 'driverID' => $driverId, + 'paymentID' => "pax_cancel_$rideId", + 'amount' => $fee, + 'paymentMethod' => 'passenger_cancel_compensation', + ] + ); + + if ($debtCode !== 200 || $rewardCode !== 200) { + // ‏أي طرف فشل يعني اختلالاً يجب أن يظهر في التسوية، لا أن يمرّ صامتاً. + error_log("[cancel_fee] MONEY: رسم $fee للرحلة $rideId —" + . " خصم الراكب=$debtCode، رصيد السائق=$rewardCode" + . " (راكب=$passengerId سائق=$driverId)"); + } +} diff --git a/backend/ride/rides/cancel_ride_by_passenger.php b/backend/ride/rides/cancel_ride_by_passenger.php index 08cd855d..3afe5c20 100644 --- a/backend/ride/rides/cancel_ride_by_passenger.php +++ b/backend/ride/rides/cancel_ride_by_passenger.php @@ -1,5 +1,7 @@ prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?"); + // ‏SELECT * لا قائمة أعمدة: نحتاج rideTimeStart و carType لحساب الرسم، + // ‏وأسماء بعض الأعمدة متضاربة بين الschema والقاعدة الحية (carType / + // ‏car_type). النجمة تجعل الاستعلام يعمل في كل الحالات. + $stmt = $con->prepare("SELECT * FROM ride WHERE id = ?"); $stmt->execute([$rideId]); $ride = $stmt->fetch(PDO::FETCH_ASSOC); @@ -64,8 +68,47 @@ try { $insertCanecl->execute([$driverId, $passengerId, $rideId, $reason]); } + // ================================================================= + // 1.5 رسم الإلغاء — تعويض للسائق لا إيراد للشركة + // + // ‏متماثل مع تعويض عدم الحضور في cancel_ride_by_driver.php: من ضيّع + // ‏وقت الآخر يعوّضه. الفرق أن الزمن يُحتسب من لحظة القبول لا من لحظة + // ‏الوصول — السائق كان في طريقه لا واقفاً ينتظر. + // + // ‏المبلغ لا يأتي من العميل بأي حال، ويُحسب من أسعار kazan نفسها. + // ================================================================= + $cancelFee = 0.0; + $feeExemptReason = ''; + try { + $cancelFee = computePassengerCancelFee( + $con, $redisLocation ?? null, $rideId, $ride, $driverId, $feeExemptReason + ); + } catch (Throwable $eFee) { + // ‏فشل حساب الرسم لا يمنع الإلغاء أبداً — الإلغاء حق للراكب. + error_log("[cancel_ride_by_passenger] تعذّر حساب الرسم: " . $eFee->getMessage()); + } + + if ($cancelFee > 0) { + // ‏عدّاد عدم الحضور — للتقارير وقرارات الحظر اليدوية. + try { + $con->prepare("UPDATE passengers SET noShowCount = noShowCount + 1 WHERE id = ?") + ->execute([$ride['passenger_id'] ?? '']); + } catch (PDOException $eCnt) { + error_log("[cancel_ride_by_passenger] عدّاد عدم الحضور غير مُرحَّل بعد: " + . $eCnt->getMessage()); + } + } + $con->commit(); + // ‏التحويل المالي بعد الـcommit لا داخله: نداءات S2S شبكية قد تستغرق + // ‏ثوانٍ، وإبقاء المعاملة مفتوحة خلالها يقفل صفوف ride تحت ضغط. + if ($cancelFee > 0) { + applyCancelFeeTransfer( + $con, $ride['passenger_id'] ?? '', $driverId, $rideId, $cancelFee + ); + } + // تحديث السيرفر البعيد (Remote DB) if (isset($con_ride)) { try { @@ -155,7 +198,12 @@ try { 'passenger_id' => $ride['passenger_id'] ?? '', ]); - jsonSuccess(null, "Ride cancelled successfully"); + // ‏الرسم يُرجَع للتطبيق ليعرضه صراحةً. خصم صامت من المحفظة يكتشفه + // ‏الراكب لاحقاً هو أسرع طريق لشكوى ولمراجعة سيئة. + jsonSuccess([ + 'cancel_fee' => $cancelFee ?? 0, + 'fee_exempt' => ($cancelFee ?? 0) > 0 ? '' : ($feeExemptReason ?? ''), + ], "Ride cancelled successfully"); } catch (PDOException $e) { if ($con->inTransaction()) $con->rollBack();