Update: 2026-08-08 12:58:16

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
+89
View File
@@ -0,0 +1,89 @@
<?php
// Admin/fuel/drawdowns.php — حركة القسائم وملخّص الائتمان
// POST (اختياري): station_id, status, driver_id, limit
//
// ‏الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وأي محطة تعمل؟
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$status = filterRequest('status');
$driverId = filterRequest('driver_id');
$limit = (int) (filterRequest('limit', 'int') ?: 100);
$limit = max(1, min($limit, 500));
$where = [];
$params = [];
if ($stationId > 0) {
$where[] = 'v.station_id = ?';
$params[] = $stationId;
}
if (!empty($status)) {
$where[] = 'v.status = ?';
$params[] = $status;
}
if (!empty($driverId)) {
$where[] = 'v.driver_id = ?';
$params[] = $driverId;
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
$st = $con->prepare("
SELECT v.id, v.driver_id, v.code, v.amount_authorized, v.amount_redeemed,
v.currency, v.status, v.expires_at, v.redeemed_at, v.created_at,
s.name_ar AS station_name
FROM fuel_vouchers v
LEFT JOIN fuel_stations s ON s.id = v.station_id
$clause
ORDER BY v.id DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الملخّص على كامل المنتج لا على الصفحة المعروضة: رقمٌ يتغيّر بتغيّر
// ‏الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
$summary = $con->query("
SELECT
COALESCE(SUM(amount), 0) AS lent_total,
COALESCE(SUM(amount_collected), 0) AS collected_total,
COALESCE(SUM(amount_remaining), 0) AS outstanding_total,
COUNT(DISTINCT driver_id) AS drivers_count
FROM obligation_ledger
WHERE product_code = 'fuel_wallet'
")->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (PDOException $e) {
error_log('[admin/fuel/drawdowns] ' . $e->getMessage());
jsonError('Server error', 500);
}
$vouchers = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'driver_id' => $r['driver_id'],
'code' => $r['code'],
'amount_authorized' => (float) $r['amount_authorized'],
'amount_redeemed' => (float) $r['amount_redeemed'],
'currency' => $r['currency'],
'status' => $r['status'],
'station' => $r['station_name'],
'expires_at' => $r['expires_at'],
'redeemed_at' => $r['redeemed_at'],
'created_at' => $r['created_at'],
], $rows);
jsonSuccess([
'vouchers' => $vouchers,
'summary' => [
'lent_total' => (float) ($summary['lent_total'] ?? 0),
'collected_total' => (float) ($summary['collected_total'] ?? 0),
'outstanding_total' => (float) ($summary['outstanding_total'] ?? 0),
'drivers_count' => (int) ($summary['drivers_count'] ?? 0),
],
], 'success');
+90
View File
@@ -0,0 +1,90 @@
<?php
// Admin/fuel/list.php — قائمة محطات الوقود الشريكة
// POST (اختياري): search, city, only_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$search = filterRequest('search');
$city = filterRequest('city');
$onlyActive = filterRequest('only_active') === '1';
$where = [];
$params = [];
if (!empty($search)) {
$where[] = '(s.name_ar LIKE ? OR s.chain_name LIKE ? OR s.address LIKE ?)';
$like = '%' . $search . '%';
array_push($params, $like, $like, $like);
}
if (!empty($city)) {
$where[] = 's.city = ?';
$params[] = $city;
}
if ($onlyActive) {
$where[] = 's.is_active = 1';
}
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
try {
// ‏إحصاء الصرف مع كل محطة: لوحة بلا أرقام لا تخبر أحداً أي محطة
// ‏تعمل فعلاً وأيها اسمٌ في جدول. `redeemed` وحدها — القسائم الملغاة
// ‏والمنتهية لا تقول شيئاً عن أداء المحطة.
$st = $con->prepare("
SELECT s.*,
COUNT(v.id) AS redeem_count,
COALESCE(SUM(v.amount_redeemed), 0) AS redeem_total,
MAX(v.redeemed_at) AS last_redeem_at
FROM fuel_stations s
LEFT JOIN fuel_vouchers v
ON v.station_id = s.id AND v.status = 'redeemed'
$clause
GROUP BY s.id
ORDER BY s.is_active DESC, s.city ASC, s.name_ar ASC
LIMIT 200
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[admin/fuel/list] ' . $e->getMessage());
jsonError('Server error', 500);
}
$stations = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name_ar' => $r['name_ar'],
'chain_name' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
'is_active' => (int) $r['is_active'] === 1,
'created_at' => $r['created_at'],
'redeem_count' => (int) $r['redeem_count'],
'redeem_total' => (float) $r['redeem_total'],
'last_redeem_at' => $r['last_redeem_at'],
// ‏المفتاح لا يخرج أبداً — لا هنا ولا في أي نقطة أخرى. مخزَّن مُهشَّراً
// ‏أصلاً، ويُعرض مرة واحدة فقط لحظة إنشائه أو تدويره.
], $rows);
// ‏حالة المنتج تُرسل مع القائمة لا في نقطة منفصلة: الشاشة تحتاج أن تعرض
// ‏«مُجهَّز وغير مُشغَّل» بوضوح، وطلبان لبناء لافتة واحدة إسراف.
$productActive = false;
try {
$st = $con->prepare("SELECT is_active FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
$st->execute();
$productActive = (int) $st->fetchColumn() === 1;
} catch (PDOException $e) {
error_log('[admin/fuel/list] تعذّرت قراءة حالة المنتج: ' . $e->getMessage());
}
jsonSuccess([
'stations' => $stations,
'product_active' => $productActive,
], 'success');
+44
View File
@@ -0,0 +1,44 @@
<?php
// Admin/fuel/rotate_key.php — توليد مفتاح جديد لمحطة
// POST: station_id
//
// ‏يُستعمل عند فقدان المحطة مفتاحها أو الشك في تسريبه. المفتاح القديم
// ‏يتوقّف فوراً — وهذا مقصود: التدوير إجراء أمني، وإبقاء القديم يعمل
// ‏«حتى تنتقل المحطة» يُفرغه من معناه.
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
if ($stationId <= 0) {
jsonError('station_id مطلوب');
}
try {
$st = $con->prepare("SELECT name_ar FROM fuel_stations WHERE id = ? LIMIT 1");
$st->execute([$stationId]);
$name = $st->fetchColumn();
if ($name === false) {
jsonError('المحطة غير موجودة', 404);
}
$plainKey = bin2hex(random_bytes(24));
$con->prepare("UPDATE fuel_stations SET api_key_hash = ? WHERE id = ?")
->execute([hash('sha256', $plainKey), $stationId]);
error_log("[admin/fuel] دُوِّر مفتاح المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'station' => $name,
'api_key' => $plainKey,
], 'المفتاح القديم توقّف. انسخ الجديد الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/rotate_key] ' . $e->getMessage());
jsonError('Server error', 500);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
// Admin/fuel/save.php — إضافة محطة أو تعديلها
// POST: station_id (اختياري للتعديل), name_ar, chain_name, city, address,
// lat, lng, phone, discount_percent, is_active
require_once __DIR__ . '/../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
jsonError('Unauthorized: Admin access required', 403);
}
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
$nameAr = filterRequest('name_ar');
if (empty($nameAr)) {
jsonError('اسم المحطة مطلوب');
}
$discount = (float) (filterRequest('discount_percent', 'numeric') ?: 0);
if ($discount < 0 || $discount > 100) {
jsonError('نسبة الخصم يجب أن تكون بين 0 و 100');
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
$fields = [
'name_ar' => $nameAr,
'chain_name' => filterRequest('chain_name'),
'city' => filterRequest('city'),
'address' => filterRequest('address'),
'lat' => $lat !== null ? (float) $lat : null,
'lng' => $lng !== null ? (float) $lng : null,
'phone' => filterRequest('phone'),
'discount_percent' => $discount,
'is_active' => filterRequest('is_active') === '0' ? 0 : 1,
];
try {
if ($stationId > 0) {
$sets = implode(', ', array_map(static fn($k) => "`$k` = ?", array_keys($fields)));
$st = $con->prepare("UPDATE fuel_stations SET $sets WHERE id = ?");
$st->execute([...array_values($fields), $stationId]);
if ($st->rowCount() === 0) {
// ‏rowCount صفر قد يعني «غير موجودة» وقد يعني «حُفظت بلا تغيير».
// ‏نفرّق بينهما بقراءة صريحة: إخبار المستخدم أن المحطة غير
// ‏موجودة بينما هي موجودة يدفعه لإنشاء نسخة ثانية منها.
$chk = $con->prepare("SELECT 1 FROM fuel_stations WHERE id = ? LIMIT 1");
$chk->execute([$stationId]);
if (!$chk->fetchColumn()) {
jsonError('المحطة غير موجودة', 404);
}
}
error_log("[admin/fuel] عُدّلت المحطة #$stationId بواسطة $user_id");
jsonSuccess(['station_id' => $stationId, 'created' => false], 'تم حفظ التعديلات');
}
// ── إنشاء جديد ──
// ‏المفتاح يُولَّد هنا ولا يُطلب من المستخدم: مفتاح يختاره إنسان يكون
// ‏قصيراً ومتوقَّعاً، وهذا المفتاح يأذن بصرف مال.
$plainKey = bin2hex(random_bytes(24));
$cols = array_keys($fields);
$st = $con->prepare(
'INSERT INTO fuel_stations (`' . implode('`, `', $cols) . '`, `api_key_hash`) '
. 'VALUES (' . implode(', ', array_fill(0, count($cols), '?')) . ', ?)'
);
$st->execute([...array_values($fields), hash('sha256', $plainKey)]);
$stationId = (int) $con->lastInsertId();
error_log("[admin/fuel] أُنشئت المحطة #$stationId بواسطة $user_id");
jsonSuccess([
'station_id' => $stationId,
'created' => true,
// ‏المرة **الوحيدة** التي يظهر فيها المفتاح. مخزَّن مُهشَّراً، فلا
// ‏سبيل لاستعادته لاحقاً — من يفقده يدوّر مفتاحاً جديداً.
'api_key' => $plainKey,
], 'أُنشئت المحطة. انسخ المفتاح الآن — لن يظهر مرة أخرى.');
} catch (PDOException $e) {
error_log('[admin/fuel/save] ' . $e->getMessage());
jsonError('Server error', 500);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Admin/fuel/toggle_product.php — تشغيل محفظة الوقود أو إيقافها
// POST: is_active (1 | 0)
//
// ‏قرار المالك: تُبنى الشاشات وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد
// ‏إتمام الاتفاق مع سلسلة المحطات. هذه هي نقطة ذلك التبديل.
//
// ‏ما الذي يتغيّر فعلاً؟ المنتج المُطفأ لا يظهر في `obligationProduct`،
// ‏فلا يستطيع سائق تفعيل محفظته ولا إصدار قسيمة. أما الالتزامات القائمة
// ‏ودفترها فلا تتأثر — الإطفاء يغلق الباب ولا يمحو ما خلفه.
require_once __DIR__ . '/../../connect.php';
// ‏super_admin وحده. تشغيل المنتج يفتح باب إقراض حقيقي بمال الشركة،
// ‏وهو قرار مالك لا قرار مناوب على اللوحة.
if ($role !== 'super_admin') {
jsonError('Unauthorized: Super admin access required', 403);
}
$isActive = filterRequest('is_active') === '1' ? 1 : 0;
try {
// ‏التشغيل بلا محطة واحدة فعّالة يعني سائقاً يفعّل محفظته، يطلب
// ‏قسيمة، ثم لا يجد أين يصرفها. الرفض هنا أرحم من اكتشافه عند مضخة.
if ($isActive === 1) {
$st = $con->query("SELECT COUNT(*) FROM fuel_stations WHERE is_active = 1");
if ((int) $st->fetchColumn() === 0) {
jsonError('لا توجد محطة فعّالة واحدة. أضف محطة قبل تشغيل المنتج.');
}
}
$st = $con->prepare("UPDATE obligation_products SET is_active = ? WHERE code = 'fuel_wallet'");
$st->execute([$isActive]);
if ($st->rowCount() === 0) {
$chk = $con->query("SELECT 1 FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
if (!$chk->fetchColumn()) {
jsonError('منتج محفظة الوقود غير موجود — نفّذ الترحيل أولاً', 404);
}
}
error_log('[admin/fuel] ' . ($isActive ? 'شُغّلت' : 'أُوقفت') . " محفظة الوقود بواسطة $user_id");
jsonSuccess(
['product_active' => $isActive === 1],
$isActive === 1
? 'شُغّلت محفظة الوقود — صارت متاحة للسائقين المؤهَّلين'
: 'أُوقفت محفظة الوقود — لا تفعيل جديد ولا قسائم جديدة'
);
} catch (PDOException $e) {
error_log('[admin/fuel/toggle_product] ' . $e->getMessage());
jsonError('Server error', 500);
}
+32 -18
View File
@@ -7,11 +7,19 @@
* ‏رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
* ‏المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
* ‏أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
* ‏هنا، وتقرأه التسوية.
* ‏هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
*
* ‏تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
* ‏الالتزامات العام، فيكتب في `obligation_ledger` لا في
* ‏`insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
* ‏أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
* ‏الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
* ‏تاريخياً، وقد رُحّلت بياناتها في
* ‏`migrations/2026_08_08_obligations_engine.sql`.
*
* ‏الحماية من الاحتساب المزدوج طبقتان:
* ١. `last_charged_on` في الوثيقة — نفلتر به قبل العمل
* ٢. مفتاح فريد (policy_id, charge_date) في الدفتر — يحسم السباق
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
* لو عملت نسختان من الكرون معاً
*
* ‏الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
@@ -39,19 +47,24 @@ $charged = 0;
$skipped = 0;
try {
// ‏المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
// ‏لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
// ‏بالمنطق نفسه بلا سطر جديد هنا.
$st = $con->query("
SELECT p.id, p.driver_id, p.last_charged_on,
pl.premium, pl.currency, pl.billing_cycle
FROM driver_insurance_policies p
JOIN insurance_plans pl ON pl.id = p.plan_id
WHERE p.status = 'active'
ORDER BY p.id ASC
SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
pr.code AS product_code, pr.currency, pr.billing_cycle
FROM driver_obligations o
JOIN obligation_products pr ON pr.id = o.product_id
WHERE o.status = 'active'
AND pr.kind = 'recurring'
AND pr.billing_cycle <> 'none'
ORDER BY o.id ASC
");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
// ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل وثيقة كل يوم.
// ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
if ($policy['last_charged_on'] === $chargeDate) {
$skipped++;
continue;
@@ -61,16 +74,17 @@ try {
$con->beginTransaction();
$con->prepare("
INSERT INTO insurance_premium_ledger
(policy_id, driver_id, charge_date, amount, currency, status)
VALUES (?, ?, ?, ?, ?, 'pending')
INSERT INTO obligation_ledger
(obligation_id, driver_id, product_code, charge_date,
amount, amount_collected, amount_remaining, currency, status)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
")->execute([
$policy['id'], $policy['driver_id'], $chargeDate,
$policy['premium'], $policy['currency'],
$policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
]);
$con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ?
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]);
$con->commit();
@@ -82,11 +96,11 @@ try {
// ‏هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
if ($e->getCode() === '23000') {
$con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ?
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]);
$skipped++;
} else {
error_log("[insurance] تعذّر قيد قسط الوثيقة #{$policy['id']}: " . $e->getMessage());
error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
}
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* cron_obligation_settlement.php — محرك تسوية الالتزامات
* ─────────────────────────────────────────────────────────────
* ‏الجهة **الوحيدة** التي تحوّل قيداً في `obligation_ledger` إلى خصم فعلي
* ‏من محفظة السائق. مُصدِرو الاستحقاق (التأمين، الوقود، الصيانة) يقيّدون
* ‏فقط ولا يلمسون مالاً — وهذا ما يجعل إضافة منتج جديد لا تمسّ أخطر
* ‏منطق في المنصة.
*
* ‏هذا الملف يغلق ثغرة إنتاج قائمة: `cron_insurance_premiums.php` يقيّد
* ‏الأقساط منذ إطلاقه وتعليقه يقول «تقرأه التسوية» — ولم تكن التسوية
* ‏موجودة. كل قسط قُيّد حتى اليوم بقي `pending` بلا تحصيل.
*
* ‏لماذا كرون منفصل لا خصم عند إنهاء الرحلة؟
* ‏لأن `finish_ride_updates.php` مسار حرج والسائق ينتظر رده. نداء شبكي
* ‏إلى سيرفر المحفظة داخله يعني تعليق شاشة السائق على بطء طرف ثالث،
* ‏وفشلاً في التسوية يظهر للسائق كفشل في إنهاء رحلته.
*
* ‏ضمانات عدم التكرار طبقتان:
* ١. `last_attempt_on` في القيد — محاولة واحدة لكل قيد في اليوم
* ٢. `settlement_ref` فريد في سيرفر المحفظة — يحسم السباق ويجعل
* ‏إعادة النداء بعد انقطاع شبكي آمنة بالتعريف
*
* ‏الأولى وحدها لا تكفي: نداء قد ينجح في المحفظة ثم ينقطع قبل أن يصل
* ‏الرد، فيبقى القيد بلا تحديث. الطبقة الثانية تجعل إعادة المحاولة تُرجع
* ‏نتيجة الخصم الأول بدل خصم ثانٍ.
*
* ‏جدولة: مرة يومياً في آخر اليوم لا بعد قيد الاستحقاقات مباشرة —
* ‏السقف نسبة من أرباح اليوم، وقراءتها بعد منتصف الليل تعني سقفاً صفرياً
* ‏كل ليلة وتحصيلاً لا يقع أبداً.
* 30 23 * * * docker compose exec -T php php /var/www/backend/bot/cron_obligation_settlement.php
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../obligations/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
fwrite(STDERR, '[obligation] DB unavailable: ' . $e->getMessage() . "\n");
exit(1);
}
$sharedKey = getenv('S2S_SHARED_KEY') ?: '';
if ($sharedKey === '') {
// ‏بلا مفتاح لن ينجح أي نداء. الخروج المبكر أوضح من مئة محاولة
// ‏تعود 401 وتزيد `attempts` بلا سبب حقيقي.
fwrite(STDERR, "[obligation] S2S_SHARED_KEY غير معرّف — لا تسوية\n");
exit(1);
}
/**
* ‏ينادي نقطة الخصم ويُرجع ما خُصم فعلاً، أو null عند فشل لا يمكن تفسيره.
*
* ‏التمييز بين «خُصم صفر» و«فشل» جوهري: الأول حالة طبيعية (لا أرباح
* ‏اليوم) تُسجَّل محاولةً، والثاني لا يجوز أن يُحسب محاولة — وإلا ضاع
* ‏يوم تحصيل بسبب عطل شبكي عابر.
*/
function obligationDeduct(string $ref, string $driverId, string $productCode,
float $amount, float $capPercent): ?float
{
$payload = obligationWalletCall(
'/v2/main/ride/driverWallet/deduct_s2s_obligation.php',
[
'settlement_ref' => $ref,
'driverID' => $driverId,
'product_code' => $productCode,
'amount' => number_format($amount, 3, '.', ''),
'cap_percent' => number_format($capPercent, 2, '.', ''),
]
);
if ($payload === null || !isset($payload['deducted'])) {
error_log("[obligation] تعذّر الحصول على نتيجة خصم ($ref)");
return null;
}
return round((float) $payload['deducted'], 3);
}
$today = date('Y-m-d');
$settled = 0;
$partial = 0;
$idle = 0;
$failed = 0;
$total = 0.0;
try {
// ‏الترتيب مقصود: السائق أولاً ليكون سقفه اليومي مُستهلَكاً بترتيب
// ‏معلوم، ثم أولوية المنتج (التأمين قبل الوقود قبل الصيانة)، ثم
// ‏الأقدم فالأحدث — الدَّين الأقدم أولى بالسداد.
$st = $con->query("
SELECT l.id, l.obligation_id, l.driver_id, l.product_code,
l.amount_remaining, l.amount_collected, l.amount, l.attempts,
p.daily_cap_percent, p.kind
FROM obligation_ledger l
JOIN driver_obligations o ON o.id = l.obligation_id
JOIN obligation_products p ON p.id = o.product_id
WHERE l.status IN ('pending', 'partial')
AND l.amount_remaining > 0
AND (l.last_attempt_on IS NULL OR l.last_attempt_on <> CURDATE())
ORDER BY l.driver_id ASC, p.priority ASC, l.charge_date ASC
");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$ledgerId = (int) $row['id'];
$remaining = round((float) $row['amount_remaining'], 3);
// ‏المرجع مبنيّ على عدّاد المحاولات لا على تاريخ اليوم. الفرق ليس
// ‏تجميلياً: `attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح، فنداء
// ‏نجح في المحفظة وضاع ردّه يُعاد لاحقاً — ولو بعد أيام — بالمرجع
// ‏نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. مرجعٌ
// ‏مبنيّ على التاريخ كان سيخصم المبلغ مرتين في هذه الحالة بالضبط.
$ref = "obl_{$ledgerId}_{$row['attempts']}";
$deducted = obligationDeduct(
$ref, $row['driver_id'], $row['product_code'],
$remaining, (float) $row['daily_cap_percent']
);
if ($deducted === null) {
// ‏لا نلمس القيد إطلاقاً — ولا حتى `attempts`. قد يكون الخصم
// ‏تمّ في المحفظة وضاع الرد، وإبقاء العدّاد كما هو يضمن أن
// ‏المحاولة القادمة تحمل المرجع نفسه فتستردّ نتيجة الأولى.
$failed++;
continue;
}
$collected = round((float) $row['amount_collected'] + $deducted, 3);
$newRemaining = round((float) $row['amount'] - $collected, 3);
if ($newRemaining < 0) {
$newRemaining = 0.0;
}
if ($newRemaining <= 0) {
$status = 'settled';
} elseif ($collected > 0) {
$status = 'partial';
} else {
$status = 'pending';
}
try {
$con->beginTransaction();
$con->prepare("
UPDATE obligation_ledger
SET amount_collected = ?, amount_remaining = ?, status = ?,
attempts = attempts + 1, last_attempt_on = ?,
settled_at = CASE WHEN ? = 'settled' THEN NOW() ELSE settled_at END
WHERE id = ?
")->execute([$collected, $newRemaining, $status, $today, $status, $ledgerId]);
// ‏المقسّط وحده يراكم أصلاً مسدَّداً. الدوري بلا نهاية فلا
// ‏معنى لأصلٍ ينفد عنده.
if ($deducted > 0 && $row['kind'] === 'installment') {
$con->prepare("
UPDATE driver_obligations
SET principal_paid = principal_paid + ?
WHERE id = ?
")->execute([$deducted, (int) $row['obligation_id']]);
// ‏سداد الأصل كاملاً يُنهي الالتزام. بدون هذا يبقى قرض
// ‏مسدَّد «نشطاً» فيظهر للسائق ويزاحم غيره على السقف.
$con->prepare("
UPDATE driver_obligations
SET status = 'completed', ended_at = CURDATE()
WHERE id = ? AND status = 'active'
AND principal_amount > 0
AND principal_paid >= principal_amount
")->execute([(int) $row['obligation_id']]);
}
$con->commit();
} catch (PDOException $e) {
if ($con->inTransaction()) $con->rollBack();
// ‏الخصم تمّ فعلاً والقيد لم يُحدَّث — أخطر حالة هنا. لا إعادة
// ‏محاولة تلقائية: المرجع نفسه سيُرجع الخصم الأول لا خصماً
// ‏جديداً، لكن التباين بين الدفترين يحتاج عيناً بشرية.
error_log("[obligation] خُصم {$deducted} للقيد #{$ledgerId} ولم يُحدَّث الدفتر: " . $e->getMessage());
$failed++;
continue;
}
$total += $deducted;
if ($status === 'settled') $settled++;
elseif ($deducted > 0) $partial++;
else $idle++;
}
} catch (PDOException $e) {
fwrite(STDERR, '[obligation] فشل المرور: ' . $e->getMessage() . "\n");
exit(1);
}
printf(
"[obligation] سُوِّي %d، جزئي %d، بلا خصم %d، فشل %d — إجمالي %.3f\n",
$settled, $partial, $idle, $failed, $total
);
+16 -12
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
@@ -22,7 +23,12 @@ if (!$policy) {
$reason = filterRequest('reason') ?: 'بطلب السائق';
// ‏الوثيقة والالتزام يُغلقان معاً. التزام يبقى نشطاً بعد إلغاء وثيقته
// ‏يعني قسطاً يُقيَّد كل ليلة على سائقٍ بلا تغطية — عكس الثغرة السابقة
// ‏تماماً، وأسوأ منها: هناك مال يضيع على الشركة، وهنا مال يُؤخذ ظلماً.
try {
$con->beginTransaction();
// ‏الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام
// ‏مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته
// ‏لو أعيد استعماله يوماً في سياق آخر.
@@ -34,24 +40,22 @@ try {
$st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]);
if ($st->rowCount() === 0) {
$con->rollBack();
jsonError('تعذّر إلغاء الوثيقة');
}
} catch (PDOException $e) {
obligationClose($con, $driverId, $policy['code'], $reason);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[assurance/cancel] ' . $e->getMessage());
jsonError('Server error');
}
$pending = 0.0;
try {
$st = $con->prepare("
SELECT COALESCE(SUM(amount), 0) FROM insurance_premium_ledger
WHERE policy_id = ? AND status = 'pending'
");
$st->execute([$policy['id']]);
$pending = (float) $st->fetchColumn();
} catch (PDOException $e) {
error_log('[assurance/cancel] تعذّرت قراءة المستحق: ' . $e->getMessage());
}
// ‏المستحق يُقرأ من الدفتر الموحّد لا من دفتر التأمين القديم: القديم
// ‏مجمَّد بعد الترحيل، وقراءته تعني رقماً لا يتغيّر مهما سدّد السائق.
$pending = obligationPendingTotal($con, $driverId, $policy['code']);
error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId");
+19 -12
View File
@@ -32,28 +32,35 @@ $summary = ['pending_total' => 0.0, 'settled_total' => 0.0,
'currency' => $policy['currency']];
$entries = [];
// ‏المصدر هو الدفتر الموحّد لا دفتر التأمين القديم (المجمَّد بعد ترحيل
// ‏2026_08_08). المفتاح صار (السائق + رمز المنتج) بدل رقم الوثيقة:
// ‏الدفتر الموحّد لا يعرف الوثائق، يعرف التزامات.
try {
// ‏المستحق من `amount_remaining` والمحصَّل من `amount_collected`:
// ‏قيدٌ حُصِّل نصفه يظهر في الرقمين معاً بحصّته الصحيحة، بينما جمع
// ‏`amount` حسب الحالة كان سيعدّه كاملاً في أحدهما ويكذب في كليهما.
$st = $con->prepare("
SELECT status, SUM(amount) AS total
FROM insurance_premium_ledger
WHERE policy_id = ?
GROUP BY status
SELECT COALESCE(SUM(amount_remaining), 0) AS pending_total,
COALESCE(SUM(amount_collected), 0) AS settled_total
FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
");
$st->execute([$policy['id']]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
if ($row['status'] === 'pending') $summary['pending_total'] = (float) $row['total'];
if ($row['status'] === 'settled') $summary['settled_total'] = (float) $row['total'];
$st->execute([$driverId, $policy['code']]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$summary['pending_total'] = (float) $row['pending_total'];
$summary['settled_total'] = (float) $row['settled_total'];
}
// ‏آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد.
$st = $con->prepare("
SELECT charge_date, amount, currency, status, note
FROM insurance_premium_ledger
WHERE policy_id = ?
SELECT charge_date, amount, amount_collected, amount_remaining,
currency, status, note
FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
ORDER BY charge_date DESC
LIMIT 30
");
$st->execute([$policy['id']]);
$st->execute([$driverId, $policy['code']]);
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage());
+20
View File
@@ -10,6 +10,7 @@
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
// ‏الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه،
// ‏وقراءتها بمعرّف مُرسَل تكشف أرقام غيره.
@@ -37,10 +38,29 @@ try {
$current = assuranceActivePolicy($con, $driverId);
// ‏نداء واحد للمحفظة لكل الشاشة: الملخّص عن السائق لا عن الخطة.
// ‏بدونه كانت الشاشة تعرض «مؤهَّل» ثم يُرفض الطلب عند الضغط — وهو أسوأ
// ‏من الرفض المبكر: يعد السائق بشيء ثم يسحبه.
$incomeSummary = obligationWalletIncomeSummary($driverId);
$out = [];
foreach ($plans as $plan) {
$check = assuranceCheckEligibility($con, $driverId, $plan);
// ‏بوابة الائتمان تُقاس على منتج الالتزام المقابل للخطة. غيابه يعني
// ‏خطة لم تُرحَّل بعد — لا نحجبها ولا ندّعي أهليةً لها، بل نتركها
// ‏لحكم شروط الخطة وحدها ونسجّل الحالة.
$product = obligationProduct($con, $plan['code']);
if ($product) {
$gate = obligationCheckWalletIncome($product, $driverId, $incomeSummary);
if (!$gate['eligible']) {
$check['eligible'] = false;
$check['reasons'] = array_merge($check['reasons'], $gate['reasons']);
}
} else {
error_log("[assurance/plans] لا منتج التزام للخطة {$plan['code']}");
}
$out[] = [
'id' => (int) $plan['id'],
'code' => $plan['code'],
+34 -1
View File
@@ -9,6 +9,7 @@
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/eligibility.php';
require_once __DIR__ . '/../obligations/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
@@ -49,7 +50,30 @@ if (!$check['eligible']) {
jsonError('لم تستوفِ شروط هذه الخطة بعد: ' . implode('، ', $check['reasons']));
}
// ‏بوابة الائتمان تُفحص هنا صراحةً رغم أن `obligationOpen` تفحصها أيضاً:
// ‏الفحص هناك حارسٌ أخير يرمي استثناءً، ورسالته تصل للسائق كخطأ عام.
// ‏السائق الذي يُرفض طلبه يستحق أن يعرف السبب وما يفعله ليستوفيه.
$product = obligationProduct($con, $plan['code']);
if (!$product) {
error_log("[assurance/subscribe] منتج الالتزام مفقود للخطة {$plan['code']}");
jsonError('تعذّر تفعيل الوثيقة حالياً');
}
$walletGate = obligationCheckWalletIncome($product, $driverId);
if (!$walletGate['eligible']) {
jsonError(
'التأمين يُخصم قسطه من أرباحك في المحفظة، ولذلك يشترط أن تمرّ أرباحك بها: '
. implode('، ', $walletGate['reasons'])
);
}
// ‏كتابتان في معاملة واحدة: الوثيقة (السجل التأميني الذي يقرأه التطبيق)
// ‏والالتزام (ما يراه محرك القيد والتسوية). وثيقة بلا التزام تعني تغطية
// ‏تُمنح ولا يُحصَّل قسطها أبداً — وهي بالضبط الثغرة التي جاء هذا المحرك
// ‏ليغلقها، فلا يجوز أن نعيد فتحها من باب الاشتراكات الجديدة.
try {
$con->beginTransaction();
$con->prepare("
INSERT INTO driver_insurance_policies
(driver_id, plan_id, status, started_at, rides_at_signup, rating_at_signup)
@@ -57,7 +81,16 @@ try {
")->execute([$driverId, $planId, $check['rides'], $check['rating']]);
$policyId = (int) $con->lastInsertId();
} catch (PDOException $e) {
obligationOpen($con, $driverId, $plan['code'], [
'cycle_amount' => (float) $plan['premium'],
'rides' => $check['rides'],
'rating' => $check['rating'],
]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[assurance/subscribe] تعذّر إنشاء الوثيقة: ' . $e->getMessage());
jsonError('تعذّر تفعيل الوثيقة حالياً');
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* fuel/cancel_voucher.php — إلغاء قسيمة لم تُصرف
*
* ‏يحرّر المبلغ المحجوز فوراً. بدونه ينتظر السائق انتهاء الصلاحية ليعود
* ‏سقفه — وهو ينتظر عند مضخة ليتزوّد بمبلغ آخر.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
// ‏الشرط على driver_id إلى جانب الحالة: الرمز وحده يكفي للعثور على
// ‏قسيمة، ولا يجوز أن يُلغي سائقٌ قسيمة غيره برمز وقع بيده.
$st = $con->prepare("
UPDATE fuel_vouchers
SET status = 'cancelled'
WHERE driver_id = ? AND status = 'issued'
");
$st->execute([$driverId]);
if ($st->rowCount() === 0) {
jsonError('لا توجد قسيمة قابلة للإلغاء');
}
$obligation = fuelObligation($con, $driverId);
$state = $obligation ? fuelCreditState($con, $obligation) : null;
jsonSuccess([
'cancelled' => true,
'available' => $state['available'] ?? null,
], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* fuel/enroll.php — تفعيل محفظة الوقود للسائق
*
* ‏يفتح التزام `fuel_wallet` بسقف ائتمان مبدئي. لا يصرف شيئاً — الصرف
* ‏يبدأ بقسيمة، والدَّين يُقيَّد عند تأكيد المحطة.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/../driver_assurance/eligibility.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
if (fuelObligation($con, $driverId)) {
jsonError('محفظة الوقود مفعّلة لديك بالفعل');
}
$product = obligationProduct($con, FUEL_PRODUCT_CODE);
if (!$product) {
error_log('[fuel/enroll] منتج محفظة الوقود غير موجود أو مُطفأ');
jsonError('محفظة الوقود غير متاحة حالياً');
}
// ‏شروط الرحلات والتقييم تُقاس بالدالة نفسها التي يستعملها التأمين:
// ‏المنطق واحد (سائق أثبت أنه باقٍ)، وازدواج تطبيقه كان سينتج معيارين
// ‏يفترقان بصمت عند أول تعديل.
$check = assuranceCheckEligibility($con, $driverId, $product);
if (!$check['eligible']) {
jsonError('لم تستوفِ شروط محفظة الوقود بعد: ' . implode('، ', $check['reasons']));
}
// ‏بوابة الائتمان أشدّ هنا منها في التأمين: الوقود قيمة فعلية تُسلَّم في
// ‏خزّان السيارة، ولا سبيل لاستردادها إلا من أرباح تمرّ بالمحفظة.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
jsonError(
'محفظة الوقود تُسدَّد من أرباحك في المحفظة، ولذلك تشترط أن تمرّ أرباحك بها: '
. implode('، ', $gate['reasons'])
);
}
// ‏السقف المبدئي واحد للجميع في هذه المرحلة. رفعه بحسب سلوك السداد قرار
// ‏لاحق يحتاج بيانات لا نملكها بعد — ومنح سقف متدرّج بلا تاريخ سداد
// ‏تخمينٌ يرتدي ثوب الخوارزمية.
$defaultLimit = (float) (getenv('FUEL_DEFAULT_CREDIT_LIMIT') ?: 30);
try {
$con->beginTransaction();
$obligationId = obligationOpen($con, $driverId, FUEL_PRODUCT_CODE, [
'cycle_amount' => 0, // ‏منتج سحب: لا قسط دوري له
'rides' => $check['rides'],
'rating' => $check['rating'],
]);
$con->prepare("UPDATE driver_obligations SET credit_limit = ? WHERE id = ?")
->execute([$defaultLimit, $obligationId]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/enroll] ' . $e->getMessage());
jsonError('تعذّر تفعيل محفظة الوقود حالياً');
}
error_log("[fuel] فُعّلت محفظة الوقود للسائق $driverId بسقف $defaultLimit");
jsonSuccess([
'obligation_id' => $obligationId,
'credit_limit' => $defaultLimit,
'available' => $defaultLimit,
'currency' => $product['currency'],
], 'تم تفعيل محفظة الوقود');
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* fuel/functions.php — منطق محفظة الوقود المشترك
* ─────────────────────────────────────────────────────────────
* ‏محفظة الوقود منتج `drawdown` في محرك الالتزامات: لا دورة فوترة له،
* ‏والاستحقاق يُقيَّد لحظة الصرف من المحطة لا على جدول زمني.
*
* ‏كل ما يخصّ المال هنا يقتصر على **قيد الدَّين**. الخصم من رصيد السائق
* ‏يبقى حكراً على محرك التسوية، كما في التأمين.
*/
require_once __DIR__ . '/../obligations/functions.php';
const FUEL_PRODUCT_CODE = 'fuel_wallet';
const FUEL_VOUCHER_TTL_MIN = 45;
/**
* ‏التزام الوقود النشط للسائق، أو null.
*/
function fuelObligation(PDO $con, string $driverId): ?array
{
$st = $con->prepare("
SELECT o.*, p.currency, p.daily_cap_percent
FROM driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
LIMIT 1
");
$st->execute([$driverId, FUEL_PRODUCT_CODE]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يُنهي صلاحية القسائم التي مضى وقتها.
*
* ‏يُستدعى قبل أي حساب سقف لا في كرون: القسيمة المنتهية تحجز من سقف
* ‏السائق، وتأخير تحريرها ساعةً كاملةً حتى يمرّ الكرون يعني منعه من
* ‏التزوّد بسبب قسيمة ماتت. التنظيف عند القراءة يجعل الرقم صادقاً دائماً.
*/
function fuelExpireStaleVouchers(PDO $con, string $driverId): void
{
$con->prepare("
UPDATE fuel_vouchers
SET status = 'expired'
WHERE driver_id = ? AND status = 'issued' AND expires_at < NOW()
")->execute([$driverId]);
}
/**
* ‏حالة سقف السائق: المستخدَم، المحجوز، والمتاح.
*
* ‏المحجوز يُطرح كالمستخدَم تماماً: قسيمة أُصدرت ولم تُصرف بعد قد تُصرف
* ‏بعد لحظة، والسماح بإصدار قسائم بمجموع يتجاوز السقف يعني سائقاً
* ‏يتزوّد بضعف ما يستحق ثم لا نجد ما نخصم منه.
*
* @return array{limit: float, outstanding: float, held: float, available: float}
*/
function fuelCreditState(PDO $con, array $obligation): array
{
$obligationId = (int) $obligation['id'];
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE obligation_id = ? AND status IN ('pending', 'partial')
");
$st->execute([$obligationId]);
$outstanding = (float) $st->fetchColumn();
$st = $con->prepare("
SELECT COALESCE(SUM(amount_authorized), 0)
FROM fuel_vouchers
WHERE obligation_id = ? AND status = 'issued' AND expires_at >= NOW()
");
$st->execute([$obligationId]);
$held = (float) $st->fetchColumn();
$limit = (float) $obligation['credit_limit'];
$available = round($limit - $outstanding - $held, 3);
return [
'limit' => $limit,
'outstanding' => round($outstanding, 3),
'held' => round($held, 3),
'available' => max($available, 0.0),
];
}
/**
* ‏يولّد رمز قسيمة غير متوقَّع.
*
* ‏`random_int` لا `rand`: الرمز مفتاحُ صرفٍ فعلي، ومولّد عشوائية قابل
* ‏للتنبّؤ يعني أن من يعرف رمزاً واحداً قد يخمّن التالي ويتزوّد على
* ‏حساب سائق آخر. عشرة أرقام تُبقي المجال واسعاً والرقم قابلاً للإملاء
* ‏على عامل المحطة هاتفياً.
*/
function fuelGenerateCode(PDO $con): string
{
for ($i = 0; $i < 10; $i++) {
$code = (string) random_int(1000000000, 9999999999);
$st = $con->prepare("SELECT 1 FROM fuel_vouchers WHERE code = ? LIMIT 1");
$st->execute([$code]);
if (!$st->fetchColumn()) {
return $code;
}
}
// ‏عشر محاولات فاشلة على مجال من تسعة مليارات ليست صدفة — هي عطل
// ‏(قاعدة ممتلئة أو مولّد معطوب). الاستمرار هنا يعني رمزاً مكرّراً.
throw new RuntimeException('تعذّر توليد رمز قسيمة فريد');
}
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* fuel/get.php — حالة محفظة الوقود للسائق
*
* ‏السقف، المستخدَم، المحجوز، والمتاح — ثم آخر الحركات. الأرقام الأربعة
* ‏معاً لا المتاح وحده: السائق الذي يرى «المتاح ٥» بلا تفسير يظنّها
* ‏عطلاً، والذي يرى «سقفك ٣٠، عليك ٢٠، محجوز ٥» يفهم ويتصرّف.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonSuccess([
'enrolled' => false,
'credit' => null,
'voucher' => null,
'entries' => [],
], 'محفظة الوقود غير مفعّلة');
}
fuelExpireStaleVouchers($con, $driverId);
$state = fuelCreditState($con, $obligation);
$voucher = null;
$entries = [];
try {
$st = $con->prepare("
SELECT id, code, amount_authorized, currency, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
$voucher = [
'voucher_id' => (int) $row['id'],
'code' => $row['code'],
'amount' => (float) $row['amount_authorized'],
'expires_at' => $row['expires_at'],
];
}
// ‏آخر عشرين حركة: كافية لشهر تزوّد، وتمنع رداً ينمو بلا حد.
$st = $con->prepare("
SELECT charge_date, amount, amount_collected, amount_remaining,
currency, status, note
FROM obligation_ledger
WHERE obligation_id = ?
ORDER BY id DESC LIMIT 20
");
$st->execute([(int) $obligation['id']]);
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/get] تعذّرت قراءة الحالة: ' . $e->getMessage());
}
jsonSuccess([
'enrolled' => true,
'credit' => [
'limit' => $state['limit'],
'outstanding' => $state['outstanding'],
'held' => $state['held'],
'available' => $state['available'],
'currency' => $obligation['currency'],
// ‏نسبة الاقتطاع اليومي معروضة صراحةً: الخصم من الأرباح يجب ألّا
// ‏يكون مفاجأة، والسائق الذي يعرف أن الاقتطاع ١٥٪ يخطّط عليه.
'daily_cap_percent' => (float) $obligation['daily_cap_percent'],
],
'voucher' => $voucher,
'entries' => $entries,
], 'success');
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* fuel/redeem.php — تأكيد المحطة صرف القسيمة
* ─────────────────────────────────────────────────────────────
* ‏هذه هي **نقطة قيد الاستحقاق** لمنتج الوقود: اللحظة التي يتحوّل فيها
* ‏وقودٌ في الخزّان إلى دَين في الدفتر.
*
* ‏تُنادى من نظام المحطة لا من تطبيق السائق. السائق طرف مستفيد، وجعل
* ‏تأكيد الصرف بيده يعني أن ينفي ما استلمه.
*
* ‏لا تخصم شيئاً من رصيد أحد — تقيّد استحقاقاً، ويحصّله محرك التسوية
* ‏بالسقف اليومي. نفس الفصل الذي يحكم التأمين.
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
error_log('[fuel/redeem] DB unavailable: ' . $e->getMessage());
jsonError('Server error', 500);
}
// ‏مصادقة المحطة بمفتاحها الخاص لا بمفتاح النظام المشترك: المفاتيح
// ‏موزّعة على أطراف خارجية، وتسريب مفتاح محطة يجب أن يكشف تلك المحطة
// ‏وحدها ويُبطَل بصفّ واحد.
$stationKey = $_SERVER['HTTP_X_STATION_KEY'] ?? '';
if (empty($stationKey)) {
jsonError('Unauthorized', 401);
}
$st = $con->prepare("
SELECT id, name_ar, discount_percent
FROM fuel_stations
WHERE api_key_hash = ? AND is_active = 1 LIMIT 1
");
$st->execute([hash('sha256', $stationKey)]);
$station = $st->fetch(PDO::FETCH_ASSOC);
if (!$station) {
jsonError('Unauthorized', 401);
}
$code = filterRequest('code');
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if (empty($code) || $amount <= 0) {
jsonError('code و amount مطلوبان');
}
try {
$con->beginTransaction();
// ‏القفل ضروري لا احتياطي: طلبان متزامنان بالرمز نفسه (ضغطة مكرّرة
// ‏على جهاز المحطة، أو محاولة صرف مزدوج) كانا سيمرّان كلاهما فيُقيَّد
// ‏الدَّين مرتين على وقود صُرف مرة.
$st = $con->prepare("
SELECT v.*, o.driver_id AS o_driver
FROM fuel_vouchers v
JOIN driver_obligations o ON o.id = v.obligation_id
WHERE v.code = ? FOR UPDATE
");
$st->execute([$code]);
$voucher = $st->fetch(PDO::FETCH_ASSOC);
if (!$voucher) {
$con->rollBack();
jsonError('رمز غير معروف');
}
if ($voucher['status'] !== 'issued') {
$con->rollBack();
// ‏رسالة صريحة لا عامة: عامل المحطة يحتاج أن يعرف إن كانت صُرفت
// ‏سابقاً أم انتهت صلاحيتها، وإلا أعاد المحاولة أمام سائق ينتظر.
jsonError('القسيمة ' . match ($voucher['status']) {
'redeemed' => 'مصروفة سابقاً',
'expired' => 'منتهية الصلاحية',
'cancelled' => 'ملغاة',
default => 'غير قابلة للصرف',
});
}
if (strtotime($voucher['expires_at']) < time()) {
$con->prepare("UPDATE fuel_vouchers SET status = 'expired' WHERE id = ?")
->execute([(int) $voucher['id']]);
$con->commit();
jsonError('القسيمة منتهية الصلاحية');
}
// ‏الصرف لا يتجاوز المأذون. تجاوزه يعني ديناً لم يوافق عليه السائق
// ‏ولم يُحجز له سقف — أي خرقاً للسقف من باب خلفي.
if ($amount > (float) $voucher['amount_authorized']) {
$con->rollBack();
jsonError(
'المبلغ يتجاوز المأذون به في القسيمة ('
. (float) $voucher['amount_authorized'] . ')'
);
}
$con->prepare("
UPDATE fuel_vouchers
SET status = 'redeemed', station_id = ?, amount_redeemed = ?,
discount_percent = ?, redeemed_at = NOW()
WHERE id = ?
")->execute([
(int) $station['id'], $amount,
(float) $station['discount_percent'], (int) $voucher['id'],
]);
// ‏قيد الاستحقاق. `source_ref` رقم القسيمة — وهو ما يسمح بأكثر من
// ‏سحبة في اليوم الواحد (المفتاح الفريد يشمله)، ويجعل إعادة النداء
// ‏بالرمز نفسه غير قادرة على مضاعفة الدَّين.
$con->prepare("
INSERT INTO obligation_ledger
(obligation_id, driver_id, product_code, charge_date, source_ref,
amount, amount_collected, amount_remaining, currency, status, note)
VALUES (?, ?, ?, CURDATE(), ?, ?, 0, ?, ?, 'pending', ?)
")->execute([
(int) $voucher['obligation_id'], $voucher['driver_id'], FUEL_PRODUCT_CODE,
'voucher_' . (int) $voucher['id'],
$amount, $amount, $voucher['currency'],
mb_substr('تزوّد من ' . $station['name_ar'], 0, 255),
]);
$con->commit();
} catch (PDOException $e) {
if ($con->inTransaction()) $con->rollBack();
// ‏23000 على المفتاح الفريد يعني أن هذه القسيمة قُيّدت فعلاً — نداء
// ‏مكرّر لا خطأ. القسيمة مصروفة والدَّين مقيَّد مرة واحدة.
if ($e->getCode() === '23000') {
jsonSuccess(['code' => $code, 'duplicate' => true], 'القسيمة مصروفة سابقاً');
}
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
error_log('[fuel/redeem] ' . $e->getMessage());
jsonError('تعذّر إتمام الصرف', 500);
}
error_log("[fuel] صُرفت القسيمة {$code} في محطة #{$station['id']} بقيمة $amount");
jsonSuccess([
'code' => $code,
'amount' => $amount,
'currency' => $voucher['currency'],
'station' => $station['name_ar'],
'discount_percent' => (float) $station['discount_percent'],
], 'تم الصرف');
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* fuel/request_voucher.php — إصدار قسيمة تزوّد
*
* ‏السائق يطلب مبلغاً فيحصل على رمز يعطيه لعامل المحطة. لا دَين بعد —
* ‏القسيمة حجزٌ من السقف فقط، والدَّين يُقيَّد عند تأكيد المحطة الصرف.
*
* ‏لماذا قسيمة مسبقة لا خصم عند المضخة؟ لأن عامل المحطة لا يملك وسيلة
* ‏للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى تطبيق
* ‏السائق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا
* ‏تأكيد رقم.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
if ($amount <= 0) {
jsonError('حدّد مبلغ التزوّد');
}
$obligation = fuelObligation($con, $driverId);
if (!$obligation) {
jsonError('فعّل محفظة الوقود أولاً');
}
// ‏تحرير المنتهي قبل الحساب: بدونه يبقى المبلغ محجوزاً بقسيمة ماتت،
// ‏فيُمنع السائق من التزوّد بسبب طلب نسيه قبل ساعة.
fuelExpireStaleVouchers($con, $driverId);
// ‏قسيمة مفتوحة واحدة في كل وقت. قسيمتان تعنيان رمزين في يد السائق،
// ‏وخطأ عامل المحطة في اختيار أحدهما يصرف المبلغ الخطأ — والأهم أن
// ‏السائق لا يحتاج أكثر من واحدة أصلاً.
$st = $con->prepare("
SELECT id, code, amount_authorized, expires_at
FROM fuel_vouchers
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
LIMIT 1
");
$st->execute([$driverId]);
if ($open = $st->fetch(PDO::FETCH_ASSOC)) {
jsonError(
'لديك قسيمة سارية بالرمز ' . $open['code'] . ' بقيمة '
. (float) $open['amount_authorized'] . '. استخدمها أو ألغِها أولاً.'
);
}
$state = fuelCreditState($con, $obligation);
if ($amount > $state['available']) {
jsonError(
'المتاح في محفظة وقودك ' . $state['available'] . ' ' . $obligation['currency']
. '. سدّد المستحق عليك ليعود سقفك.'
);
}
try {
$code = fuelGenerateCode($con);
$con->prepare("
INSERT INTO fuel_vouchers
(obligation_id, driver_id, code, amount_authorized, currency, status, expires_at)
VALUES (?, ?, ?, ?, ?, 'issued', DATE_ADD(NOW(), INTERVAL ? MINUTE))
")->execute([
(int) $obligation['id'], $driverId, $code, $amount,
$obligation['currency'], FUEL_VOUCHER_TTL_MIN,
]);
$voucherId = (int) $con->lastInsertId();
} catch (Throwable $e) {
error_log('[fuel/request_voucher] ' . $e->getMessage());
jsonError('تعذّر إصدار القسيمة حالياً');
}
error_log("[fuel] قسيمة #$voucherId للسائق $driverId بقيمة $amount");
jsonSuccess([
'voucher_id' => $voucherId,
'code' => $code,
'amount' => $amount,
'currency' => $obligation['currency'],
'expires_in_min' => FUEL_VOUCHER_TTL_MIN,
'available_after' => round($state['available'] - $amount, 3),
], 'أعطِ هذا الرمز لعامل المحطة');
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* fuel/stations.php — المحطات الشريكة
*
* ‏قائمة للسائق، مرتّبة بالقرب إن أرسل موقعه. لا يُرجع مفاتيح المحطات
* ‏ولا أي حقل تشغيلي — القائمة تُعرض في تطبيق السائق، وكل ما يخرج إليه
* ‏يجب افتراض أنه علنيّ.
*/
require_once __DIR__ . '/../connect.php';
require_once __DIR__ . '/functions.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$lat = filterRequest('lat', 'numeric');
$lng = filterRequest('lng', 'numeric');
try {
if ($lat !== null && $lng !== null) {
// ‏مسافة تقريبية بفرق الإحداثيات لا Haversine: الغرض ترتيب قائمة
// ‏محطات في مدينة واحدة، والفارق بين الطريقتين على هذا النطاق
// ‏لا يغيّر ترتيباً — بينما الحساب المثلثي على كل صف يكلّف بلا مقابل.
$st = $con->prepare("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent,
(POW(lat - ?, 2) + POW(lng - ?, 2)) AS dist
FROM fuel_stations
WHERE is_active = 1
ORDER BY (lat IS NULL), dist ASC
LIMIT 50
");
$st->execute([(float) $lat, (float) $lng]);
} else {
$st = $con->query("
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
discount_percent
FROM fuel_stations
WHERE is_active = 1
ORDER BY city ASC, name_ar ASC
LIMIT 50
");
}
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[fuel/stations] ' . $e->getMessage());
jsonError('Server error');
}
$out = array_map(static fn(array $r): array => [
'id' => (int) $r['id'],
'name' => $r['name_ar'],
'chain' => $r['chain_name'],
'city' => $r['city'],
'address' => $r['address'],
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
'phone' => $r['phone'],
'discount_percent' => (float) $r['discount_percent'],
], $rows);
jsonSuccess(['stations' => $out], 'success');
@@ -0,0 +1,239 @@
-- ============================================================
-- محرك الالتزامات العام — بند 3.4 من دراسة الفرص (اقتصاد السائق)
--
-- ‏سبب وجود هذا الملف ليس التوسّع المستقبلي، بل ثغرة قائمة في الإنتاج:
-- ‏`cron_insurance_premiums.php` يقيّد الأقساط في `insurance_premium_ledger`
-- ‏منذ إطلاقه، وتعليقه يقول «تقرأه التسوية» — والتسوية غير موجودة. لا
-- ‏مرجع واحد لذلك الجدول في المشروع كله خارج الكرون والـmigration. أي أن
-- ‏كل قسط تأمين قُيّد حتى اليوم ما زال `pending` ولم يُحصَّل قرشٌ منه.
--
-- ‏فالخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة.
-- ‏والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق —
-- ‏كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة
-- ‏تسوية لكل واحد منها تعني أربع نسخ من أخطر منطق في المنصة: المنطق
-- ‏الذي يلمس مال السائق.
--
-- ‏الفصل المعتمد:
-- • مُصدِر استحقاق لكل منتج → يقيّد «على السائق كذا»
-- • دفتر موحّد → سجل دائم واحد مهما كان المصدر
-- • محرك تسوية واحد → الجهة الوحيدة التي تلمس الرصيد
--
-- ‏جداول التأمين تبقى كما هي — لا تُحذف ولا تُعدَّل. تُنقل بياناتها هنا
-- ‏في نهاية هذا الملف، وتبقى الأصلية شاهداً تاريخياً.
-- ============================================================
-- ── ١) المنتجات ─────────────────────────────────────────────
-- ‏يعمّم `insurance_plans`. الفرق الجوهري عن الخطة: `kind` يحدّد سلوك
-- ‏الاستحقاق نفسه — الدوري يتكرّر بلا نهاية (تأمين)، والمقسّط له أصل
-- ‏محدود ينتهي بسداده (صيانة، تمويل)، والمسحوب يُقيَّد عند السحب لا
-- ‏على جدول (وقود).
CREATE TABLE IF NOT EXISTS `obligation_products` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(60) NOT NULL COMMENT 'معرّف ثابت يُستعمل في الكود',
`kind` ENUM('recurring','installment','drawdown') NOT NULL,
`name_ar` VARCHAR(160) NOT NULL,
`description_ar` TEXT DEFAULT NULL,
-- ‏الشريك الخارجي: شركة تأمين، سلسلة محطات، ورشة، أو بنك. عمود نصّي
-- ‏لا جدول: لا نعرف بعد ما إذا كانت لهذه الجهات دورة حياة تستحق
-- ‏جدولاً، وجدول فارغ الغرض أسوأ من عمود.
`partner_name` VARCHAR(160) DEFAULT NULL,
`billing_cycle` ENUM('daily','monthly','none') NOT NULL DEFAULT 'daily'
COMMENT 'none للمنتجات المسحوبة — لا دورة لها',
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
-- ── شروط الأهلية ──
-- ‏تُخزَّن مع المنتج لا في الكود: الشريك سيغيّرها، والسوق المصري يختلف
-- ‏عن الأردني، وتغيير رقم في صف أرخص من نشر إصدار. منقولة كما هي من
-- ‏`insurance_plans` لأن المنطق ذاته ينطبق على الوقود والصيانة: كلاهما
-- ‏ائتمان يُمنح لسائق قد يختفي.
`min_completed_rides` INT NOT NULL DEFAULT 200,
`min_rating` DECIMAL(3,2) NOT NULL DEFAULT 4.50,
`min_account_days` INT NOT NULL DEFAULT 30,
-- ‏سقف الخصم اليومي كنسبة من أرباح اليوم. على مستوى المنتج لا النظام:
-- ‏قسط التأمين الصغير يحتمل نسبة أعلى من قرض صيانة كبير، والسقف الموحّد
-- ‏يعني إمّا خنق السائق أو إبطاء التحصيل.
`daily_cap_percent` DECIMAL(5,2) NOT NULL DEFAULT 25.00,
-- ‏ترتيب المزاحمة حين تستحق التزامات عدة في يوم واحد. الأصغر أولاً:
-- ‏التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠) — انقطاع التأمين
-- ‏يُلغي وثيقةً ويفقد السائق تغطيته، بينما تأخّر قسط صيانة يوماً لا
-- ‏يكلّف أحداً شيئاً.
`priority` SMALLINT NOT NULL DEFAULT 50,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_code` (`code`),
KEY `idx_kind_active` (`kind`, `is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٢) التزامات السائقين ────────────────────────────────────
-- ‏يعمّم `driver_insurance_policies`: الاشتراك/العقد النشط بين سائق ومنتج.
CREATE TABLE IF NOT EXISTS `driver_obligations` (
`id` INT NOT NULL AUTO_INCREMENT,
`driver_id` VARCHAR(100) NOT NULL,
`product_id` INT NOT NULL,
`external_ref` VARCHAR(120) DEFAULT NULL COMMENT 'رقم الوثيقة/العقد لدى الشريك',
`status` ENUM('active','suspended','completed','cancelled')
NOT NULL DEFAULT 'active',
`started_at` DATE NOT NULL,
`ended_at` DATE DEFAULT NULL,
-- ‏مبلغ الدورة الواحدة. منسوخ من المنتج لحظة الاشتراك لا مقروءاً منه:
-- ‏رفع سعر الخطة غداً يجب ألّا يغيّر قسط من اشترك أمس بأثر رجعي.
`cycle_amount` DECIMAL(12,3) NOT NULL DEFAULT 0,
-- ‏للمقسّط فقط: الأصل وما سُدِّد منه. المنتج الدوري يتركهما صفراً —
-- ‏لا نهاية له فلا معنى لأصلٍ ينفد.
`principal_amount` DECIMAL(12,3) NOT NULL DEFAULT 0,
`principal_paid` DECIMAL(12,3) NOT NULL DEFAULT 0,
-- ‏لقطة الأهلية لحظة الاشتراك. بدونها لا يمكن الإجابة لاحقاً على
-- ‏«لماذا مُنح هذا السائق ائتماناً؟» حين ينخفض تقييمه أو تتغيّر الشروط.
`rides_at_signup` INT NOT NULL DEFAULT 0,
`rating_at_signup` DECIMAL(3,2) NOT NULL DEFAULT 0,
-- ‏آخر يوم قُيّد عنه استحقاق. هذا العمود — لا التاريخ الحالي — هو
-- ‏الفلتر الرخيص ضد الاحتساب المزدوج. الحارس الحقيقي هو المفتاح
-- ‏الفريد في الدفتر أدناه.
`last_charged_on` DATE DEFAULT NULL,
`cancel_reason` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_driver_status` (`driver_id`, `status`),
KEY `idx_charge_sweep` (`status`, `last_charged_on`),
CONSTRAINT `fk_obligation_product` FOREIGN KEY (`product_id`)
REFERENCES `obligation_products` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٣) الدفتر الموحّد ───────────────────────────────────────
-- ‏يعمّم `insurance_premium_ledger` بفارق واحد جوهري: `amount_collected`
-- ‏و`amount_remaining`. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود
-- ‏والصيانة فتحصيلهما جزئي بطبعه — سقف الخصم اليومي يعني أن قيداً بقيمة
-- ‏عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك
-- ‏إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي.
CREATE TABLE IF NOT EXISTS `obligation_ledger` (
`id` INT NOT NULL AUTO_INCREMENT,
`obligation_id` INT NOT NULL,
`driver_id` VARCHAR(100) NOT NULL,
`product_code` VARCHAR(60) NOT NULL COMMENT 'منسوخ للاستعلام بلا JOIN',
`charge_date` DATE NOT NULL COMMENT 'اليوم أو أول الشهر المحتسَب',
`amount` DECIMAL(12,3) NOT NULL COMMENT 'أصل الاستحقاق',
`amount_collected` DECIMAL(12,3) NOT NULL DEFAULT 0,
`amount_remaining` DECIMAL(12,3) NOT NULL COMMENT 'amount - amount_collected',
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
-- ‏partial ليست حالة عابرة بل مستقرّة: قيد حُصِّل بعضه ينتظر يوماً
-- ‏أفضل. waived للإعفاء الإداري — يُغلق القيد بلا مال، ويبقى أثره.
`status` ENUM('pending','partial','settled','waived','failed')
NOT NULL DEFAULT 'pending',
`attempts` SMALLINT NOT NULL DEFAULT 0,
`last_attempt_on` DATE DEFAULT NULL,
`settled_at` DATETIME DEFAULT NULL,
`note` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- ‏الحارس الحقيقي ضد الاحتساب المزدوج: استحقاق واحد لكل التزام في
-- ‏اليوم الواحد، مهما تكرّر تشغيل الكرون أو تزامنت نسختان منه.
UNIQUE KEY `uq_obligation_date` (`obligation_id`, `charge_date`),
-- ‏فهرس مسح التسوية: تمرّ على المعلّق والجزئي مرتّباً بأولوية المنتج.
KEY `idx_settlement_sweep` (`status`, `driver_id`),
KEY `idx_driver_date` (`driver_id`, `charge_date`),
CONSTRAINT `fk_ledger_obligation` FOREIGN KEY (`obligation_id`)
REFERENCES `driver_obligations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- ترحيل التأمين إلى النموذج العام
--
-- ‏يعمل هذا القسم أكثر من مرة بلا ضرر: كل إدراج مشروط بعدم وجود ما
-- ‏يقابله. سبب الحرص أن الترحيل يلمس التزامات مالية قائمة، وتشغيلاً
-- ‏ثانياً بلا حماية يعني ازدواج كل وثيقة وكل قسط.
-- ============================================================
-- ── خطط التأمين تصير منتجات ──
-- ‏`code` يُنسخ كما هو ليبقى المعرّف الثابت واحداً بين النموذجين.
-- ‏الأولوية ١٠: التأمين أوّل من يُحصَّل عند المزاحمة.
INSERT INTO `obligation_products`
(`code`, `kind`, `name_ar`, `description_ar`, `partner_name`, `billing_cycle`,
`currency`, `min_completed_rides`, `min_rating`, `min_account_days`,
`daily_cap_percent`, `priority`, `is_active`)
SELECT
pl.`code`, 'recurring', pl.`name_ar`, pl.`description_ar`,
pr.`name`, pl.`billing_cycle`, pl.`currency`,
pl.`min_completed_rides`, pl.`min_rating`, pl.`min_account_days`,
25.00, 10, pl.`is_active`
FROM `insurance_plans` pl
JOIN `insurance_providers` pr ON pr.`id` = pl.`provider_id`
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_products` op WHERE op.`code` = pl.`code`
);
-- ── الوثائق تصير التزامات ──
-- ‏`cycle_amount` يُنسخ من قسط الخطة لحظة الترحيل — نفس مبدأ تثبيت السعر
-- ‏الذي يحكم الاشتراكات الجديدة.
INSERT INTO `driver_obligations`
(`driver_id`, `product_id`, `external_ref`, `status`, `started_at`, `ended_at`,
`cycle_amount`, `rides_at_signup`, `rating_at_signup`, `last_charged_on`,
`cancel_reason`, `created_at`)
SELECT
p.`driver_id`, op.`id`, p.`policy_number`,
-- ‏'expired' في التأمين تقابل 'completed' هنا: النموذج العام لا يعرف
-- ‏انتهاء صلاحية، يعرف التزاماً بلغ نهايته.
CASE p.`status` WHEN 'expired' THEN 'completed' ELSE p.`status` END,
p.`started_at`, p.`ended_at`,
pl.`premium`, p.`rides_at_signup`, p.`rating_at_signup`, p.`last_charged_on`,
p.`cancel_reason`, p.`created_at`
FROM `driver_insurance_policies` p
JOIN `insurance_plans` pl ON pl.`id` = p.`plan_id`
JOIN `obligation_products` op ON op.`code` = pl.`code`
WHERE NOT EXISTS (
SELECT 1 FROM `driver_obligations` o
WHERE o.`driver_id` = p.`driver_id`
AND o.`product_id` = op.`id`
AND o.`started_at` = p.`started_at`
);
-- ── الأقساط المعلّقة تصير قيوداً ──
-- ‏هذه هي الغاية العملية من الترحيل كله: هذه الصفوف — أقساط حقيقية
-- ‏تراكمت في الإنتاج بلا تحصيل — تصير مرئية لمحرك التسوية.
--
-- ‏`settled` القديمة تُنقل أيضاً رغم أنها لن تُحصَّل: دفتر ناقص التاريخ
-- ‏لا يُسوّى مع شريك.
INSERT INTO `obligation_ledger`
(`obligation_id`, `driver_id`, `product_code`, `charge_date`,
`amount`, `amount_collected`, `amount_remaining`, `currency`,
`status`, `settled_at`, `note`, `created_at`)
SELECT
o.`id`, l.`driver_id`, op.`code`, l.`charge_date`,
l.`amount`,
CASE WHEN l.`status` = 'settled' THEN l.`amount` ELSE 0 END,
CASE WHEN l.`status` = 'settled' THEN 0 ELSE l.`amount` END,
l.`currency`,
-- ‏'failed' القديمة تعود 'pending': الفشل السابق لم يكن قراراً بل
-- ‏غياب محرك. حجبها عن التسوية الآن يعني إسقاط مال مستحق فعلاً.
CASE l.`status` WHEN 'failed' THEN 'pending' ELSE l.`status` END,
l.`settled_at`, l.`note`, l.`created_at`
FROM `insurance_premium_ledger` l
JOIN `driver_insurance_policies` p ON p.`id` = l.`policy_id`
JOIN `insurance_plans` pl ON pl.`id` = p.`plan_id`
JOIN `obligation_products` op ON op.`code` = pl.`code`
JOIN `driver_obligations` o ON o.`driver_id` = p.`driver_id`
AND o.`product_id` = op.`id`
AND o.`started_at` = p.`started_at`
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_ledger` ol
WHERE ol.`obligation_id` = o.`id` AND ol.`charge_date` = l.`charge_date`
);
@@ -0,0 +1,55 @@
-- ============================================================
-- بوابة الائتمان: لا التزام إلا لمن تمرّ أرباحه بالمحفظة
--
-- ‏المشكلة التي يعالجها هذا الملف (قرار المالك 2026-08-09):
--
-- ‏سقف الخصم اليومي في محرك الالتزامات نسبة من أرباح اليوم، وأرباح اليوم
-- ‏تُقاس من الصفوف الموجبة في `driverWallet`. سائق الكاش الذي يحصّل نقداً
-- ‏ولا تمرّ أرباحه بالمحفظة يظهر بأرباح صفر كل يوم — فسقفه صفر، فلا
-- ‏يُحصَّل منه شيء أبداً.
--
-- ‏هذا محتمَل في التأمين (قسط صغير متكرر، والخسارة محدودة ومرئية). وهو
-- ‏غير محتمَل إطلاقاً في الوقود والصيانة والتمويل: هناك تسلّم السائق
-- ‏قيمةً فعلية — وقوداً في خزّان سيارته أو إصلاحاً في ورشة — مقابل وعد
-- ‏بالسداد من قناة لا يمرّ بها ماله. هذا ليس ائتماناً بل هبة.
--
-- ‏الحل: شرط أهلية إضافي مخزَّن مع المنتج لا في الكود، على غرار شروط
-- ‏الرحلات والتقييم القائمة. العتبة تختلف بطبيعة المنتج: قسط تأمين يومي
-- ‏صغير لا يحتاج ما يحتاجه قرض صيانة.
-- ============================================================
ALTER TABLE `obligation_products`
-- ‏المفتاح العام. تركه قابلاً للإطفاء ليس تساهلاً: منتج مستقبلي قد
-- ‏يُدفع مقدَّماً أو يموّله طرف ثالث، فلا معنى لبوابة ائتمان عليه.
ADD COLUMN `requires_wallet_income` TINYINT(1) NOT NULL DEFAULT 1
COMMENT 'يشترط أن تمرّ أرباح السائق بالمحفظة' AFTER `min_account_days`,
-- ‏عدد الأيام المتمايزة التي دخل فيها مالٌ محفظةَ السائق خلال آخر
-- ‏ثلاثين يوماً. المقياس أيام لا مبلغ لأن السؤال هنا ليس «كم يكسب؟»
-- ‏بل «هل قناة التحصيل تعمل؟» — سائق دخله مرة واحدة كبيرة قد يكون
-- ‏استرداداً أو حادثة، وسائق دخله اثنا عشر يوماً متفرّقاً قناته حيّة.
ADD COLUMN `min_wallet_days_30d` SMALLINT NOT NULL DEFAULT 10
COMMENT 'أيام دخل في المحفظة خلال آخر 30 يوماً' AFTER `requires_wallet_income`,
-- ‏الحد الأدنى للدخل المار بالمحفظة في آخر ثلاثين يوماً. يُقاس بعملة
-- ‏المنتج. صفر يعني «لا شرط مبلغ» ويُكتفى بشرط الأيام.
ADD COLUMN `min_wallet_income_30d` DECIMAL(12,3) NOT NULL DEFAULT 0
COMMENT 'إجمالي الدخل المار بالمحفظة خلال آخر 30 يوماً' AFTER `min_wallet_days_30d`;
-- ── عتبة التأمين ────────────────────────────────────────────
-- ‏منتجات التأمين المرحَّلة تخضع للبوابة أيضاً — القسط الذي تتحمّله
-- ‏الشركة ثم تسترده من الأرباح ائتمانٌ بالمعنى نفسه.
--
-- ‏لكن العتبة متساهلة عمداً (ثمانية أيام، بلا شرط مبلغ): التأمين مكافأة
-- ‏استمرار مشروطة أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها
-- ‏يفرغ المنتج من غرضه.
--
-- ‏هذا يمسّ الاشتراكات **الجديدة** وحدها. الالتزامات القائمة لا تُفحص
-- ‏ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة
-- ‏بأثر رجعي.
UPDATE `obligation_products` op
JOIN `insurance_plans` pl ON pl.`code` = op.`code`
SET op.`requires_wallet_income` = 1,
op.`min_wallet_days_30d` = 8,
op.`min_wallet_income_30d` = 0;
@@ -0,0 +1,145 @@
-- ============================================================
-- محفظة الوقود — بند 3.4 من دراسة الفرص، الخطوة الثالثة
--
-- ‏الفكرة: اتفاق مع سلسلة محطات، السائق يتزوّد وقوداً بلا نقد، والقيمة
-- ‏تُقيَّد ديناً عليه يُخصم من أرباحه بالسقف اليومي نفسه. قيمة فورية
-- ‏ملموسة للسائق — الوقود مشكلته اليومية الأولى — وأقوى أثراً من أي
-- ‏زيادة عمولة، لأنها تحلّ نقص السيولة لا تزيد الرقم.
--
-- ‏هذا أول منتج من نوع `drawdown`: لا دورة فوترة له. الاستحقاق يُقيَّد
-- ‏لحظة السحب من المحطة لا على جدول زمني.
-- ============================================================
-- ── ١) تعدّد الاستحقاقات في اليوم الواحد ────────────────────
--
-- ‏المفتاح الفريد الحالي (obligation_id, charge_date) يمنع قيدين لالتزام
-- ‏واحد في يوم واحد — وهو بالضبط ما نريده للتأمين: قسط واحد في اليوم.
--
-- ‏لكنه يكسر الوقود: سائق يتزوّد مرتين في يوم واحد سيصطدم قيده الثاني
-- ‏بالمفتاح فيُرفض، فيحصل على وقود بلا دَين يقابله. اكتُشف هذا عند
-- ‏تصميم المُصدِر لا بعد تشغيله.
--
-- ‏الحل: `source_ref` يميّز السحبة. فارغاً للمنتجات الدورية — فيبقى
-- ‏ضمانها كما هو حرفياً — ومملوءاً برقم السحبة للوقود.
--
-- ‏سلسلة فارغة لا NULL: MySQL يعتبر كل NULL مختلفاً عن غيره في المفاتيح
-- ‏الفريدة، فعمود NULL كان سيلغي الحماية عن التأمين بصمت.
ALTER TABLE `obligation_ledger`
ADD COLUMN `source_ref` VARCHAR(80) NOT NULL DEFAULT ''
COMMENT 'مرجع الحدث المُنشئ — فارغ للدوري، رقم السحبة للوقود'
AFTER `charge_date`;
ALTER TABLE `obligation_ledger`
DROP INDEX `uq_obligation_date`,
ADD UNIQUE KEY `uq_obligation_event` (`obligation_id`, `charge_date`, `source_ref`);
-- ── ٢) سقف الائتمان ─────────────────────────────────────────
-- ‏`principal_amount` لا يصلح هنا: هو أصلٌ ينفد بالسداد (قرض صيانة)،
-- ‏بينما سقف الوقود متجدّد — يُسدَّد فيعود متاحاً، كبطاقة ائتمان.
ALTER TABLE `driver_obligations`
ADD COLUMN `credit_limit` DECIMAL(12,3) NOT NULL DEFAULT 0
COMMENT 'سقف متجدّد لمنتجات السحب (الوقود)' AFTER `principal_amount`;
-- ── ٣) المحطات الشريكة ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS `fuel_stations` (
`id` INT NOT NULL AUTO_INCREMENT,
`name_ar` VARCHAR(160) NOT NULL,
`chain_name` VARCHAR(120) DEFAULT NULL COMMENT 'اسم السلسلة إن كانت فرعاً',
`city` VARCHAR(80) DEFAULT NULL,
`address` VARCHAR(255) DEFAULT NULL,
`lat` DECIMAL(10,7) DEFAULT NULL,
`lng` DECIMAL(10,7) DEFAULT NULL,
`phone` VARCHAR(40) DEFAULT NULL,
-- ‏مفتاح المحطة مخزَّن مُهشَّراً لا نصّاً. المحطات أطراف خارجية وأجهزتها
-- ‏خارج سيطرتنا، وتسريب قاعدة البيانات يجب ألّا يسلّم مفاتيح تشغيلية
-- ‏لأحد. المفتاح الأصلي يُعطى للمحطة مرة واحدة عند الإنشاء.
`api_key_hash` CHAR(64) NOT NULL COMMENT 'sha256 لمفتاح المحطة',
-- ‏خصم الشريك عن سعر المضخة. يُسجَّل مع كل سحبة لأنه يتغيّر بالتفاوض،
-- ‏وحسابه لاحقاً بالنسبة الحالية يزوّر تسويات الماضي.
`discount_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_active_city` (`is_active`, `city`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٤) قسائم السحب ──────────────────────────────────────────
-- ‏دورة الحياة: issued → redeemed | expired | cancelled
--
-- ‏لماذا قسيمة مسبقة لا خصم مباشر عند المضخة؟ لأن عامل المحطة لا يملك
-- ‏وسيلة للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى
-- ‏تطبيق السائق (حيث الهوية مؤكَّدة والسقف معلوم)، ولا يبقى على المحطة
-- ‏إلا تأكيد رقم.
CREATE TABLE IF NOT EXISTS `fuel_vouchers` (
`id` INT NOT NULL AUTO_INCREMENT,
`obligation_id` INT NOT NULL,
`driver_id` VARCHAR(100) NOT NULL,
`station_id` INT DEFAULT NULL COMMENT 'يُملأ عند الصرف',
`code` VARCHAR(12) NOT NULL COMMENT 'الرقم الذي يعطيه السائق للمحطة',
-- ‏المبلغ المحجوز عند الإصدار، والمصروف فعلاً عند التأكيد. الفرق
-- ‏طبيعي: السائق يطلب عشرين ويتزوّد بخمسة عشر. الدَّين على المصروف.
`amount_authorized` DECIMAL(12,3) NOT NULL,
`amount_redeemed` DECIMAL(12,3) NOT NULL DEFAULT 0,
`discount_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
`currency` VARCHAR(10) NOT NULL DEFAULT 'JOD',
`status` ENUM('issued','redeemed','expired','cancelled')
NOT NULL DEFAULT 'issued',
-- ‏صلاحية قصيرة. قسيمة مفتوحة إلى الأبد تحجز من سقف السائق مبلغاً
-- ‏لن يُستعمل، فيجد نفسه ممنوعاً من التزوّد بسبب قسيمة نسيها.
`expires_at` DATETIME NOT NULL,
`redeemed_at` DATETIME DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
-- ‏الرمز فريد مطلقاً: عامل المحطة يُدخل رقماً بلا سياق، فتكراره ولو
-- ‏بين سائقين مختلفين يعني صرفاً على حساب الشخص الخطأ.
UNIQUE KEY `uq_code` (`code`),
KEY `idx_driver_status` (`driver_id`, `status`),
KEY `idx_open_vouchers` (`status`, `expires_at`),
CONSTRAINT `fk_voucher_obligation` FOREIGN KEY (`obligation_id`)
REFERENCES `driver_obligations` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_voucher_station` FOREIGN KEY (`station_id`)
REFERENCES `fuel_stations` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── ٥) منتج الوقود ──────────────────────────────────────────
-- ‏سقف الخصم اليومي ١٥٪ لا ٢٥٪ كالتأمين: دَين الوقود أكبر من قسط يومي،
-- ‏واقتطاع ربع الدخل لسداده يترك السائق بلا ما يكفي ليعمل غداً — فيعود
-- ‏محتاجاً وقوداً بالدَّين، وتلك حلقة لا تُغلق.
--
-- ‏بوابة الائتمان أشدّ من التأمين (١٢ يوماً مقابل ٨) لأن هنا قيمةً فعلية
-- ‏تُسلَّم في خزّان السيارة، لا تغطيةً احتمالية.
--
-- ‏**يُنشأ مُطفأً (`is_active = 0`) عمداً.** قرار المالك: تُبنى الشاشات
-- ‏وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة
-- ‏المحطات. المنتج المُطفأ يمنع السائقين من التفعيل والصرف، بينما تعمل
-- ‏الشاشة الإدارية بالكامل للتجهيز والعرض.
--
-- ‏التشغيل بصفّ واحد من الشاشة الإدارية (تبديل حالة المنتج) — لا بنشر
-- ‏إصدار جديد.
INSERT INTO `obligation_products`
(`code`, `kind`, `name_ar`, `description_ar`, `partner_name`, `billing_cycle`,
`currency`, `min_completed_rides`, `min_rating`, `min_account_days`,
`requires_wallet_income`, `min_wallet_days_30d`, `min_wallet_income_30d`,
`daily_cap_percent`, `priority`, `is_active`)
SELECT
'fuel_wallet', 'drawdown', 'محفظة الوقود',
'تزوّد بالوقود من المحطات الشريكة بلا نقد، والقيمة تُخصم من أرباحك تدريجياً بسقف يومي.',
NULL, 'none', 'JOD',
200, 4.50, 30,
1, 12, 0,
15.00, 20, 0
WHERE NOT EXISTS (
SELECT 1 FROM `obligation_products` WHERE `code` = 'fuel_wallet'
);
+234
View File
@@ -0,0 +1,234 @@
<?php
/**
* obligations/functions.php — دوال محرك الالتزامات المشتركة
* ─────────────────────────────────────────────────────────────
* ‏الواجهة التي تستعملها المنتجات (تأمين، وقود، صيانة) لفتح التزام
* ‏وإغلاقه وقراءة المستحق عليه. لا شيء هنا يلمس مالاً: الخصم حكرٌ على
* ‏`bot/cron_obligation_settlement.php` وحده.
*
* ‏سبب وجود هذا الملف أن `driver_assurance/` كان يكتب في جداوله الخاصة،
* ‏وبقاؤه كذلك بعد الترحيل كان يعني أن كل اشتراك **جديد** لا يظهر في
* ‏الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل
* ‏لمرة واحدة يعالج الماضي؛ هذه الدوال تمنع تكرار الثغرة في المستقبل.
*/
const OBLIGATION_HTTP_TIMEOUT = 15;
function obligationWalletServerUrl(): string
{
// ‏bootstrap.php يعرّف GLOBAL_COUNTRY ثابتاً ولا يضعه في البيئة —
// ‏استخدم الثابت لا getenv (نفس فخّ foodWalletServerUrl).
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
return match ($country) {
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
};
}
/**
* ‏نداء خادم-لخادم إلى سيرفر المحفظة. يُرجع الحمولة أو null عند الفشل.
*
* ‏printSuccess في سيرفر المحفظة يغلّف الحمولة في المفتاح "message" —
* ‏اصطلاحه لا خطأ تسمية هنا.
*/
function obligationWalletCall(string $path, array $params): ?array
{
$key = getenv('S2S_SHARED_KEY') ?: '';
if ($key === '') {
error_log('[obligation] S2S_SHARED_KEY غير معرّف');
return null;
}
$ch = curl_init(obligationWalletServerUrl() . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => OBLIGATION_HTTP_TIMEOUT,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . $key,
],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $code !== 200) {
error_log("[obligation] نداء المحفظة فشل ($path): HTTP $code $err");
return null;
}
$json = json_decode((string) $body, true);
if (!is_array($json) || ($json['status'] ?? '') !== 'success' || !is_array($json['message'] ?? null)) {
error_log("[obligation] رد غير متوقّع ($path): " . substr((string) $body, 0, 200));
return null;
}
return $json['message'];
}
/**
* ‏ملخّص دخل السائق المارّ بالمحفظة في آخر ثلاثين يوماً، أو null عند
* ‏تعذّر الوصول. يُقرأ من سيرفر المحفظة لأن الحركات تعيش هناك.
*/
function obligationWalletIncomeSummary(string $driverId): ?array
{
return obligationWalletCall(
'/v2/main/ride/driverWallet/income_summary_s2s.php',
['driverID' => $driverId, 'window_days' => 30]
);
}
/**
* ‏بوابة الائتمان: هل تمرّ أرباح هذا السائق بالمحفظة بما يكفي؟
*
* ‏قرار المالك (2026-08-09): لا التزام ائتماني لمن لا تمرّ أرباحه
* ‏بالمحفظة. السبب أن سقف الخصم اليومي نسبة من الدخل المارّ بها، فسائق
* ‏الكاش سقفه صفر دائماً ولا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو
* ‏صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها.
*
* ‏الفشل مغلق: تعذُّر الوصول إلى سيرفر المحفظة يعني **رفضاً** لا قبولاً.
* ‏عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد
* ‏لا يُسترد منه — والخطأ الثاني وحده هو الذي يكلّف مالاً.
*
* ‏`$summary` يُمرَّر جاهزاً حين تُفحص عدة منتجات لسائق واحد (شاشة
* ‏الخطط): الملخّص عن السائق لا عن المنتج، ونداء لكل خطة يعني أربعة
* ‏نداءات شبكية لبناء شاشة واحدة.
*
* @return array{eligible: bool, reasons: string[], summary: array|null}
*/
function obligationCheckWalletIncome(array $product, string $driverId, ?array $summary = null): array
{
if ((int) ($product['requires_wallet_income'] ?? 1) !== 1) {
return ['eligible' => true, 'reasons' => [], 'summary' => null];
}
$minDays = (int) ($product['min_wallet_days_30d'] ?? 0);
$minIncome = (float) ($product['min_wallet_income_30d'] ?? 0);
$summary ??= obligationWalletIncomeSummary($driverId);
if ($summary === null) {
return [
'eligible' => false,
'reasons' => ['تعذّر التحقق من حركة محفظتك حالياً. حاول لاحقاً.'],
'summary' => null,
];
}
$days = (int) ($summary['days_with_income'] ?? 0);
$income = (float) ($summary['total_income'] ?? 0);
$reasons = [];
if ($days < $minDays) {
$reasons[] = "أرباحك مرّت بالمحفظة في $days يوماً خلال آخر ٣٠ يوماً، والمطلوب $minDays";
}
if ($minIncome > 0 && $income < $minIncome) {
$reasons[] = 'إجمالي ما مرّ بمحفظتك خلال آخر ٣٠ يوماً أقل من الحد المطلوب';
}
return [
'eligible' => empty($reasons),
'reasons' => $reasons,
'summary' => $summary,
];
}
/**
* ‏يقرأ منتج التزام برمزه. يُرجع null إن لم يوجد أو كان مُطفأً.
*/
function obligationProduct(PDO $con, string $productCode): ?array
{
$st = $con->prepare("
SELECT * FROM obligation_products WHERE code = ? AND is_active = 1 LIMIT 1
");
$st->execute([$productCode]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يفتح التزاماً لسائق على منتج، ويُرجع معرّفه.
*
* ‏`cycle_amount` يُنسخ من المنتج لحظة الفتح ولا يُقرأ منه لاحقاً: رفع
* ‏سعر المنتج غداً يجب ألّا يغيّر قسط من اشترك اليوم بأثر رجعي.
*/
function obligationOpen(
PDO $con,
string $driverId,
string $productCode,
array $snapshot = [],
float $principal = 0.0,
?string $externalRef = null
): int {
$product = obligationProduct($con, $productCode);
if (!$product) {
throw new RuntimeException("منتج الالتزام غير موجود أو غير مفعّل: $productCode");
}
// ‏البوابة تُفحص هنا أيضاً لا في نقطة الاستدعاء وحدها. النقطة قد
// ‏تفحص وتنسى، أو يُضاف منتج جديد بنقطة جديدة تُغفلها — والفحص عند
// ‏الكتابة هو الوحيد الذي لا يمكن الالتفاف عليه بالسهو.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
throw new RuntimeException('بوابة الائتمان: ' . implode('، ', $gate['reasons']));
}
$con->prepare("
INSERT INTO driver_obligations
(driver_id, product_id, external_ref, status, started_at,
cycle_amount, principal_amount, rides_at_signup, rating_at_signup)
VALUES (?, ?, ?, 'active', CURDATE(), ?, ?, ?, ?)
")->execute([
$driverId,
(int) $product['id'],
$externalRef,
(float) ($snapshot['cycle_amount'] ?? 0),
$principal,
(int) ($snapshot['rides'] ?? 0),
(float) ($snapshot['rating'] ?? 0),
]);
return (int) $con->lastInsertId();
}
/**
* ‏يُغلق التزاماً نشطاً. لا يمسّ القيود المستحقّة — تصفيرها عند الإلغاء
* ‏كانت ستجعل الإلغاء وسيلةً للتهرّب من أيام استفاد منها السائق فعلاً.
*/
function obligationClose(PDO $con, string $driverId, string $productCode, string $reason): bool
{
$st = $con->prepare("
UPDATE driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
SET o.status = 'cancelled', o.ended_at = CURDATE(), o.cancel_reason = ?
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
");
$st->execute([mb_substr($reason, 0, 255), $driverId, $productCode]);
return $st->rowCount() > 0;
}
/**
* ‏مجموع ما بقي على السائق في منتج ما.
*
* ‏الأساس `amount_remaining` لا `amount`: قيدٌ حُصِّل نصفه لم يعد مستحقاً
* ‏بكامله، وعرض الأصل للسائق بعد اقتطاعٍ منه مطالبةٌ بما دُفع.
*/
function obligationPendingTotal(PDO $con, string $driverId, string $productCode): float
{
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
AND status IN ('pending', 'partial')
");
$st->execute([$driverId, $productCode]);
return (float) $st->fetchColumn();
}
+210
View File
@@ -0,0 +1,210 @@
<?php
/**
* ride/earnings/summary.php — ملخّص أرباح السائق من نقطة واحدة
* ─────────────────────────────────────────────────────────────
* ‏كانت شاشة الأرباح تبني نفسها من أربعة نداءات مختلفة، لكلٍّ منها لحظة
* ‏وصول ومنطق تجميع مستقلّان. النتيجة أن السائق يرى أرقاماً لا تتفق —
* ‏والسائق الذي لا يثق برقم أرباحه لا يثق بالمنصة كلها.
*
* ‏هذه النقطة تُرجع كل شيء بختم زمني واحد.
*
* ‏**تعريف مقصود:** «الأرباح» هنا من الرحلات لا من المحفظة. سؤال «كم
* ‏كسبت؟» غير سؤال «كم رصيدي؟»: الأول عن العمل، والثاني عن المال المتاح
* ‏بعد السحب والشحن والاقتطاعات. خلطهما هو ما أنتج الرقمين المتناقضين.
* ‏الرصيد يبقى في شاشة المحفظة، ويُشار إليه هنا بالاقتطاعات فقط.
*
* ‏خطآن في `gamification/getWeeklyAggregate.php` عولجا هنا:
*
* ١. `status = 'Finished'` وحدها. الحالة مكتوبة بحرفين مختلفين في
* ‏الجدول تاريخياً ('Finished' و'finished') — كما وثّق
* ‏`driver_assurance/eligibility.php` — فكان نصف رحلات السائق
* ‏تقريباً يسقط من أرباحه المعروضة.
*
* ٢. `TIMESTAMPDIFF(MINUTE, r.time, r.endtime)` على عمودَي TIME.
* ‏رحلة تبدأ 23:40 وتنتهي 00:10 تُحسب بـ**سالب** ٢٣ ساعة ونصف،
* ‏فتأكل ساعات يوم كامل. نستعمل `rideTimeStart`/`rideTimeFinish`
* ‏وهما timestamps كاملة.
*/
require_once __DIR__ . '/../../connect.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
// ‏الحالتان معاً في كل استعلام أدناه. ثابت واحد يمنع أن يُصحَّح أحدها
// ‏ويُنسى الآخر.
const EARNINGS_FINISHED = "r.status IN ('Finished', 'finished')";
/**
* ‏مجاميع فترة واحدة.
*
* ‏`price_for_driver` هو حصة السائق المثبَّتة لحظة عرض السعر — وهو ما
* ‏وُعد به قبل القبول. استعماله (لا اشتقاق نسبة العمولة الآن) يضمن أن
* ‏ما يراه في شاشة الأرباح هو ما رآه في شاشة الطلب.
*/
function earningsPeriod(PDO $con, string $driverId, string $dateCondition): array
{
$sql = "
SELECT
COUNT(r.id) AS trips,
COALESCE(SUM(r.price_for_driver), 0) AS net,
COALESCE(SUM(r.price), 0) AS gross,
COALESCE(SUM(r.distance), 0) AS distance,
COALESCE(SUM(
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0)
), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND " . EARNINGS_FINISHED . "
AND $dateCondition
";
$st = $con->prepare($sql);
$st->execute([$driverId]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$net = round((float) ($row['net'] ?? 0), 2);
$gross = round((float) ($row['gross'] ?? 0), 2);
return [
'trips' => (int) ($row['trips'] ?? 0),
'net' => $net,
'gross' => $gross,
// ‏العمولة تُعرض صراحةً لا تُطرح بصمت. السائق الذي يرى «كسبتَ ٢٠،
// ‏عمولة المنصة ٣، لك ١٧» يفهم؛ والذي يرى ١٧ وحدها يخمّن ويشكّ.
'commission' => round(max($gross - $net, 0), 2),
'distance' => round((float) ($row['distance'] ?? 0), 1),
'minutes' => (int) ($row['minutes'] ?? 0),
];
}
try {
$today = earningsPeriod($con, $driverId, 'DATE(r.created_at) = CURDATE()');
$week = earningsPeriod($con, $driverId, 'r.created_at >= DATE(NOW()) - INTERVAL 6 DAY');
$month = earningsPeriod($con, $driverId, 'r.created_at >= DATE_FORMAT(NOW(), "%Y-%m-01")');
// ‏الأسبوع السابق للمقارنة. المقارنة بالذات لا بالسائقين الآخرين:
// ‏«أعلى من أسبوعك الماضي» تحفّز الجميع، بينما ترتيب السائقين يُحبط
// ‏أكثرهم — ومن يحتاج التحفيز أكثر هو من سيقع في أسفل الترتيب.
$prevWeek = earningsPeriod(
$con,
$driverId,
'r.created_at >= DATE(NOW()) - INTERVAL 13 DAY AND r.created_at < DATE(NOW()) - INTERVAL 6 DAY'
);
$trend = null;
if ($prevWeek['net'] > 0) {
$trend = round((($week['net'] - $prevWeek['net']) / $prevWeek['net']) * 100, 1);
}
// ── السلسلة اليومية للرسم البياني ──
$st = $con->prepare("
SELECT DATE(r.created_at) AS day,
COUNT(r.id) AS trips,
COALESCE(SUM(r.price_for_driver), 0) AS net,
COALESCE(SUM(
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0)
), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND " . EARNINGS_FINISHED . "
AND r.created_at >= DATE(NOW()) - INTERVAL 6 DAY
GROUP BY DATE(r.created_at)
ORDER BY DATE(r.created_at) ASC
");
$st->execute([$driverId]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
// ‏الأيام الفارغة تُملأ بأصفار هنا لا في التطبيق: رسم بياني ينقصه يوم
// ‏يُزيح باقي الأعمدة فيقرأ السائق الثلاثاء مكان الأربعاء.
$byDay = [];
foreach ($rows as $r) {
$byDay[$r['day']] = $r;
}
$daily = [];
for ($i = 6; $i >= 0; $i--) {
$day = date('Y-m-d', strtotime("-$i day"));
$r = $byDay[$day] ?? null;
$daily[] = [
'day' => $day,
'trips' => (int) ($r['trips'] ?? 0),
'net' => round((float) ($r['net'] ?? 0), 2),
'minutes' => (int) ($r['minutes'] ?? 0),
];
}
$best = null;
foreach ($daily as $d) {
if ($best === null || $d['net'] > $best['net']) {
$best = $d;
}
}
} catch (PDOException $e) {
error_log('[earnings/summary] ' . $e->getMessage());
jsonError('تعذّر حساب الأرباح حالياً', 500);
}
// ── الاقتطاعات ──
// ‏تُعرض في شاشة الأرباح لا في شاشة المنتج وحدها. بعد محرك الالتزامات
// ‏صار يُخصم من السائق قسط تأمين ودَين وقود، واقتطاعٌ لا يظهر بجانب
// ‏الأرباح التي اقتُطع منها يُقرأ سرقةً مهما كان مشروعاً.
$deductions = ['today' => 0.0, 'week' => 0.0, 'month' => 0.0, 'outstanding' => 0.0, 'items' => []];
try {
$st = $con->prepare("
SELECT
COALESCE(SUM(CASE WHEN DATE(updated_at) = CURDATE() THEN amount_collected ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN updated_at >= DATE(NOW()) - INTERVAL 6 DAY THEN amount_collected ELSE 0 END), 0) AS week,
COALESCE(SUM(CASE WHEN updated_at >= DATE_FORMAT(NOW(), '%Y-%m-01') THEN amount_collected ELSE 0 END), 0) AS month,
COALESCE(SUM(amount_remaining), 0) AS outstanding
FROM obligation_ledger
WHERE driver_id = ?
");
$st->execute([$driverId]);
$d = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$deductions['today'] = round((float) ($d['today'] ?? 0), 2);
$deductions['week'] = round((float) ($d['week'] ?? 0), 2);
$deductions['month'] = round((float) ($d['month'] ?? 0), 2);
$deductions['outstanding'] = round((float) ($d['outstanding'] ?? 0), 2);
// ‏تفصيل حسب المنتج: «اقتُطع ٣» سؤال، و«٣ لمحفظة الوقود» جواب.
$st = $con->prepare("
SELECT l.product_code, p.name_ar,
COALESCE(SUM(l.amount_remaining), 0) AS outstanding
FROM obligation_ledger l
JOIN driver_obligations o ON o.id = l.obligation_id
JOIN obligation_products p ON p.id = o.product_id
WHERE l.driver_id = ? AND l.amount_remaining > 0
GROUP BY l.product_code, p.name_ar
");
$st->execute([$driverId]);
$deductions['items'] = array_map(static fn(array $r): array => [
'product' => $r['product_code'],
'name' => $r['name_ar'],
'outstanding' => round((float) $r['outstanding'], 2),
], $st->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
// ‏غياب جداول الالتزامات (قبل تنفيذ الترحيل) ليس خطأً يُفشل الشاشة:
// ‏الأرباح تُعرض والاقتطاعات أصفار. إسقاط الشاشة كلها لأن ميزةً
// ‏جانبية غير مهيّأة عقوبةٌ في غير محلها.
error_log('[earnings/summary] تعذّرت قراءة الاقتطاعات: ' . $e->getMessage());
}
jsonSuccess([
'today' => $today,
'week' => $week,
'month' => $month,
'prev_week' => $prevWeek,
'trend_percent' => $trend,
'daily' => $daily,
'best_day' => $best,
'deductions' => $deductions,
// ‏ختم زمني واحد لكل الأرقام. التطبيق يعرضه كـ«آخر تحديث»، فيعرف
// ‏السائق متى قِيست هذه الأرقام بدل أن يظنّ القديم حاضراً.
'as_of' => date('c'),
], 'success');
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* ride/earnings/trips.php — تفصيل أرباح كل رحلة
* ─────────────────────────────────────────────────────────────
* ‏السؤال الأول عند كل سائق في العالم: «لماذا أخذت هذا المبلغ من هذه
* ‏الرحلة؟» — ولم يكن في التطبيق ما يجيبه. `payment_history_driver_page`
* ‏يعرض حركات محفظة (شحن، سحب، تحويل)، لا تركيبة أجرة رحلة.
*
* ‏وأثر ذلك ليس شكاوى فحسب: المنصة تمنح إعفاء عمولة ٠٪ عند تحقيق سلسلة
* ‏(`hasZeroCommission` في `finish_ride_updates.php`) — هدية حقيقية
* ‏يدفع ثمنها الصندوق ولا يراها السائق في أي شاشة. نحن ندفع ولا نحصد.
*
* POST: date (اختياري YYYY-MM-DD) أو range=week|month، limit
*/
require_once __DIR__ . '/../../connect.php';
$driverId = $user_id ?? '';
if (empty($driverId) || ($role ?? '') !== 'driver') {
jsonError('Unauthorized', 401);
}
$date = filterRequest('date');
$range = filterRequest('range');
$limit = (int) (filterRequest('limit', 'int') ?: 50);
$limit = max(1, min($limit, 200));
$params = [$driverId];
if (!empty($date)) {
// ‏تاريخ اليوم الواحد: الاستعمال الأغلب — السائق ينقر عموداً في الرسم
// ‏البياني ليرى رحلات ذلك اليوم.
$dateCondition = 'DATE(r.created_at) = ?';
$params[] = $date;
} elseif ($range === 'month') {
$dateCondition = 'r.created_at >= DATE_FORMAT(NOW(), "%Y-%m-01")';
} else {
$dateCondition = 'r.created_at >= DATE(NOW()) - INTERVAL 6 DAY';
}
try {
$st = $con->prepare("
SELECT r.id, r.created_at, r.date, r.time, r.endtime,
r.rideTimeStart, r.rideTimeFinish,
r.start_location, r.end_location,
r.price, r.price_for_driver, r.price_for_passenger,
r.distance, r.carType, r.paymentMethod,
r.ai_negotiated_bonus,
GREATEST(TIMESTAMPDIFF(MINUTE, r.rideTimeStart, r.rideTimeFinish), 0) AS minutes
FROM ride r
WHERE r.driver_id = ?
AND r.status IN ('Finished', 'finished')
AND $dateCondition
ORDER BY r.created_at DESC
LIMIT $limit
");
$st->execute($params);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('[earnings/trips] ' . $e->getMessage());
jsonError('تعذّر جلب تفاصيل الرحلات', 500);
}
$trips = array_map(static function (array $r): array {
$gross = round((float) $r['price'], 2);
$net = round((float) $r['price_for_driver'], 2);
$bonus = round((float) ($r['ai_negotiated_bonus'] ?? 0), 2);
// ‏العمولة مشتقّة من الفرق لا محسوبة بنسبة اليوم: النسبة تتغيّر
// ‏بالمنطقة والوقت وخصومات العمولة، وحسابها الآن بالنسبة الحالية
// ‏يُظهر للسائق رقماً يخالف ما اقتُطع منه فعلاً وقت الرحلة.
$commission = round(max($gross - $net, 0), 2);
// ‏إعفاء العمولة: صفرٌ حقيقي لا غياب بيانات. نميّزه صراحةً ليظهر في
// ‏التطبيق كمكسب («عمولة معفاة») لا كسطر فارغ.
$zeroCommission = $gross > 0 && $commission <= 0.009;
return [
'ride_id' => (int) $r['id'],
'at' => $r['created_at'],
'from' => $r['start_location'],
'to' => $r['end_location'],
'car_type' => $r['carType'],
'payment_method' => $r['paymentMethod'],
'distance_km' => round((float) $r['distance'], 1),
'minutes' => (int) $r['minutes'],
'breakdown' => [
'gross' => $gross,
'commission' => $commission,
'bonus' => $bonus,
'net' => $net,
'zero_commission' => $zeroCommission,
],
];
}, $rows);
// ‏مجاميع القائمة المعروضة نفسها. حسابها في التطبيق كان سيعني رقماً
// ‏يختلف عن الملخّص عند اختلاف التقريب — وهو بالضبط نوع التناقض الذي
// ‏جاءت هذه النقطة لتنهيه.
$sum = [
'trips' => count($trips),
'gross' => 0.0,
'commission' => 0.0,
'net' => 0.0,
];
foreach ($trips as $t) {
$sum['gross'] += $t['breakdown']['gross'];
$sum['commission'] += $t['breakdown']['commission'];
$sum['net'] += $t['breakdown']['net'];
}
foreach (['gross', 'commission', 'net'] as $k) {
$sum[$k] = round($sum[$k], 2);
}
jsonSuccess([
'trips' => $trips,
'summary' => $sum,
'as_of' => date('c'),
], 'success');
-526
View File
@@ -1,526 +0,0 @@
<?php
/**
* tools/panel/index.php — لوحة إدارة مفاتيح البيئة
* ─────────────────────────────────────────────────────────────
* ‏⚠️ هذه أعلى نقطة قيمةً في المنظومة: من يصل إليها يملك كل شيء دفعة
* ‏واحدة. لذلك القيود التالية ليست تزييناً، وإزالة أيٍّ منها تُبطل
* ‏الباقي:
*
* ١. ‏الاستماع على 127.0.0.1 فقط — لا تُفتح إلا عبر نفق SSH:
* ssh -L 8099:127.0.0.1:8099 root@server
* ‏ثم http://127.0.0.1:8099 في متصفّحك.
*
* ٢. ‏عبارة مرور منفصلة تماماً عن أي حساب آخر (ENV_PANEL_PASSPHRASE
* ‏مخزَّنة كتجزئة password_hash، لا نصّاً).
*
* ٣. ‏المفاتيح المجمّدة تُعرض حالتها فقط — لا قيمتها ولا حقل تعديل.
* ‏لا يوجد مسار في هذا الملف يكتبها، لا بواجهة ولا بطلب مصنوع.
*
* ٤. ‏كل تغيير يُسجَّل: من أين، ومتى، وأي مفتاح (لا قيمته).
*
* ‏التشغيل (على المضيف، لا داخل الحاوية — يحتاج الوصول إلى docker):
* cd backend/tools/panel && php -S 127.0.0.1:8099
*/
declare(strict_types=1);
require_once __DIR__ . '/../env_registry.php';
// ═══════════════════════════════════════════════════════════════
// القيد ١ — لا شيء غير المضيف المحلي
// ═══════════════════════════════════════════════════════════════
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!in_array($remote, ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('Forbidden — local access only.');
}
// ‏ترويسات الوكيل لا تُصدَّق: لو وُضعت اللوحة خلف nginx يوماً، فإن
// ‏X-Forwarded-For تجعل REMOTE_ADDR محلياً دائماً وتُبطل القيد أعلاه.
// ‏وجودها يعني أننا خلف وكيل — وهذا ما لا نريده أصلاً.
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_FORWARDED'] as $h) {
if (!empty($_SERVER[$h])) {
http_response_code(403);
exit('Forbidden — panel must not be proxied.');
}
}
$root = dirname(__DIR__, 3);
$envFile = getenv('ENV_FILE_PATH') ?: "$root/docker/.env";
$logFile = __DIR__ . '/panel_audit.log';
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Strict',
'use_strict_mode' => true,
]);
/** ‏يسجّل حدثاً. القيم لا تُسجَّل أبداً — أسماء المفاتيح فقط. */
function auditLog(string $file, string $event, string $detail = ''): void
{
$line = sprintf("[%s] %s | %s | %s\n",
date('Y-m-d H:i:s'), $_SERVER['REMOTE_ADDR'] ?? '?', $event, $detail);
@file_put_contents($file, $line, FILE_APPEND);
@chmod($file, 0600);
}
// ═══════════════════════════════════════════════════════════════
// القيد ٢ — عبارة المرور
// ═══════════════════════════════════════════════════════════════
$env = envRead($envFile);
$hash = $env['ENV_PANEL_PASSPHRASE'] ?? '';
if ($hash === '' || !str_starts_with($hash, '$2y$')) {
http_response_code(503);
echo '<meta charset="utf-8"><pre style="font-family:monospace;padding:2rem">';
echo "اللوحة معطّلة: لم تُضبط ENV_PANEL_PASSPHRASE.\n\n";
echo "ولّد تجزئة وضعها في docker/.env:\n\n";
echo " php -r 'echo password_hash(\"عبارتك\", PASSWORD_BCRYPT), PHP_EOL;'\n\n";
echo "ثم: ENV_PANEL_PASSPHRASE='\$2y\$...'\n\n";
echo "اجعلها عبارة لا تشبه أي كلمة مرور أخرى عندك.\n";
echo '</pre>';
exit;
}
$error = '';
if (($_POST['action'] ?? '') === 'login') {
// ‏تأخير ثابت: يجعل تخمين العبارة بطيئاً بما يكفي، وbcrypt نفسه
// ‏يتكفّل بالباقي.
usleep(400_000);
if (password_verify((string) ($_POST['passphrase'] ?? ''), $hash)) {
session_regenerate_id(true);
$_SESSION['ok'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
auditLog($logFile, 'LOGIN_OK');
} else {
$error = 'عبارة غير صحيحة.';
auditLog($logFile, 'LOGIN_FAIL');
}
}
if (($_GET['logout'] ?? '') === '1') {
session_destroy();
header('Location: ?');
exit;
}
$authed = !empty($_SESSION['ok']);
// ═══════════════════════════════════════════════════════════════
// العمليات
// ═══════════════════════════════════════════════════════════════
$notice = '';
if ($authed && ($_POST['action'] ?? '') !== '' && ($_POST['action'] ?? '') !== 'login') {
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('CSRF mismatch');
}
$key = (string) ($_POST['key'] ?? '');
$action = (string) $_POST['action'];
// ── الحارس الجامع ──
// ‏قبل أي فرع: المجمّدة ممنوعة. الفحص هنا لا في كل فرع، فلا يمكن
// ‏لفرع جديد يُضاف لاحقاً أن ينساه.
if (in_array($key, ENV_FROZEN, true)) {
http_response_code(403);
auditLog($logFile, 'BLOCKED_FROZEN', $key);
exit('هذا المفتاح مجمّد ولا يمكن تعديله من اللوحة.');
}
$rotatable = ENV_GENERATED + ENV_SESSION_SCOPED;
if ($action === 'rotate' && isset($rotatable[$key])) {
$backup = envWrite($envFile, [$key => envGenerate($rotatable[$key])]);
if ($backup) {
auditLog($logFile, 'ROTATE', $key);
$notice = "دُوِّر $key. نسخة احتياطية: " . basename($backup)
. ' — أعد تشغيل حاويات php.';
} else {
$error = 'تعذّرت الكتابة.';
}
} elseif ($action === 'set' && in_array($key, ENV_EXTERNAL, true)) {
$value = trim((string) ($_POST['value'] ?? ''));
if ($value === '') {
$error = 'القيمة فارغة.';
} else {
$backup = envWrite($envFile, [$key => $value]);
if ($backup) {
auditLog($logFile, 'SET_EXTERNAL', $key);
$notice = "حُدِّث $key.";
} else {
$error = 'تعذّرت الكتابة.';
}
}
} elseif ($action === 'rotate_db' && isset(ENV_DB_CREDENTIALS[$key])) {
[$notice, $error] = rotateDbPassword($envFile, $logFile, $env, $key);
} else {
$error = 'عملية غير معروفة أو مفتاح غير قابل للتعديل.';
}
$env = envRead($envFile); // إعادة القراءة بعد أي تغيير
}
/**
* ‏يدوّر كلمة مرور مستخدم قاعدة بيانات بالترتيب الصحيح.
*
* ‏الترتيب هو كل شيء: ALTER USER أولاً، ثم **تحقّق باتصال فعلي**، ثم
* ‏كتابة .env. لو كتبنا .env أولاً وفشل ALTER، لصار التطبيق يحمل كلمة
* ‏لا يقبلها الخادم — انقطاع كامل. ولو نجح ALTER وفشلت الكتابة، نرجع
* ‏كلمة المرور القديمة فوراً بدل ترك الطرفين متنافرين.
*
* ‏ولا تُلمس البيانات في أي خطوة: هذا تغيير في mysql.user لا غير.
*/
function rotateDbPassword(string $envFile, string $logFile, array $env, string $key): array
{
$map = ENV_DB_CREDENTIALS[$key];
$user = $env[$map['user']] ?? '';
$dbName = $env[$map['db']] ?? '';
$rootPass = $env['MYSQL_ROOT_PASSWORD'] ?? '';
$oldPass = $env[$key] ?? '';
if ($user === '' || $dbName === '' || $rootPass === '') {
return ['', "ينقص {$map['user']} أو {$map['db']} أو MYSQL_ROOT_PASSWORD."];
}
// ‏كلمة مرور بلا رموز تحتاج هروباً في الشل — hex آمنة في كل سياق.
$newPass = envGenerate(20);
$alter = sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($newPass)
);
[$code] = runMysql($rootPass, $alter);
if ($code !== 0) {
auditLog($logFile, 'DB_ROTATE_FAIL_ALTER', $key);
return ['', 'فشل ALTER USER — لم يتغيّر شيء.'];
}
// ‏تحقّق: هل تعمل الكلمة الجديدة فعلاً؟ الثقة بأن ALTER نجح لا تكفي
// ‏(قد يكون المستخدم معرَّفاً بمضيف آخر مثلاً).
if (!verifyDbLogin($user, $newPass, $dbName)) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK', $key);
return ['', 'الكلمة الجديدة لم تعمل — أُعيدت القديمة.'];
}
$backup = envWrite($envFile, [$key => $newPass]);
if (!$backup) {
runMysql($rootPass, sprintf(
"ALTER USER %s@'%%' IDENTIFIED BY %s; FLUSH PRIVILEGES;",
quoteIdent($user), quoteLiteral($oldPass)
));
auditLog($logFile, 'DB_ROTATE_ROLLBACK_WRITE', $key);
return ['', 'تعذّرت كتابة .env — أُعيدت كلمة المرور القديمة.'];
}
auditLog($logFile, 'DB_ROTATE', "$key ($user@$dbName)");
return ["دُوِّرت كلمة مرور $user. أعد تشغيل حاويات php الآن.", ''];
}
/** ‏هروب معرّف MySQL بالعلامة الخلفية. */
function quoteIdent(string $s): string
{
return '`' . str_replace('`', '``', $s) . '`';
}
/** ‏هروب سلسلة نصية لـ MySQL. */
function quoteLiteral(string $s): string
{
return "'" . addcslashes($s, "\\'\"\0\n\r\032") . "'";
}
/**
* ‏ينفّذ SQL في حاوية mysql.
*
* ‏كلمة المرور تمرّ عبر متغيّر بيئة لا وسيطاً في سطر الأوامر: الوسيط
* ‏يظهر في `ps` لأي مستخدم على الجهاز.
*/
function runMysql(string $rootPass, string $sql): array
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -uroot -e ' . escapeshellarg($sql) . ' 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $rootPass]);
if (!is_resource($proc)) return [1, 'proc_open failed'];
$out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $out];
}
/** ‏يتأكد أن الاعتماد الجديد يسجّل دخولاً فعلياً. */
function verifyDbLogin(string $user, string $pass, string $db): bool
{
$cmd = 'docker compose -f ' . escapeshellarg(dirname(__DIR__, 3) . '/docker/docker-compose.yml')
. ' exec -T -e MYSQL_PWD mysql mysql -u' . escapeshellarg($user)
. ' ' . escapeshellarg($db) . ' -e "SELECT 1" 2>&1';
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open($cmd, $descriptors, $pipes, null, ['MYSQL_PWD' => $pass]);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return proc_close($proc) === 0;
}
/** ‏حالة المفتاح بلا كشف قيمته. */
function keyState(array $env, string $name): string
{
if (!array_key_exists($name, $env)) return 'مفقود';
return envIsPlaceholder($env[$name]) ? 'نائب' : 'مضبوط';
}
/** ‏بصمة قصيرة تكفي للتمييز ولا تكفي للاستعمال. */
function keyFingerprint(array $env, string $name): string
{
$v = $env[$name] ?? '';
return $v === '' ? '—' : substr(hash('sha256', $v), 0, 8);
}
$csrf = $_SESSION['csrf'] ?? '';
?>
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>مفاتيح البيئة — سيرو</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background:#12151a; color:#e6e9ef; margin:0; padding:2rem 1rem; }
.wrap { max-width: 940px; margin: 0 auto; }
h1 { font-size:1.4rem; margin:0 0 .3rem; }
.sub { color:#8b93a7; font-size:.85rem; margin-bottom:1.5rem; }
.card { background:#1a1f27; border:1px solid #2a3140; border-radius:12px;
padding:1.1rem 1.25rem; margin-bottom:1.25rem; }
.card h2 { font-size:1rem; margin:0 0 .25rem; }
.card .hint { color:#8b93a7; font-size:.8rem; margin:0 0 .9rem; line-height:1.6; }
table { width:100%; border-collapse:collapse; }
td, th { padding:.5rem .4rem; text-align:right; border-bottom:1px solid #232a35;
font-size:.88rem; vertical-align:middle; }
th { color:#8b93a7; font-weight:600; font-size:.78rem; }
code { font-family: ui-monospace, monospace; font-size:.82rem; }
.state { font-size:.75rem; padding:.15rem .5rem; border-radius:999px; }
.ok { background:#12341f; color:#5ed68b; }
.miss { background:#3a1f1f; color:#ff8b8b; }
.ph { background:#3a3320; color:#ffc861; }
button { background:#2d6cdf; color:#fff; border:0; border-radius:8px;
padding:.4rem .9rem; font-size:.82rem; cursor:pointer; font-family:inherit; }
button:hover { background:#3d7cef; }
button.warn { background:#8a3a2a; }
button.warn:hover { background:#a1462f; }
input[type=text], input[type=password] { background:#12151a; border:1px solid #2a3140;
color:#e6e9ef; border-radius:8px; padding:.45rem .6rem; font-family:inherit;
font-size:.85rem; width:100%; box-sizing:border-box; }
.frozen { opacity:.72; }
.banner { border-radius:10px; padding:.75rem 1rem; margin-bottom:1.25rem; font-size:.88rem; }
.banner.err { background:#3a1f1f; border:1px solid #6b3030; }
.banner.ok { background:#12341f; border:1px solid #2f6b45; }
.banner.danger { background:#33221a; border:1px solid #7a4426; line-height:1.7; }
form.inline { display:inline; }
.row-actions { display:flex; gap:.5rem; align-items:center; }
</style>
</head>
<body>
<div class="wrap">
<?php if (!$authed): ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">وصول محلي فقط عبر نفق SSH.</p>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card">
<form method="post">
<input type="hidden" name="action" value="login">
<p class="hint">عبارة مرور اللوحة</p>
<input type="password" name="passphrase" autofocus autocomplete="off">
<p></p>
<button type="submit">دخول</button>
</form>
</div>
<?php else: ?>
<h1>مفاتيح البيئة</h1>
<p class="sub">
<code><?= htmlspecialchars($envFile) ?></code> ·
<a href="?logout=1" style="color:#8b93a7">خروج</a>
</p>
<?php if ($notice): ?><div class="banner ok"><?= htmlspecialchars($notice) ?></div><?php endif; ?>
<?php if ($error): ?><div class="banner err"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="banner danger">
<strong>بعد أي تغيير</strong> — أعد تشغيل الحاويات لتقرأ الجديد:
<code>docker compose restart php php_food php_transit</code><br>
وتذكّر أن <code>WEBHOOK_SECRET_KEY</code> له نظير في تطبيق
<code>sms_relay</code> يحتاج إعادة بناء، و<code>S2S_SHARED_KEY</code>
و<code>INTERNAL_SOCKET_KEY</code> لهما نظائر في خوادم السوكيت والدفع.
</div>
<!-- ── أسرار داخلية ── -->
<div class="card">
<h2>أسرار داخلية</h2>
<p class="hint">طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_GENERATED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit"><?= $s === 'مضبوط' ? 'تدوير' : 'توليد' ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── مرتبطة بالجلسات ── -->
<div class="card">
<h2>مرتبطة بالجلسات</h2>
<p class="hint">
تدويرها يُخرج <strong>كل</strong> السائقين والركّاب من حساباتهم ويطالبهم
بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th><th></th></tr>
<?php foreach (ENV_SESSION_SCOPED as $name => $bytes):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
<td>
<form method="post" class="inline"
onsubmit="return confirm('سيُخرج هذا كل المستخدمين من حساباتهم. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── قواعد البيانات ── -->
<div class="card">
<h2>كلمات مرور قواعد البيانات</h2>
<p class="hint">
التدوير يجري بالترتيب الصحيح: <code>ALTER USER</code> ثم تحقّق باتصال
فعلي ثم كتابة <code>.env</code> — وإن فشلت أي خطوة تُستعاد الكلمة
القديمة فوراً. <strong>لا مساس بالبيانات ولا إعادة بناء</strong>؛ هذا
تغيير في <code>mysql.user</code> لا غير.
</p>
<table>
<tr><th>المفتاح</th><th>المستخدم</th><th>القاعدة</th><th>الحالة</th><th></th></tr>
<?php foreach (ENV_DB_CREDENTIALS as $name => $map):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><code><?= htmlspecialchars($env[$map['user']] ?? '—') ?></code></td>
<td><code><?= htmlspecialchars($env[$map['db']] ?? '—') ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<?php if ($s === 'مضبوط'): ?>
<form method="post" class="inline"
onsubmit="return confirm('تدوير كلمة مرور القاعدة. أعد تشغيل حاويات php فوراً بعدها. متابعة؟')">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="rotate_db">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<button type="submit" class="warn">تدوير</button>
</form>
<?php else: ?><span style="color:#8b93a7">—</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── طرف ثالث ── -->
<div class="card">
<h2>مفاتيح طرف ثالث</h2>
<p class="hint">لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th style="width:45%">قيمة جديدة</th><th></th></tr>
<?php foreach (ENV_EXTERNAL as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<form method="post">
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set">
<input type="hidden" name="key" value="<?= htmlspecialchars($name) ?>">
<input type="text" name="value" autocomplete="off" placeholder="اتركه فارغاً لعدم التغيير">
</td>
<td><button type="submit">حفظ</button></td>
</form>
</tr>
<?php endforeach; ?>
</table>
</div>
<!-- ── المجمّدة ── -->
<div class="card frozen">
<h2>⛔ مفاتيح مجمّدة — للعلم فقط</h2>
<p class="hint">
بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب
وهواتفهم ولوحات السيارات مشفّرة بـ <code>ENC_KEY</code>، وأعمدة
<code>*_bidx</code> محسوبة بـ <code>BLIND_INDEX_PEPPER</code>.
تغيير أيٍّ منها <strong>لا يعيد تشفير شيء</strong> — يجعل كل صف قديم
غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها
مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد،
ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف.
</p>
<table>
<tr><th>المفتاح</th><th>الحالة</th><th>بصمة</th></tr>
<?php foreach (ENV_FROZEN as $name):
$s = keyState($env, $name);
$cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
<tr>
<td><code><?= htmlspecialchars($name) ?></code></td>
<td><span class="state <?= $cls ?>"><?= $s ?></span></td>
<td><code><?= keyFingerprint($env, $name) ?></code></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<p class="sub">سجلّ التغييرات: <code>backend/tools/panel/panel_audit.log</code></p>
<?php endif; ?>
</div>
</body>
</html>