441 lines
21 KiB
PHP
441 lines
21 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';
|
||
|
||
// getPerKmRate / getPerMinRate / getCurrencyByCountry — كانت معرَّفة هنا
|
||
// وانتقلت إلى pricing_helper.php ليشاركها تعويض الإلغاء بنفس الأسعار.
|
||
require_once __DIR__ . '/../pricing/pricing_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', getenv('WALLET_PAYMENT_URL') ?: 'http://nginx/v2/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,
|
||
]);
|
||
|
||
// ختم نهاية الرحلة — مقياس الخمول في ترتيب محرك الإسناد (وزن ١٥٪،
|
||
// إنصافاً لمن انتظر أطول). يُكتب هنا لا عند القبول: السائق يبدأ
|
||
// "الخمول" حين يفرغ فعلاً، لا حين ينشغل.
|
||
try {
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
$redisLocation->hSet("driver:score:$driver_id", 'last_ride_end', time());
|
||
}
|
||
} catch (Throwable $eScore) {
|
||
error_log("[finish_ride_updates] تعذّر ختم نهاية الرحلة: " . $eScore->getMessage());
|
||
}
|
||
|
||
// 🔥 [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");
|
||
}
|
||
|