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

441 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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");
}