Files
Siro/backend/ride/rides/finish_ride_updates.php
T

554 lines
24 KiB
PHP

<?php
require_once __DIR__ . '/../../connect.php';
try {
$con_ride = Database::get('ride');
} catch (Exception $e) {
error_log("[finish_ride_updates] Failed to connect to Ride Database: " . $e->getMessage());
}
require_once __DIR__ . '/streak_helper.php';
// ============================================================
// finish_ride_updates.php — Atomic Server-to-Server
// ============================================================
// Driver App calls this ONCE with raw ride data (NOT the price).
// Server calculates price securely, processes payment via S2S,
// and atomically updates all databases within a transaction.
//
// Flow:
// 1. Receive raw params from driver app (including country_code)
// 2. Load country pricing from `kazan` table
// 3. Calculate price server-side (from DB + actual distance)
// 4. BEGIN TRANSACTION (local DB)
// 5. Update ride on local DB + remote DB (con_ride)
// 6. Update driver_orders
// 7. S2S cURL → Wallet Payment Server (process_ride_payments.php)
// 8. If payment OK → COMMIT, notify passenger (Socket + FCM)
// 9. If payment FAIL → ROLLBACK, ride stays 'Begin', safe retry
// ============================================================
// --- Secure S2S Configuration ---
define('S2S_SHARED_KEY', getenv('S2S_SHARED_KEY') );
define('WALLET_PAYMENT_URL', 'https://walletintaleq.intaleq.xyz/v1/main/ride/payment/process_ride_payments.php');
// ============================================================
// 1. Receive Raw Parameters (NO price from client)
// ============================================================
$rideId = filterRequest("rideId");
// Force driver_id from JWT — never trust user-supplied driver_id
$driver_id = $user_id;
$passengerId = filterRequest("passengerId");
$newStatus = filterRequest("status"); // Expected: "Finished"
$actualDistance = filterRequest("actualDistance");
$actualDuration = filterRequest("actualDuration");
$passengerToken = filterRequest("passengerToken");
$driver_token = filterRequest("driver_token");
$walletChecked = filterRequest("walletChecked");
$passengerWalletBurc = filterRequest("passengerWalletBurc");
$countryCode = filterRequest("country_code"); // 🆕 الدولة: Syria, Egypt, ...
if (empty($rideId) || empty($newStatus) || empty($driver_id) || empty($passengerId)) {
jsonError("Missing required parameters: rideId, driver_id, passengerId, status");
exit;
}
if ($newStatus !== 'Finished') {
jsonError("Invalid status. Expected: Finished");
exit;
}
// 🆕 إذا لم يتم إرسال country_code، نأخذه من قاعدة بيانات الرحلة
if (empty($countryCode)) {
try {
$stmtCountry = $con->prepare("SELECT r.id, d.site AS country_code
FROM ride r
LEFT JOIN driver d ON r.driver_id = d.id
WHERE r.id = ? LIMIT 1");
$stmtCountry->execute([$rideId]);
$rowCountry = $stmtCountry->fetch(PDO::FETCH_ASSOC);
$countryCode = $rowCountry['country_code'] ?? 'Syria';
} catch (Exception $e) {
$countryCode = 'Syria'; // fallback
}
}
// ============================================================
// 2. Load Country Pricing from `kazan` Table
// ============================================================
try {
$stmtKazan = $con->prepare("SELECT * FROM kazan WHERE country = ? LIMIT 1");
$stmtKazan->execute([$countryCode]);
$countryPricing = $stmtKazan->fetch(PDO::FETCH_ASSOC);
if (!$countryPricing) {
// Fallback: إذا لم نجد سعر للدولة، نستخدم Syria كافتراضي
error_log("[finish_ride_updates] No pricing found for country: $countryCode. Falling back to Syria.");
$stmtKazan->execute(['Syria']);
$countryPricing = $stmtKazan->fetch(PDO::FETCH_ASSOC);
$countryCode = 'Syria';
}
} catch (PDOException $e) {
error_log("[finish_ride_updates] Failed to load country pricing: " . $e->getMessage());
jsonError("Failed to load pricing configuration.");
exit;
}
// ============================================================
// 3. Server-Side Price Calculation (Secure — NOT from client)
// ============================================================
try {
// Fetch ride data from remote/local DB for server-side calculation
$stmtRideData = $con->prepare("
SELECT id, price AS quoted_price, car_type,
distance AS planned_distance, passenger_id, driver_id, price_for_driver
FROM ride WHERE id = ? AND driver_id = ?
LIMIT 1
");
$stmtRideData->execute([$rideId, $driver_id]);
$rideData = $stmtRideData->fetch(PDO::FETCH_ASSOC);
if (!$rideData) {
jsonError("Ride not found or driver mismatch.");
exit;
}
$quotedPrice = floatval($rideData['quoted_price'] ?? 0);
$kazanPercent = floatval($countryPricing['kazanPercent'] ?? $countryPricing['kazan'] ?? 10); // 🆕 من جدول kazan (kazanPercent هو الاسم الجديد)
$carType = $rideData['car_type'] ?? 'Fixed Price';
// 🔥 [Fix Driver Commission] عند عرض السعر (pricing/get.php) قد تُطبَّق نسبة
// خصم عمولة خاصة بالمنطقة (kazanDiscountFactor من surge:kazan_discounts)
// فتُحفَظ نتيجتها في عمود ride.price_for_driver — وهو "الوعد" الذي رآه
// السائق كـ"أرباحك أعلى" وقت قبول الطلب. إعادة جلب kazanPercent الخام هنا
// كانت تتجاهل ذلك الخصم بالكامل. نشتق نسبة العمولة الفعلية من الفرق
// المحفوظ فعلياً بين السعر المُقتبَس وحصة السائق منه، ونستخدمها بدل
// النسبة الخام، حتى تبقى النسبة المطبقة عند التسوية مطابقة لما وُعد به.
$priceForDriver = floatval($rideData['price_for_driver'] ?? 0);
if ($quotedPrice > 0 && $priceForDriver > 0 && $priceForDriver <= $quotedPrice) {
$effectiveKazanPercent = (($quotedPrice - $priceForDriver) / $quotedPrice) * 100;
error_log("[finish_ride_updates] Using locked commission rate for ride $rideId: {$effectiveKazanPercent}% (was raw {$kazanPercent}%)");
$kazanPercent = $effectiveKazanPercent;
}
// Fixed-price types, Speed & Awfar: use quoted price as-is
$fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car'];
if (in_array($carType, $fixedPriceTypes)) {
$finalPrice = $quotedPrice; // Fallback if Redis fails
// 🆕 Immutable Fare Lock: Force use of Redis locked price
try {
global $redis;
if ($redis) {
$lockedPrice = $redis->get("ride_locked_price_{$rideId}");
if ($lockedPrice !== false) {
$finalPrice = floatval($lockedPrice);
error_log("[finish_ride_updates] Using Redis Locked Price for ride {$rideId}: {$finalPrice}");
} else {
error_log("[finish_ride_updates] Redis Locked Price not found for ride {$rideId}. Using DB quoted price.");
}
} else {
error_log("[finish_ride_updates] Global Redis instance not available, using DB quoted price.");
}
} catch (Exception $e) {
error_log("[finish_ride_updates] Redis Error (reading locked price): " . $e->getMessage());
}
} else {
// Variable pricing: calculate from actual distance
$cleanDist = preg_replace('/[^0-9.]/', '', $actualDistance);
$distanceKm = floatval($cleanDist);
// 🔥 [Fix Price Cap] سقف أعلى على الانحراف عن المسافة المخططة —
// actualDistance يأتي من العميل، بدون هذا السقف يمكن لانجراف GPS
// أو قيمة مُتلاعَب بها أن تُضخّم السعر النهائي بلا حدود. نسمح بهامش
// معقول للانحرافات الحقيقية (تحويلة، إغلاق طريق...) ونقصّ الباقي.
$plannedDistanceKm = floatval($rideData['planned_distance'] ?? 0);
if ($plannedDistanceKm > 0) {
$maxAllowedDistanceKm = max($plannedDistanceKm * 1.5, $plannedDistanceKm + 5);
if ($distanceKm > $maxAllowedDistanceKm) {
error_log("[finish_ride_updates] ⚠️ actualDistance ($distanceKm km) exceeds cap ($maxAllowedDistanceKm km) for ride $rideId — planned was $plannedDistanceKm km. Clamping.");
$distanceKm = $maxAllowedDistanceKm;
}
}
if ($distanceKm <= 0) {
$finalPrice = $quotedPrice; // fallback
} else {
// 🆕 استخدام الأسعار من جدول kazan حسب الدولة (كل نوع سيارة له عمود سعره الخاص)
$perKmRate = getPerKmRate($carType, $countryPricing);
$perMinRate = getPerMinRate($countryPricing);
$durationMin = intval(preg_replace('/[^0-9]/', '', $actualDuration));
// نفس فكرة السقف على المدة: لا نسمح بمدة أكبر من ضعف زمن الرحلة
// المعقول (نفترض حد أقصى واسع 3 ساعات إذا لم تتوفر مدة مخططة)
$maxAllowedDurationMin = 180;
if ($durationMin > $maxAllowedDurationMin) {
error_log("[finish_ride_updates] ⚠️ actualDuration ($durationMin min) exceeds cap ($maxAllowedDurationMin min) for ride $rideId. Clamping.");
$durationMin = $maxAllowedDurationMin;
}
$calculated = ($distanceKm * $perKmRate) + ($durationMin * $perMinRate);
// 🆕 تطبيق خصم التتابع (إعفاء من العمولة) - نحدد المتغير فقط لكن لا نغير السعر على الراكب
$is_zero_commission = false;
if (hasZeroCommission($con, $driver_id)) {
$is_zero_commission = true;
error_log("[finish_ride_updates] Driver $driver_id has active 0% commission streak!");
}
// السعر النهائي يجب أن يتضمن العمولة دائماً لكي يدفعها الراكب،
// لكن إذا كانت العمولة صفر للسائق، يتم إعطاؤها للسائق بدلاً من الشركة عبر السيرفر المالي.
$calculated *= (1 + ($kazanPercent / 100));
// 🔥 [Fix Price Cap] سقف أعلى مطلق أيضاً على السعر النهائي نفسه
// (دفاع ثانٍ) — لا يتجاوز 1.6 * السعر المُقتبَس أصلاً بأي حال.
$maxAllowedPrice = $quotedPrice > 0 ? $quotedPrice * 1.6 : round($calculated, 2);
$finalPrice = max($quotedPrice, min(round($calculated, 2), $maxAllowedPrice));
}
}
// 🆕 تحديد رمز العملة حسب الدولة
$currency = getCurrencyByCountry($countryCode);
} catch (PDOException $e) {
error_log("[finish_ride_updates] " . $e->getMessage());
jsonError("Error calculating price");
exit;
}
// ============================================================
// 4. Atomic Transaction: Update DBs + Process Payment
// ============================================================
try {
// 🔥 [Fix Split-Brain] كان تحديث قاعدة البيانات البعيدة (con_ride) يحدث
// هنا قبل محاولة الدفع وبدون أي Rollback عليه — فإذا فشل الدفع لاحقاً،
// كانت con_ride تبقى 'Finished' بينما المحلية تُرجَع لـ 'Begin'،
// فإعادة محاولة لاحقة تفشل بصمت على con_ride (شرط WHERE status='Begin'
// لم يعد يتحقق) وتُنتج سعرين مختلفين بين القاعدتين. الآن نؤجل تحديث
// con_ride إلى ما بعد نجاح الدفع فعلياً (انظر الأسفل بعد commit()).
// --- BEGIN Local DB Transaction ---
$con->beginTransaction();
// 4a. Update ride (local DB)
$stmtLocal = $con->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtLocal->execute([$newStatus, $finalPrice, $rideId]);
if ($stmtLocal->rowCount() == 0) {
throw new Exception("Ride already finished or not found in local DB.");
}
// 4b. Update driver_orders (Optimized atomic query)
$stmtOrders = $con->prepare("
INSERT INTO `driver_orders` (`driver_id`, `order_id`, `status`, `created_at`)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
`driver_id` = VALUES(`driver_id`),
`status` = VALUES(`status`),
`created_at` = NOW()
");
$stmtOrders->execute([$driver_id, $rideId, $newStatus]);
// ============================================================
// 4c. Server-to-Server Payment Processing (S2S)
// ============================================================
$paymentPayload = [
'rideId' => $rideId,
'driverId' => $driver_id,
'passengerId' => $passengerId,
'paymentAmount' => $finalPrice,
'paymentMethod' => ($walletChecked === 'true') ? 'wallet' : 'cash',
'walletChecked' => $walletChecked,
'passengerWalletBurc' => $passengerWalletBurc,
'authToken' => $driver_token,
'currency' => $currency, // 🆕 إرسال العملة لمخدم الدفع
'country_code' => $countryCode, // 🆕 إرسال الدولة لمخدم الدفع
'is_zero_commission' => isset($is_zero_commission) && $is_zero_commission ? 'true' : 'false', // 🆕 إرسال إعفاء العمولة
'kazanPercent' => $kazanPercent, // 🆕 إرسال نسبة العمولة متغيرة حسب الدولة
];
$ch = curl_init(WALLET_PAYMENT_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($paymentPayload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . S2S_SHARED_KEY,
],
]);
$paymentResponse = curl_exec($ch);
$httpStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
// Validate payment response
$paymentSuccess = false;
$paymentError = '';
if ($curlError) {
$paymentError = "S2S connection error: " . $curlError;
} elseif ($httpStatusCode !== 200) {
$paymentError = "Payment server returned HTTP $httpStatusCode";
} else {
$paymentResult = json_decode($paymentResponse, true);
if ($paymentResult && isset($paymentResult['status']) && $paymentResult['status'] === 'success') {
$paymentSuccess = true;
} else {
$paymentError = $paymentResult['error'] ?? 'Payment server returned failure';
}
}
if (!$paymentSuccess) {
// ❌ Payment failed — ROLLBACK everything
$con->rollBack();
error_log("[finish_ride_updates] Payment FAILED for ride $rideId: $paymentError");
jsonError("Payment processing failed: $paymentError");
exit;
}
// ✅ Payment succeeded — COMMIT
$con->commit();
// 🆕 Cache ride state in Redis — placed immediately after commit(), before
// the best-effort remote-DB sync / streak / notification code below, so a
// later exception in any of those non-critical steps can never suppress
// this write for a ride that's genuinely finished in MySQL.
sendToLocationServer('update_ride_state', [
'ride_id' => $rideId,
'status' => $newStatus,
'driver_id' => $driver_id,
'passenger_id' => $passengerId,
]);
// 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع
// والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً.
// فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).
if (isset($con_ride)) {
try {
$stmtRemote = $con_ride->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtRemote->execute([$newStatus, $finalPrice, $rideId]);
} catch (PDOException $e) {
error_log("[finish_ride_updates] Remote DB (con_ride) update failed for ride $rideId: " . $e->getMessage());
}
}
// 🆕 Update Driver Streak (Increment because ride is finished successfully)
handleDriverStreak($con, $driver_id, 'increment');
// ============================================================
// 5. Notifications (After successful commit)
// ============================================================
$passenger_id = $passengerId; // alias for legacy code
if (!empty($passenger_id)) {
// Legacy list for backward compatibility
$legacyList = [
(string)$driver_id,
(string)$rideId,
(string)$driver_token,
(string)$finalPrice
];
// a) Socket notification
$socketPayload = [
'ride_id' => $rideId,
'status' => 'finished',
'price' => $finalPrice,
'currency' => $currency, // 🆕
'DriverList' => $legacyList
];
if (function_exists('notifyPassengerOnRideServer')) {
notifyPassengerOnRideServer($passenger_id, $socketPayload);
}
// b) FCM notification
if (!empty($passengerToken)) {
$fcmData = [
'ride_id' => (string)$rideId,
'price' => (string)$finalPrice,
'currency' => $currency, // 🆕
'DriverList' => $legacyList
];
sendFCM_Internal(
$passengerToken,
"تم إنهاء الرحلة 🏁",
"المبلغ المطلوب: " . $finalPrice . " " . $currency,
$fcmData,
'Driver Finish Trip',
$passengerId
);
}
}
// 🆕 c) Driver notification for Zero Commission
if (isset($is_zero_commission) && $is_zero_commission && !empty($driver_token)) {
$fcmDriverData = [
'ride_id' => (string)$rideId,
'status' => 'streak_reward'
];
sendAndSaveDriverNotification(
$con,
$driver_id,
$driver_token,
"🔥 مكافأة التتابع",
"أنت بطل! لم يتم خصم عمولة لهذه الرحلة لأنك حافظت على تتابع قبول الرحلات.",
$fcmDriverData,
'Zero Commission Reward'
);
}
// ============================================================
// 6. Return Success with server-calculated price + currency
// ============================================================
jsonSuccess([
'price' => $finalPrice,
'currency' => $currency, // 🆕 إرجاع العملة للتطبيق
'rideId' => $rideId
], "Ride finished and payment processed successfully.");
} catch (Exception $e) {
if (isset($con) && $con->inTransaction()) {
$con->rollBack();
}
error_log("[finish_ride_updates] Error for ride $rideId: " . $e->getMessage());
jsonError("Transaction failed");
}
// ============================================================
// Helper Functions — الآن تقرأ الأسعار من جدول kazan حسب الدولة
// ============================================================
/**
* الحصول على سعر الكيلومتر حسب نوع السيارة من جدول أسعار الدولة
*
* 🆕 كل نوع سيارة له عمود مستقل في جدول kazan:
* Speed → speedPrice | Comfort → comfortPrice | Lady → ladyPrice
* Electric → electricPrice | Van → vanPrice | Delivery → deliveryPrice
* Mishwar Vip → mishwarVipPrice | Fixed Price → fixedPrice | Awfar → awfarPrice
*
* @param string $carType نوع السيارة
* @param array $countryPricing صف من جدول kazan
* @return float
*/
function getPerKmRate(string $carType, array $countryPricing): float {
// 🆕 الخريطة المباشرة: كل نوع سيارة يقابله عمود بنفس الاسم + "Price"
$rateColumns = [
'Comfort' => 'comfortPrice',
'Speed' => 'speedPrice',
'Lady' => 'ladyPrice',
'Electric' => 'electricPrice',
'Van' => 'vanPrice',
'Delivery' => 'deliveryPrice',
'Mishwar Vip' => 'mishwarVipPrice',
'Fixed Price' => 'fixedPrice',
'Awfar Car' => 'awfarPrice',
];
$column = $rateColumns[$carType] ?? 'speedPrice';
// دعم التوافق مع الإصدارات القديمة (backward compatibility)
$rate = floatval($countryPricing[$column] ?? 0);
// إذا كان السعر صفر أو غير موجود، نبحث في الأسماء القديمة
if ($rate <= 0) {
$oldColumnMap = [
'Lady' => 'familyPrice',
'Mishwar Vip' => 'freePrice',
'Electric' => 'naturePrice',
'Van' => 'heavyPrice',
];
$oldColumn = $oldColumnMap[$carType] ?? null;
if ($oldColumn && isset($countryPricing[$oldColumn])) {
$rate = floatval($countryPricing[$oldColumn]);
}
}
// Fallback أخير
if ($rate <= 0) {
$rate = floatval($countryPricing['speedPrice'] ?? 36);
}
return $rate;
}
/**
* الحصول على سعر الدقيقة حسب وقت اليوم من جدول kazan
*
* 🆕 الآن يقرأ من أعمدة الدقيقة المنفصلة:
* normalMinPrice → Normal (9 ص - 2 م / 6 م - 9 م)
* peakMinPrice → Peak (2 م - 5 م)
* lateMinPrice → Late (9 م - 1 ص)
*
* @param array $countryPricing صف من جدول kazan
* @return float
*/
function getPerMinRate(array $countryPricing): float {
$hour = (int)date('H');
// 🆕 قراءة الأسعار من الأعمدة الجديدة للدقيقة
$normalMinPrice = floatval($countryPricing['normalMinPrice'] ?? 0);
$peakMinPrice = floatval($countryPricing['peakMinPrice'] ?? 0);
$lateMinPrice = floatval($countryPricing['lateMinPrice'] ?? 0);
// 🆕 دعم التوافق مع الإصدارات القديمة (latePrice, naturePrice)
if ($lateMinPrice <= 0) $lateMinPrice = floatval($countryPricing['latePrice'] ?? 0);
if ($normalMinPrice <= 0) $normalMinPrice = floatval($countryPricing['naturePrice'] ?? 0);
// Fallback: حساب سعر الدقيقة من speedPrice إذا كانت الأعمدة الجديدة فارغة
if ($normalMinPrice <= 0) {
$speedPrice = floatval($countryPricing['speedPrice'] ?? 36);
$normalMinPrice = $speedPrice / 4;
}
if ($peakMinPrice <= 0) $peakMinPrice = $normalMinPrice * 1.15; // 15% زيادة
if ($lateMinPrice <= 0) $lateMinPrice = $normalMinPrice * 1.25; // 25% زيادة
if ($hour >= 21 || $hour < 1) {
return round($lateMinPrice, 2); // Late Night
}
if ($hour >= 14 && $hour <= 17) {
return round($peakMinPrice, 2); // Peak
}
return round($normalMinPrice, 2); // Normal
}
/**
* تحديد رمز العملة حسب الدولة
*
* @param string $countryCode رمز الدولة (Syria, Egypt, Jordan, ...)
* @return string رمز العملة (SYP, EGP, JOD, ...)
*/
function getCurrencyByCountry(string $countryCode): string {
$currencies = [
'Syria' => 'SYP',
'Egypt' => 'EGP',
'Jordan' => 'JOD',
'Iraq' => 'IQD',
'UAE' => 'AED',
'Saudi Arabia' => 'SAR',
'Qatar' => 'QAR',
'Kuwait' => 'KWD',
'Bahrain' => 'BHD',
'Oman' => 'OMR',
'Turkey' => 'TRY',
'Lebanon' => 'LBP',
'Palestine' => 'ILS',
'Yemen' => 'YER',
'Libya' => 'LYD',
'Tunisia' => 'TND',
'Algeria' => 'DZD',
'Morocco' => 'MAD',
'Sudan' => 'SDG',
];
return $currencies[$countryCode] ?? 'SYP'; // افتراضي: ليرة سورية
}
?>