diff --git a/.DS_Store b/.DS_Store
index b2dd4fd9..934e7e29 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index bce53ca6..861b1b19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,6 +102,4 @@ GoogleService-Info.plist
semgrep_*.json
nuclei_results.txt
-# سجل لوحة المفاتيح — محلي للسيرفر، لا يُدفع
-backend/tools/panel/panel_audit.log
*.env.bak.*
diff --git a/backend/Admin/fuel/drawdowns.php b/backend/Admin/fuel/drawdowns.php
new file mode 100644
index 00000000..1f9a59f7
--- /dev/null
+++ b/backend/Admin/fuel/drawdowns.php
@@ -0,0 +1,89 @@
+ 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');
diff --git a/backend/Admin/fuel/list.php b/backend/Admin/fuel/list.php
new file mode 100644
index 00000000..3f944a6c
--- /dev/null
+++ b/backend/Admin/fuel/list.php
@@ -0,0 +1,90 @@
+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');
diff --git a/backend/Admin/fuel/rotate_key.php b/backend/Admin/fuel/rotate_key.php
new file mode 100644
index 00000000..8fda8801
--- /dev/null
+++ b/backend/Admin/fuel/rotate_key.php
@@ -0,0 +1,44 @@
+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);
+}
diff --git a/backend/Admin/fuel/save.php b/backend/Admin/fuel/save.php
new file mode 100644
index 00000000..cff2a9d8
--- /dev/null
+++ b/backend/Admin/fuel/save.php
@@ -0,0 +1,88 @@
+ 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);
+}
diff --git a/backend/Admin/fuel/toggle_product.php b/backend/Admin/fuel/toggle_product.php
new file mode 100644
index 00000000..ffea9b1d
--- /dev/null
+++ b/backend/Admin/fuel/toggle_product.php
@@ -0,0 +1,53 @@
+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);
+}
diff --git a/backend/bot/cron_insurance_premiums.php b/backend/bot/cron_insurance_premiums.php
index 1db1fd07..741d897e 100644
--- a/backend/bot/cron_insurance_premiums.php
+++ b/backend/bot/cron_insurance_premiums.php
@@ -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());
}
}
}
diff --git a/backend/bot/cron_obligation_settlement.php b/backend/bot/cron_obligation_settlement.php
new file mode 100644
index 00000000..65b0e27b
--- /dev/null
+++ b/backend/bot/cron_obligation_settlement.php
@@ -0,0 +1,199 @@
+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
+);
diff --git a/backend/driver_assurance/cancel.php b/backend/driver_assurance/cancel.php
index e389fa6c..3234be6f 100644
--- a/backend/driver_assurance/cancel.php
+++ b/backend/driver_assurance/cancel.php
@@ -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");
diff --git a/backend/driver_assurance/get.php b/backend/driver_assurance/get.php
index fc3f5f55..489f0943 100644
--- a/backend/driver_assurance/get.php
+++ b/backend/driver_assurance/get.php
@@ -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());
diff --git a/backend/driver_assurance/plans.php b/backend/driver_assurance/plans.php
index e23a7212..826887e9 100644
--- a/backend/driver_assurance/plans.php
+++ b/backend/driver_assurance/plans.php
@@ -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'],
diff --git a/backend/driver_assurance/subscribe.php b/backend/driver_assurance/subscribe.php
index f31934f2..350babf8 100644
--- a/backend/driver_assurance/subscribe.php
+++ b/backend/driver_assurance/subscribe.php
@@ -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('تعذّر تفعيل الوثيقة حالياً');
}
diff --git a/backend/fuel/cancel_voucher.php b/backend/fuel/cancel_voucher.php
new file mode 100644
index 00000000..ab4a3950
--- /dev/null
+++ b/backend/fuel/cancel_voucher.php
@@ -0,0 +1,36 @@
+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,
+], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
diff --git a/backend/fuel/enroll.php b/backend/fuel/enroll.php
new file mode 100644
index 00000000..3932bc08
--- /dev/null
+++ b/backend/fuel/enroll.php
@@ -0,0 +1,77 @@
+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'],
+], 'تم تفعيل محفظة الوقود');
diff --git a/backend/fuel/functions.php b/backend/fuel/functions.php
new file mode 100644
index 00000000..dc931011
--- /dev/null
+++ b/backend/fuel/functions.php
@@ -0,0 +1,113 @@
+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('تعذّر توليد رمز قسيمة فريد');
+}
diff --git a/backend/fuel/get.php b/backend/fuel/get.php
new file mode 100644
index 00000000..e26196a2
--- /dev/null
+++ b/backend/fuel/get.php
@@ -0,0 +1,80 @@
+ 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');
diff --git a/backend/fuel/redeem.php b/backend/fuel/redeem.php
new file mode 100644
index 00000000..43b1a1d7
--- /dev/null
+++ b/backend/fuel/redeem.php
@@ -0,0 +1,152 @@
+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'],
+], 'تم الصرف');
diff --git a/backend/fuel/request_voucher.php b/backend/fuel/request_voucher.php
new file mode 100644
index 00000000..fa0e6b33
--- /dev/null
+++ b/backend/fuel/request_voucher.php
@@ -0,0 +1,88 @@
+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),
+], 'أعطِ هذا الرمز لعامل المحطة');
diff --git a/backend/fuel/stations.php b/backend/fuel/stations.php
new file mode 100644
index 00000000..7427f10e
--- /dev/null
+++ b/backend/fuel/stations.php
@@ -0,0 +1,65 @@
+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');
diff --git a/backend/migrations/2026_08_08_obligations_engine.sql b/backend/migrations/2026_08_08_obligations_engine.sql
new file mode 100644
index 00000000..f34efa88
--- /dev/null
+++ b/backend/migrations/2026_08_08_obligations_engine.sql
@@ -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`
+);
diff --git a/backend/migrations/2026_08_09_obligation_credit_gate.sql b/backend/migrations/2026_08_09_obligation_credit_gate.sql
new file mode 100644
index 00000000..471df0f5
--- /dev/null
+++ b/backend/migrations/2026_08_09_obligation_credit_gate.sql
@@ -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;
diff --git a/backend/migrations/2026_08_10_fuel_wallet.sql b/backend/migrations/2026_08_10_fuel_wallet.sql
new file mode 100644
index 00000000..193347b2
--- /dev/null
+++ b/backend/migrations/2026_08_10_fuel_wallet.sql
@@ -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'
+);
diff --git a/backend/obligations/functions.php b/backend/obligations/functions.php
new file mode 100644
index 00000000..147ba296
--- /dev/null
+++ b/backend/obligations/functions.php
@@ -0,0 +1,234 @@
+ 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();
+}
diff --git a/backend/ride/earnings/summary.php b/backend/ride/earnings/summary.php
new file mode 100644
index 00000000..c03cb13b
--- /dev/null
+++ b/backend/ride/earnings/summary.php
@@ -0,0 +1,210 @@
+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');
diff --git a/backend/ride/earnings/trips.php b/backend/ride/earnings/trips.php
new file mode 100644
index 00000000..626fa9c5
--- /dev/null
+++ b/backend/ride/earnings/trips.php
@@ -0,0 +1,121 @@
+= 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');
diff --git a/backend/tools/panel/index.php b/backend/tools/panel/index.php
deleted file mode 100644
index b10a6099..00000000
--- a/backend/tools/panel/index.php
+++ /dev/null
@@ -1,526 +0,0 @@
- 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 '
';
- 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 '
';
- 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'] ?? '';
-?>
-
-
-
-
-
-
-مفاتيح البيئة — سيرو
-
-
-
-
-
-
-
مفاتيح البيئة
-
وصول محلي فقط عبر نفق SSH.
-
= htmlspecialchars($error) ?>
-
-
-
-
مفاتيح البيئة
-
- = htmlspecialchars($envFile) ?> ·
- خروج
-
-
-
= htmlspecialchars($notice) ?>
-
= htmlspecialchars($error) ?>
-
-
- بعد أي تغيير — أعد تشغيل الحاويات لتقرأ الجديد:
- docker compose restart php php_food php_transit
- وتذكّر أن WEBHOOK_SECRET_KEY له نظير في تطبيق
- sms_relay يحتاج إعادة بناء، وS2S_SHARED_KEY
- وINTERNAL_SOCKET_KEY لهما نظائر في خوادم السوكيت والدفع.
-
-
-
-
-
أسرار داخلية
-
طرفاها كلاهما لنا. التدوير آمن — يكفي إعادة تشغيل ما يحملها.
-
- | المفتاح | الحالة | بصمة | |
- $bytes):
- $s = keyState($env, $name);
- $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
-
- = htmlspecialchars($name) ?> |
- = $s ?> |
- = keyFingerprint($env, $name) ?> |
-
-
- |
-
-
-
-
-
-
-
-
مرتبطة بالجلسات
-
- تدويرها يُخرج كل السائقين والركّاب من حساباتهم ويطالبهم
- بتسجيل دخول جديد. ليس عطلاً، لكنه ليس شيئاً يُفعل وقت الذروة.
-
-
- | المفتاح | الحالة | بصمة | |
- $bytes):
- $s = keyState($env, $name);
- $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
-
- = htmlspecialchars($name) ?> |
- = $s ?> |
- = keyFingerprint($env, $name) ?> |
-
-
- |
-
-
-
-
-
-
-
-
كلمات مرور قواعد البيانات
-
- التدوير يجري بالترتيب الصحيح: ALTER USER ثم تحقّق باتصال
- فعلي ثم كتابة .env — وإن فشلت أي خطوة تُستعاد الكلمة
- القديمة فوراً. لا مساس بالبيانات ولا إعادة بناء؛ هذا
- تغيير في mysql.user لا غير.
-
-
- | المفتاح | المستخدم | القاعدة | الحالة | |
- $map):
- $s = keyState($env, $name);
- $cls = $s === 'مضبوط' ? 'ok' : ($s === 'نائب' ? 'ph' : 'miss'); ?>
-
- = htmlspecialchars($name) ?> |
- = htmlspecialchars($env[$map['user']] ?? '—') ?> |
- = htmlspecialchars($env[$map['db']] ?? '—') ?> |
- = $s ?> |
-
-
-
- —
- |
-
-
-
-
-
-
-
-
مفاتيح طرف ثالث
-
لا تُولَّد — تُنسخ من لوحة المزوّد وتُلصق هنا.
-
- | المفتاح | الحالة | قيمة جديدة | |
-
-
-
-
-
-
-
-
-
-
-
⛔ مفاتيح مجمّدة — للعلم فقط
-
- بيانات مخزَّنة في القاعدة مشتقّة من هذه المفاتيح: أسماء الركّاب
- وهواتفهم ولوحات السيارات مشفّرة بـ ENC_KEY، وأعمدة
- *_bidx محسوبة بـ BLIND_INDEX_PEPPER.
- تغيير أيٍّ منها لا يعيد تشفير شيء — يجعل كل صف قديم
- غير قابل للقراءة إلى الأبد، ولا نسخة احتياطية تنفع لأنها هي نفسها
- مشفّرة بالمفتاح القديم. تدويرها ترحيلُ بيانات لا تغييرُ إعداد،
- ولذلك لا يوجد لها زر هنا ولا مسار يكتبها في هذا الملف.
-
-
- | المفتاح | الحالة | بصمة |
-
-
- = htmlspecialchars($name) ?> |
- = $s ?> |
- = keyFingerprint($env, $name) ?> |
-
-
-
-
-
-
سجلّ التغييرات: backend/tools/panel/panel_audit.log
-
-
-
-
-
diff --git a/docker/crontab.production b/docker/crontab.production
index 5cf33337..2b3c81e5 100644
--- a/docker/crontab.production
+++ b/docker/crontab.production
@@ -82,13 +82,25 @@ DEX=/usr/bin/docker compose -f $SIRO/docker-compose.yml exec -T php php
*/1 * * * * $DEX /var/www/backend/bot/cron_sms_requests.php >> /var/log/sms_requests.log 2>&1
# ============================================================
-# التأمين للسائق
+# التزامات السائق (تأمين، وقود، صيانة)
# ============================================================
-# يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد — الرصيد
-# في payment_server، وهذا دفتر استحقاق تقرأه التسوية.
+# مُصدِر الاستحقاق: يقيّد استحقاق كل التزام دوري بلغت دورته في
+# obligation_ledger. لا يخصم من رصيد أحد — الرصيد في payment_server.
# مرة يومياً بعد منتصف الليل: القيد اليومي مرتبط بالتاريخ لا بالساعة.
15 0 * * * $DEX /var/www/backend/bot/cron_insurance_premiums.php >> /var/log/insurance.log 2>&1
+# محرك التسوية: الجهة الوحيدة التي تخصم فعلياً من محفظة السائق.
+#
+# لماذا 23:30 لا بعد المُصدِر مباشرة؟
+# لأن السقف اليومي نسبة من أرباح *اليوم*، وهي تُقرأ من صفوف محفظة
+# السائق المؤرخة بتاريخ اليوم. تشغيله الساعة 00:45 يعني قراءة يومٍ
+# عمره خمس وأربعون دقيقة — أرباحه صفر، فالسقف صفر، فلا يُخصم شيء
+# أبداً. المحرك كان سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش.
+#
+# 23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً:
+# ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً.
+30 23 * * * $DEX /var/www/backend/bot/cron_obligation_settlement.php >> /var/log/obligations.log 2>&1
+
# ============================================================
# الإشعارات والتسويق
# ============================================================
diff --git a/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md b/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md
new file mode 100644
index 00000000..457ec1f2
--- /dev/null
+++ b/docs/01_overview/SIRO_FEATURES_AND_POLICIES_AR.md
@@ -0,0 +1,271 @@
+# سيرو — سجلّ الإضافات وسياساتها
+
+> **ما هذا الملف؟** شرحٌ بلغة غير تقنية لكل ميزة نضيفها: ما هي، لماذا أضفناها، كيف تعمل، وما قواعدها الرقمية.
+>
+> **لمن؟** للمالك ليتابع بلا قراءة كود، ولخدمة العملاء لتجيب السائق والراكب، ولعرضه على المستثمرين.
+>
+> **قاعدة التحديث:** كل ميزة جديدة تُضاف هنا فور إنجازها. الملف مرتّب من الأحدث إلى الأقدم.
+>
+> آخر تحديث: 2026-08-10 · المصدر: [دراسة الفرص](../04_features/SIRO_OPPORTUNITY_STUDY_AR.md)
+
+---
+
+# شاشة أرباح السائق (جديدة)
+
+### ما هي
+شاشة واحدة تجيب السائق عن: كم كسبت اليوم؟ كم اقتُطع مني ولماذا؟ وكم أخذت من **كل رحلة** بالتفصيل؟
+
+### لماذا أعدنا بناءها
+ثلاثة أسباب، أولها الأخطر:
+
+**١) كان فشل الشبكة يظهر للسائق كـ«دخلك صفر».** عند تعذّر التحميل كان التطبيق يولّد سبعة أيام بأصفار ويعرضها كأنها أرباحه الحقيقية. السائق يرى دخل أسبوعه وقد تبخّر بسبب انقطاع إنترنت لحظي. لا يُقاس هذا بالجهد بل بالثقة.
+
+**٢) الأرقام كانت لا تتفق.** شاشة الرصيد تُحدَّث فوراً، وشاشة الإحصائيات كانت مخزّنة **٣ ساعات** بلا أي إشارة إلى قِدَمها. السائق يفتح شاشتين فيجد رقمين مختلفين، فلا يثق بأيّهما.
+
+**٣) لم يكن هناك تفصيل للرحلة.** السؤال الأول عند كل سائق في العالم — «لماذا أخذت هذا المبلغ من هذه الرحلة؟» — لم يكن له جواب في التطبيق. وهذا مصدر أغلب شكاوى «الحساب غلط».
+
+### كيف تعمل الآن
+
+شاشة **واحدة** اسمها «أرباحي» حلّت محل شاشتين منفصلتين (الأرباح والإحصائيات). الترتيب فيها مقصود: **المال أولاً، ثم التقدّم**.
+
+| الترتيب | القسم |
+|---|---|
+| ١ | أرباح اليوم + تركيبة المبلغ |
+| ٢ | الاقتطاعات (تأمين، وقود) |
+| ٣ | آخر ٧ أيام — رسم بياني **يُنقر** لعرض رحلات أي يوم |
+| ٤ | هذا الشهر — الصافي، معدّل الساعة، معدّل اليوم |
+| ٥ | الرحلات بتفصيل كل واحدة |
+| ٦ | تقدّمي: هدف اليوم، المستوى، سلوك القيادة، الإنجازات |
+
+كان التلعيب والمستويات في شاشة منفصلة تسبق الأرباح، فيمرّر السائق ثلاث شاشات ليصل إلى ما فتح التطبيق من أجله.
+
+| كان | صار |
+|---|---|
+| شاشتان بأرقام لا تتفق | شاشة واحدة بمصدر واحد |
+| أربعة نداءات تصل في لحظات مختلفة | نداء واحد بختم زمني واحد |
+| فشل التحميل = أصفار | «تعذّر التحميل» + آخر رقم صادق معلَّم بوقته |
+| كاش ٣ ساعات صامت | دقيقتان، وأي رقم قديم يظهر بلافتة «هذه أرقام قبل كذا» |
+| يبدأ بتقرير شهري | يبدأ بـ**أرباح اليوم** |
+| الصافي فقط | دفع الراكب ← عمولة المنصة ← صافيك |
+| لا شيء عن الاقتطاعات | التأمين والوقود ظاهران بجانب الأرباح |
+
+**تعريف مهم للجميع:** «أرباحي» ≠ «رصيدي». الأول عن العمل (الرحلات)، والثاني عن المال المتاح بعد الشحن والسحب والاقتطاعات. خلطهما هو ما أنتج الرقمين المتناقضين، وقد فُصلا الآن: الأرباح في شاشة الأرباح، والرصيد في شاشة المحفظة.
+
+### قرارات منتج
+- **العمولة تُعرض صراحةً** لا تُطرح بصمت. من يرى التركيبة يفهم، ومن يرى الصافي وحده يخمّن ويشكّ.
+- **إعفاء العمولة ٠٪** صار مرئياً كوسم على الرحلة. كنا ندفع ثمن هذه الهدية ولا نحصد أثرها لأن السائق لا يراها.
+- **المقارنة بالذات لا بالآخرين**: «أعلى من أسبوعك الماضي بـ١٢٪». ترتيب السائقين يُحبط أكثر من يحتاج التحفيز — فهو من سيقع في أسفله.
+- **الاقتطاعات في شاشة الأرباح إلزامياً.** خصمٌ لا يظهر بجانب الأرباح التي اقتُطع منها يُقرأ سرقةً مهما كان مشروعاً. هذا شرط قبل تشغيل محفظة الوقود لا بعده.
+
+### خطآن حسابيان صُحّحا
+- كانت الأرباح تُحسب على حالة `Finished` وحدها، والحالة مكتوبة في قاعدة البيانات بحرفين مختلفين تاريخياً — أي أن **نحو نصف رحلات السائق كان يسقط من أرباحه المعروضة**.
+- ساعات العمل كانت تُحسب بطريقة تجعل رحلة تبدأ ١١:٤٠ ليلاً وتنتهي ١٢:١٠ تُحسب بـ**سالب** ٢٣ ساعة ونصف، فتأكل ساعات يوم كامل.
+
+### لخدمة العملاء
+
+**«أرباحي اختفت / صارت صفراً»** — لم يعد ممكناً بعد هذا التحديث. إن ظهرت رسالة «تعذّر تحميل أرباحك» فهي مشكلة اتصال فقط، والشاشة تقول ذلك صراحةً: أرباحك محفوظة ولم يضع منها شيء.
+
+**«الرقم في شاشة الأرباح غير الرقم في المحفظة»** — طبيعي ومقصود. الأرباح = ما كسبته من الرحلات. الرصيد = المتاح فعلاً بعد الشحن والسحب والاقتطاعات.
+
+**«لماذا أخذت هذا المبلغ من الرحلة؟»** — يفتح السائق الرحلة في شاشة أرباحي فيرى: ما دفعه الراكب، المكافأة إن وُجدت، عمولة المنصة، ثم صافيه.
+
+**«خُصم مني ولا أعرف لماذا»** — بطاقة «الاقتطاعات» في شاشة الأرباح تعرض كل التزام باسمه والمتبقي عليه.
+
+---
+
+# محور اقتصاد السائق
+
+> **الفكرة الجامعة:** مشكلة السائق في أسواقنا ليست نسبة العمولة، بل **رأس المال**: الوقود يومياً، والصيانة فجأة، والسيارة نفسها. من يحلّ له هذه المشكلة يربطه بالمنصة لسنوات — وهذا أعمق خندق تنافسي متاح لنا.
+>
+> بنينا هذا المحور على ثلاث مراحل: محرك عام يدير أي التزام مالي على السائق، ثم بوابة تحمي المنصة من الإقراض بلا استرداد، ثم أول منتج فعلي فوقهما: محفظة الوقود.
+
+---
+
+## ١) محرك الالتزامات — الأساس المشترك
+
+### ما هو
+نظام واحد يدير أي مبلغ مستحق على السائق للمنصة أو لشريك: قسط تأمين، دَين وقود، قسط صيانة، وقرض مركبة مستقبلاً.
+
+### لماذا بنيناه
+كنا أمام خيارين: بناء نظام تحصيل منفصل لكل منتج، أو نظام واحد يخدمها جميعاً. اخترنا الثاني لسببين:
+
+**الأول — سبب مباشر:** التأمين كان يسجّل الأقساط على السائقين منذ إطلاقه، **ولم يكن هناك ما يحصّلها**. الأقساط تتراكم على الورق فقط. اكتُشف هذا عند التخطيط لمحفظة الوقود.
+
+**الثاني — سبب استراتيجي:** كل منتج مالي جديد كان سيحتاج نسخة من أخطر منطق في المنصة، وهو الذي يلمس مال السائق. أربع نسخ = أربعة أضعاف احتمال الخطأ.
+
+### كيف يعمل — بثلاث خطوات
+1. **التسجيل:** المنتج يسجّل «على السائق كذا» (قسط اليوم، قيمة الوقود الذي أخذه).
+2. **الدفتر:** كل هذه المبالغ تدخل سجلاً واحداً دائماً، لا يُمحى منه شيء.
+3. **التحصيل:** برنامج واحد يعمل كل ليلة الساعة ١١:٣٠ مساءً، يمرّ على الدفتر ويخصم من أرباح السائق.
+
+**نقطة مهمة:** لا يوجد في المنصة كلها إلا جهة واحدة تخصم من رصيد السائق. أي منتج جديد يسجّل مبلغاً فقط ولا يلمس المال.
+
+### السياسات الرقمية
+
+| القاعدة | القيمة | لماذا |
+|---|---|---|
+| سقف الخصم اليومي | نسبة من **أرباح اليوم** لا مبلغ ثابت | حتى لا يجد السائق محفظته صفراً في يوم ضعيف |
+| أرضية الرصيد | لا يُدفع السائق إلى السالب أبداً | الرصيد السالب يوقفه عن العمل، فيتعذّر السداد أصلاً |
+| المتبقي | يُرحَّل لليوم التالي، **لا يُسقَط** | الدَّين لا يضيع، لكنه لا يُجبى بالقوة |
+| ترتيب الأولوية | التأمين ← الوقود ← الصيانة | انقطاع التأمين يُفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يضرّ أحداً |
+| السقف مشترك | ثلاثة منتجات لا تأخذ ثلاثة أسقف | وإلا اقتُطع ٧٥٪ من يوم السائق |
+
+### لخدمة العملاء
+
+**«لماذا خُصم مني مبلغ اليوم؟»** — عليك التزام مسجّل (تأمين أو وقود). الخصم نسبة من أرباح اليوم لا مبلغ ثابت. التفاصيل كاملة في شاشة المنتج داخل التطبيق.
+
+**«خُصم مني أقل من قيمة القسط»** — هذا مقصود. النظام لا يأخذ أكثر من النسبة المسموحة من دخل اليوم. الباقي يُلاحَق غداً بلا أي غرامة تأخير.
+
+**«لم يُخصم مني شيء اليوم»** — لم تدخل أرباح إلى محفظتك اليوم، أو دخلت بمبلغ صغير. لا شيء مطلوب من السائق.
+
+**«ألغيت اشتراكي، هل يسقط ما عليّ؟»** — لا. الإلغاء يوقف الأقساط **القادمة** ولا يمسّ الماضي. أيام التغطية التي استفاد منها السائق مستحقة.
+
+---
+
+## ٢) بوابة الائتمان — من يستحق أن نموّله
+
+### ما هي
+شرط إضافي قبل منح السائق أي التزام مالي: **يجب أن تمرّ أرباحه بمحفظة سيرو**.
+
+### لماذا أضفناها
+سقف الخصم نسبة من الأرباح المارّة بالمحفظة. سائق يحصّل نقداً ولا يمرّ ماله بالمحفظة يظهر بأرباح صفر كل يوم — فلا يُخصم منه شيء **أبداً**.
+
+هذا محتمَل في التأمين (قسط صغير، خسارة محدودة). وهو غير محتمَل في الوقود: هناك نسلّم السائق قيمةً فعلية في خزّان سيارته مقابل وعد بالسداد من قناة لا يمرّ بها ماله. **هذه هبة لا ائتمان.**
+
+### كيف تعمل
+قبل تفعيل أي منتج، يقيس النظام كم **يوماً** دخل فيه مالٌ إلى محفظة السائق خلال آخر ٣٠ يوماً.
+
+**المقياس أيام لا مبلغ** — والسبب أن السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً يعني قناة حيّة.
+
+### السياسات الرقمية
+
+| المنتج | أيام الدخل المطلوبة (من ٣٠) | لماذا هذه العتبة |
+|---|---|---|
+| التأمين | ٨ أيام | مشروط أصلاً بـ٢٠٠ رحلة وتقييم ٤.٥ — التشديد فوقها يفرغه من غرضه |
+| محفظة الوقود | ١٢ يوماً | قيمة فعلية تُسلَّم، والاسترداد الوحيد من أرباح تمرّ بالمحفظة |
+
+**قواعد إضافية:**
+- تعذّر التحقق = **رفض** لا قبول. تأجيل منح ائتمان أهون من منحه لمن لا يُسترد منه.
+- الشرط على الاشتراكات **الجديدة** فقط. لا يُسحب التزام قائم من سائق بسبب شرط سُنّ بعده.
+- العتبات قابلة للتغيير بتعديل رقم في قاعدة البيانات، بلا إصدار جديد من التطبيق.
+
+### لخدمة العملاء
+
+**«لماذا رُفض طلبي رغم أن رحلاتي كافية؟»** — الشرط ليس عدد الرحلات وحده، بل أن تمرّ الأرباح بالمحفظة. الرسالة في التطبيق تذكر العدد الحالي والمطلوب بالضبط.
+
+**«كيف أستوفي الشرط؟»** — بالعمل عبر المحفظة بدل النقد. الشرط يُعاد قياسه تلقائياً كل مرة، فلا حاجة لطلب مراجعة.
+
+**«رُفض طلبي برسالة تعذّر التحقق»** — عطل مؤقت. يُعاد المحاولة بعد قليل. ليس رفضاً نهائياً.
+
+---
+
+## ٣) محفظة الوقود — أول منتج فعلي
+
+### ما هي
+السائق يتزوّد وقوداً من محطة شريكة **بلا نقد**، والقيمة تُقيَّد ديناً عليه يُخصم من أرباحه تدريجياً.
+
+### لماذا
+الوقود هو مشكلة السائق اليومية الأولى، ونقص السيولة صباحاً يمنعه من العمل. حلّ هذه المشكلة أثره أقوى من أي زيادة في نسبته — لأننا نعالج **توقيت** المال لا مقداره. وهو أيضاً ما يجعل مغادرته إلى منافس قراراً مكلفاً.
+
+### كيف تعمل — دورة كاملة
+1. السائق يفعّل المحفظة مرة واحدة (بشرط الأهلية وبوابة الائتمان).
+2. عند الحاجة، يطلب من التطبيق قسيمة بمبلغ محدّد.
+3. يحصل على **رمز من ١٠ أرقام** صالح ٤٥ دقيقة.
+4. يعطي الرمز لعامل المحطة، والمحطة تؤكّد المبلغ المصروف فعلاً.
+5. عند التأكيد — **وليس قبله** — يُسجَّل الدَّين على السائق.
+6. محرك التسوية يحصّله ليلاً بنسبة ١٥٪ من أرباح اليوم.
+7. كلما سدّد، عاد سقفه متاحاً — كبطاقة ائتمان متجدّدة.
+
+**لماذا قسيمة مسبقة لا خصم عند المضخة؟** لأن عامل المحطة لا يملك وسيلة للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى التطبيق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا تأكيد رقم.
+
+### السياسات الرقمية
+
+| القاعدة | القيمة | لماذا |
+|---|---|---|
+| السقف المبدئي | ٣٠ (بعملة البلد) | موحّد للجميع مبدئياً؛ التدرّج بحسب السداد قرار لاحق يحتاج بيانات |
+| نسبة الخصم اليومي | ١٥٪ من أرباح اليوم | أقل من التأمين (٢٥٪) لأن دَين الوقود أكبر، وخنق السائق يعيده محتاجاً للدَّين |
+| صلاحية القسيمة | ٤٥ دقيقة | القسيمة تحجز من السقف؛ المفتوحة إلى الأبد تمنع صاحبها من التزوّد |
+| قسائم مفتوحة | واحدة فقط في كل وقت | رمزان في يد السائق = صرف المبلغ الخطأ |
+| المبلغ المصروف | لا يتجاوز المأذون | تجاوزه دَين لم يوافق عليه السائق |
+| المصروف أقل من المأذون | مسموح وطبيعي | يطلب ٢٠ ويتزوّد بـ١٥ — الدَّين على ١٥ |
+
+**حماية الأموال المدمجة:**
+- كل محطة لها مفتاح خاص مخزَّن مشفَّراً. تسريب مفتاح محطة يكشف تلك المحطة وحدها ويُبطَل فوراً.
+- صرف القسيمة مرة واحدة قطعاً. الضغط المكرّر على جهاز المحطة أو محاولة صرف مزدوج لا تُنتج ديناً مضاعفاً.
+- السائق لا يستطيع تأكيد الصرف بنفسه — هو الطرف المستفيد، وتأكيده يعني أن ينفي ما استلمه.
+
+### لخدمة العملاء
+
+**«الرمز لا يعمل عند المحطة»** — تحقّق من ثلاثة: هل مضت ٤٥ دقيقة؟ هل صُرف سابقاً؟ هل المحطة ضمن الشبكة الشريكة؟ رسالة جهاز المحطة تحدّد السبب بالضبط.
+
+**«ألغيت القسيمة، متى يعود سقفي؟»** — فوراً.
+
+**«سقفي المتاح أقل من ٣٠ رغم أني لم أستخدم شيئاً»** — إمّا عليك دَين سابق لم يُسدَّد بالكامل، أو لديك قسيمة سارية تحجز مبلغاً. الشاشة تعرض الأربعة أرقام منفصلة: السقف، المستحق، المحجوز، المتاح.
+
+**«صُرف مبلغ أقل مما طلبت»** — طبيعي. الدَّين على ما صُرف فعلاً لا ما طُلب.
+
+**«لماذا سقف الوقود ١٥٪ والتأمين ٢٥٪؟»** — دَين الوقود أكبر من قسط يومي. اقتطاع ربع الدخل لسداده يترك السائق بلا ما يكفي ليعمل غداً، فيعود محتاجاً وقوداً بالدَّين — وتلك حلقة لا تُغلق.
+
+---
+
+## ٤) التأمين على السائق (سابق — أُعيد ربطه)
+
+### ما هو
+تغطية تأمينية من شريك مرخّص، بقسط يومي أو شهري يُخصم من أرباح السائق.
+
+### الحالة
+كان مبنياً وتُسجَّل أقساطه، **بلا أي تحصيل**. بعد بناء محرك الالتزامات صار يعمل ضمنه بالكامل: الأقساط المتراكمة سابقاً صارت قابلة للتحصيل، والاشتراكات الجديدة تدخل النظام تلقائياً.
+
+### السياسات الرقمية
+
+| القاعدة | القيمة |
+|---|---|
+| الحد الأدنى للرحلات المكتملة | ٢٠٠ |
+| الحد الأدنى للتقييم | ٤.٥ |
+| الحد الأدنى لعمر الحساب | ٣٠ يوماً |
+| أيام الدخل بالمحفظة | ٨ من ٣٠ |
+| نسبة الخصم اليومي | ٢٥٪ من أرباح اليوم |
+| وثيقة نشطة لكل سائق | واحدة |
+
+**قرار المنتج:** التأمين ليس خدمة تُمنح لكل من سجّل، بل **مكافأة استمرار**. منحه لمن قد يختفي بعد أسبوع يحوّله من أداة ولاء إلى نزيف. والشروط معروضة في التطبيق لمن لم يستوفها بعد — مع تقدّمه نحوها — لأن الشرط الظاهر هدفٌ يُسعى إليه، والمخفي مجرد رفض.
+
+### لخدمة العملاء
+
+**«تبديل الخطة»** — لا يتم بصمت. إلغاء صريح ثم اشتراك جديد، ليبقى في السجل أثر لكل تغيير مالي.
+
+**«ألغيت وثيقتي وبقي عليّ مبلغ»** — نعم، أقساط أيام التغطية التي مضت. الإلغاء لا يمحو الماضي.
+
+---
+
+# ما قبل هذا المحور
+
+بنود أخرى من دراسة الفرص أُنجزت أو بدأت قبل هذا العمل، ولم تُوثَّق هنا بعد بالتفصيل نفسه:
+
+- **الحجز المسبق** — يعمل
+- **الطلب عبر SMS** (للمناطق بلا إنترنت) — البنية قائمة
+- **محرك الإسناد بالدفعات** — مبني ويمكن تشغيله بمفتاح إعداد
+- **تعويض السائق عن عدم حضور الراكب** — يعمل
+- **زر السلامة (SOS)** — مبني، وتنقصه غرفة عمليات تستقبله
+- **مواصلاتي (النقل الجامعي والمؤسسي)** — الكود شبه مكتمل، وفيه ثغرات معروفة
+
+*تُوثَّق تباعاً بالتنسيق نفسه.*
+
+---
+
+# لم يُنجز بعد من دراسة الفرص
+
+سيرو للأعمال · الطرود والمشاوير · اشتراك الراكب · إغلاق التسرّب خارج المنصة · طبقة المعالم الشعبية · الصيانة بالتقسيط · تمويل المركبات
+
+**تنبيه قائم:** أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو مباشرة — هذا الخط يقترب من التمويل المنظَّم قانونياً.
+
+---
+
+# ملاحظات للعرض على المستثمرين
+
+**ما يميّز هذا المحور:** المنافسون يتنافسون على نسبة العمولة — وهي حرب أسعار تنتهي بخسارة الجميع. نحن نعالج **رأس مال السائق** ومشكلة توقيت السيولة، وهي مشكلة لا يحلّها خفض العمولة إطلاقاً.
+
+**الأصل الحقيقي المتكوّن:** بيانات دخل السائق المؤكَّدة والمارّة بمحفظتنا. هي التي تجعلنا أقدر من أي بنك على تقييم جدارته الائتمانية — وهي المنتج الذي يُعرض على شريك التمويل لاحقاً. القرض ليس الهدف؛ البيانات هي.
+
+**الأثر على البقاء:** السائق الذي يعتمد على محفظة الوقود يومياً ينتقل من «مورّد» إلى «شريك بحساب مفتوح». مغادرته لمنافس تعني تسوية حساب، لا مجرد حذف تطبيق.
+
+**صدقٌ في العرض:** المحفظة أُطلقت بمنتج واحد (الوقود) وسقف محافظ (٣٠)، لأن التوسّع في الائتمان قبل رؤية سلوك السداد الفعلي مخاطرة لا داعي لها. التوسّع قرار بيانات لا طموح.
diff --git a/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md b/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md
new file mode 100644
index 00000000..57f675be
--- /dev/null
+++ b/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md
@@ -0,0 +1,178 @@
+# محرك الالتزامات العام — تصميم وتنفيذ
+
+> بند 3.4 من [دراسة الفرص](SIRO_OPPORTUNITY_STUDY_AR.md) — اقتصاد السائق: الوقود، الصيانة، والتمويل.
+> تاريخ التنفيذ: 2026-08-08. الحالة: الخطوتان ١ و٢ منفَّذتان (الجداول + التسوية).
+
+---
+
+## لماذا الآن — ثغرة قائمة لا تحسين مستقبلي
+
+`cron_insurance_premiums.php` يقيّد أقساط التأمين منذ إطلاقه، وتعليقه يقول إن الدفتر «تقرأه التسوية». **لم تكن التسوية موجودة.** لا مرجع واحد لجدول `insurance_premium_ledger` في المشروع كله خارج الكرون والـmigration.
+
+النتيجة العملية: كل قسط تأمين قُيّد حتى اليوم بقي بحالة `pending`، ولم يُحصَّل منه شيء.
+
+الخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة. والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق — كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة تسوية لكل منها تعني أربع نسخ من أخطر منطق في المنصة: الذي يلمس مال السائق.
+
+---
+
+## البنية
+
+فصل ثلاث مسؤوليات كان الكود يخلطها:
+
+| المسؤولية | الملف | ماذا يفعل |
+|---|---|---|
+| مُصدِر الاستحقاق | `bot/cron_insurance_premiums.php` وما يليه | يقيّد «على السائق كذا». لا يلمس مالاً |
+| الدفتر الموحّد | `obligation_ledger` | سجل دائم واحد مهما كان المصدر |
+| محرك التسوية | `bot/cron_obligation_settlement.php` | **الجهة الوحيدة** التي تخصم من الرصيد |
+
+منتج جديد = صفّ في `obligation_products` + مُصدِر استحقاق صغير. لا يلمس التسوية ولا الرصيد إطلاقاً.
+
+### الجداول
+
+| الجديد | يعمّم |
+|---|---|
+| `obligation_products` | `insurance_plans` |
+| `driver_obligations` | `driver_insurance_policies` |
+| `obligation_ledger` | `insurance_premium_ledger` |
+| `obligation_settlements` (قاعدة المحفظة) | — جديد كلياً |
+
+ثلاثة فروق جوهرية عن نموذج التأمين:
+
+**`kind`** يحدّد سلوك الاستحقاق: `recurring` يتكرّر بلا نهاية (تأمين)، `installment` له أصل ينتهي بسداده (صيانة، تمويل)، `drawdown` يُقيَّد عند السحب لا على جدول (وقود).
+
+**`amount_collected` / `amount_remaining`** في الدفتر. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود والصيانة فتحصيلهما جزئي بطبعه: قيدٌ بقيمة عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي.
+
+**`priority`** لترتيب المزاحمة: التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠). انقطاع التأمين يُلغي وثيقة ويفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يكلّف أحداً شيئاً.
+
+---
+
+## سقف الخصم اليومي — القرار الذي يقرّر نجاح الميزة
+
+الخصم نسبة من **أرباح اليوم** لا مبلغ ثابت، بسقف على مستوى المنتج (`daily_cap_percent`، افتراضي ٢٥٪) وأرضية للرصيد (`OBLIGATION_MIN_BALANCE`، افتراضي صفر).
+
+السبب: سائق عليه قسط تأمين وقرض صيانة في يوم ضعيف سيفتح التطبيق ويجد صفراً — ويهجر المنصة. المتبقي يُرحَّل، لا يُسقَط.
+
+الأساس هو دخل اليوم لا الرصيد المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من دخل حاضر يبقى محسوساً كاقتطاع من كسبٍ وقع للتوّ.
+
+**السقف مشترك بين المنتجات:** نقطة الخصم تطرح ما اقتُطع اليوم من كل المنتجات قبل حساب المتاح. بدون هذا الطرح تصير ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق.
+
+---
+
+## بوابة الائتمان — لا التزام إلا لمن تمرّ أرباحه بالمحفظة
+
+قرار المالك (2026-08-09). سببه المباشر أن سقف الخصم نسبة من الدخل المارّ بالمحفظة: **سائق الكاش الذي يحصّل نقداً سقفه صفر كل يوم، فلا يُحصَّل منه شيء أبداً.** منحه وقوداً أو صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها — أي هبة لا ائتمان.
+
+الشرط مخزَّن مع المنتج (`requires_wallet_income`, `min_wallet_days_30d`, `min_wallet_income_30d`) على غرار شروط الرحلات والتقييم القائمة، لأن العتبة تختلف بطبيعة المنتج.
+
+**المقياس أيام لا مبلغ.** السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ حيّة.
+
+**الفشل مغلق.** تعذُّر الوصول إلى سيرفر المحفظة يعني رفضاً لا قبولاً. عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد لا يُسترد منه — والخطأ الثاني وحده يكلّف مالاً.
+
+**الفحص في طبقتين.** نقطة الاستدعاء تفحص لتعرض للسائق سبباً مفهوماً وما ينقصه؛ و`obligationOpen` تفحص ثانيةً عند الكتابة كحارس أخير. النقطة قد تنسى، أو يُضاف منتج جديد بمسار جديد يُغفلها — والفحص عند الكتابة وحده هو الذي لا يمكن الالتفاف عليه بالسهو.
+
+**التأمين يخضع للبوابة بعتبة متساهلة** (ثمانية أيام، بلا شرط مبلغ): القسط الذي تتحمّله الشركة ثم تسترده ائتمانٌ بالمعنى نفسه، لكن التأمين مشروط أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها يفرغ المنتج من غرضه. تغيير العتبة صفٌّ واحد في `obligation_products`.
+
+الشرط يمسّ الاشتراكات **الجديدة** وحدها. الالتزامات القائمة لا تُفحص ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة بأثر رجعي.
+
+---
+
+## الحد بين الخادمين
+
+الرصيد في `payment_server`، القيد في القاعدة الرئيسية. لا كتابة عابرة للحدود.
+
+### لماذا نقطة خصم جديدة بدل `add_s2s_reward.php` بمبلغ سالب
+
+**`add_s2s_reward.php` لا يضمن عدم التكرار.** لا مفتاح فريد على `driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات `daily_/weekly_`. تعليق `food/admin/courier_settlement.php` يقول إن `paymentID` «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير صحيح، لا شيء يفرضه.
+
+هذا مقبول تقريباً للإيداعات (إيداع مكرّر يُسترد)، وغير مقبول إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه.
+
+ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً. فالضمان بُني خارجه، في `obligation_settlements`.
+
+### المرجع مبنيّ على عدّاد المحاولات لا على التاريخ
+
+`settlement_ref = obl_{ledgerId}_{attempts}`
+
+`attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح. فنداء نجح في المحفظة وضاع ردّه في الطريق يُعاد لاحقاً — ولو بعد أيام — بالمرجع نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ.
+
+مرجعٌ مبنيّ على التاريخ (`obl_{id}_{YYYYMMDD}`، وهو ما كُتب أولاً) كان سيصل في اليوم التالي بمرجع جديد ويخصم المبلغ مرتين في هذه الحالة بالضبط.
+
+---
+
+## التوقيت
+
+| الوقت | المهمة |
+|---|---|
+| 00:15 | مُصدِر الاستحقاق — يقيّد أقساط اليوم |
+| 23:30 | محرك التسوية — يحصّل من أرباح اليوم |
+
+التسوية **لا** تعمل بعد المُصدِر مباشرة. السقف نسبة من أرباح اليوم، وتشغيلها الساعة 00:45 يعني قراءة يومٍ عمره خمس وأربعون دقيقة: أرباحه صفر، فالسقف صفر، فلا يُخصم شيء أبداً. كان المحرك سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش.
+
+23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً: ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً.
+
+**لا خصم داخل `finish_ride_updates.php`.** المسار حرج والسائق ينتظر ردّه؛ نداء شبكي إلى سيرفر المحفظة داخله يعني تعليق شاشته على بطء طرف ثالث، وفشلاً في التسوية يظهر له كفشل في إنهاء رحلته.
+
+---
+
+## ما نُفِّذ
+
+| الملف | الدور |
+|---|---|
+| `backend/migrations/2026_08_08_obligations_engine.sql` | الجداول الثلاثة + ترحيل التأمين |
+| `backend/migrations/2026_08_09_obligation_credit_gate.sql` | أعمدة بوابة الائتمان |
+| `payment_server/v2/main/ride/driverWallet/income_summary_s2s.php` | ملخّص الدخل المارّ بالمحفظة |
+| `payment_server/migrations/2026_08_08_obligation_settlements.sql` | سجل الخصومات (ضمان عدم التكرار) |
+| `payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php` | نقطة الخصم — سقف + عدم تكرار |
+| `backend/bot/cron_obligation_settlement.php` | محرك التسوية |
+| `backend/obligations/functions.php` | فتح/إغلاق التزام وقراءة المستحق |
+| `backend/bot/cron_insurance_premiums.php` | صار مُصدِر استحقاق عاماً |
+| `backend/driver_assurance/{subscribe,cancel,get,plans}.php` | ربط بالدفتر الموحّد + بوابة الائتمان |
+| `docker/crontab.production` | جدولة التسوية |
+
+### الترحيل
+
+جداول التأمين **تبقى كما هي** — لا تُحذف ولا تُعدَّل، وتصير شاهداً تاريخياً. بياناتها تُنقل إلى النموذج العام، والقيود المعلّقة (وهي مال حقيقي تراكم بلا تحصيل) تصير مرئية لمحرك التسوية.
+
+القيود القديمة بحالة `failed` تعود `pending`: الفشل السابق لم يكن قراراً بل غياب محرك، وحجبها الآن يعني إسقاط مال مستحق فعلاً.
+
+قسم الترحيل يعمل أكثر من مرة بلا ضرر — كل إدراج مشروط بعدم وجود ما يقابله.
+
+**لماذا لم يكفِ الترحيل وحده:** `driver_assurance/subscribe.php` كان يكتب في جداوله الخاصة فقط. بقاؤه كذلك كان يعني أن كل اشتراك **جديد** لا يظهر في الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل يعالج الماضي؛ ربط `subscribe/cancel` يمنع تكرار الثغرة مستقبلاً.
+
+---
+
+## حدود معروفة — تُحسم قبل الخطوة ٤
+
+**سائق الكاش محجوب لا محصَّل منه.** بوابة الائتمان تمنع منحه التزاماً أصلاً، وهذا هو القرار المتّخذ. لكنها تعالج الدخول لا الخروج: سائق كان دخله يمرّ بالمحفظة ثم تحوّل إلى الكاش بعد فتح التزامه يبقى التزامه قائماً بسقف صفر — يتراكم في الدفتر بلا تحصيل. لا يوجد بعد ما يرصد هذا التحوّل أو ينبّه عليه.
+
+**تعارض قيد وحساب.** لو خُصم من المحفظة ولم يُحدَّث الدفتر (فشل قاعدة بيانات بعد نجاح النداء)، يُسجَّل السطر في `error_log` بصيغة صريحة ولا تُعاد المحاولة تلقائياً. المرجع الثابت يمنع الخصم المزدوج، لكن التباين بين الدفترين يحتاج عيناً بشرية. لا توجد بعد لوحة تعرض هذه الحالات.
+
+**لم يُختبر مقابل قاعدة بيانات.** كل ملفات PHP اجتازت `php -l`، أما الـmigrations فلم تُنفَّذ — لا MySQL ولا Docker على جهاز التطوير هذا. يجب تشغيلها على بيئة اختبار قبل الإنتاج.
+
+---
+
+## محفظة الوقود (الخطوة ٣ — منفَّذة)
+
+أول منتج من نوع `drawdown`: لا دورة فوترة، والاستحقاق يُقيَّد لحظة الصرف من المحطة.
+
+| الملف | الدور |
+|---|---|
+| `backend/migrations/2026_08_10_fuel_wallet.sql` | `source_ref` + `credit_limit` + المحطات + القسائم + المنتج |
+| `backend/fuel/functions.php` | حساب السقف وتوليد الرموز |
+| `backend/fuel/{enroll,get,stations,request_voucher,cancel_voucher}.php` | مسار السائق |
+| `backend/fuel/redeem.php` | مُصدِر الاستحقاق — تأكيد المحطة |
+
+**تعديل على المخطّط اقتضاه المنتج:** المفتاح الفريد `(obligation_id, charge_date)` كان يمنع قيدين لالتزام واحد في يوم واحد — وهو المطلوب للتأمين، وكاسرٌ للوقود: سائق يتزوّد مرتين في يوم يُرفض قيده الثاني فيحصل على وقود بلا دَين. أُضيف `source_ref` إلى المفتاح، فارغاً للمنتجات الدورية (فيبقى ضمانها حرفياً) ومملوءاً برقم القسيمة للوقود. سلسلة فارغة لا `NULL`: MySQL يعتبر كل `NULL` مختلفاً، فعمود قابل للتفريغ كان سيلغي الحماية عن التأمين بصمت.
+
+**`credit_limit` لا `principal_amount`:** الأصل ينفد بالسداد (قرض صيانة)، وسقف الوقود متجدّد — يُسدَّد فيعود متاحاً.
+
+**السقف المتاح** = `credit_limit` − الدَّين القائم − القسائم السارية. حجز القسائم يُطرح كالدَّين تماماً: قسيمة قد تُصرف بعد لحظة، والسماح بإصدار قسائم تتجاوز السقف يعني سائقاً يتزوّد بضعف ما يستحق.
+
+---
+
+## الخطوات التالية
+
+4. الصيانة بالتقسيط (`installment`) — المحرك يدعمها بالكامل، ينقصها منتج ومسار ورشة
+5. مؤشر الجدارة الائتمانية — قراءة فقط، بلا التزام مالي، تمهيداً لشريك التمويل
+6. رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة)
+
+تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو.
diff --git a/payment_server/migrations/2026_08_08_obligation_settlements.sql b/payment_server/migrations/2026_08_08_obligation_settlements.sql
new file mode 100644
index 00000000..1083e045
--- /dev/null
+++ b/payment_server/migrations/2026_08_08_obligation_settlements.sql
@@ -0,0 +1,49 @@
+-- ============================================================
+-- سجل خصومات الالتزامات — الطرف المالي من محرك الالتزامات
+--
+-- لماذا جدول جديد بدل إعادة استخدام `add_s2s_reward.php`؟
+--
+-- لأن تلك النقطة لا تضمن عدم التكرار. لا مفتاح فريد على
+-- `driverWallet.paymentID`، ولا فحص تكرار فيها إلا لتحديات
+-- `daily_/weekly_`. تعليق `backend/food/admin/courier_settlement.php`
+-- يقول إن paymentID «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير
+-- صحيح، لا شيء في الكود ولا في المخطّط يفرضه.
+--
+-- وهذا مقبول تقريباً للإيداعات (إيداع مكرّر خطأ يُسترد)، وغير مقبول
+-- إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه، وخصم مكرّر من
+-- رصيد سائق هو أسوأ عطل ممكن في المنصة — يفقد ثقته ولا يعود.
+--
+-- ولا يمكن ببساطة إضافة مفتاح فريد على `driverWallet.paymentID`: الجدول
+-- يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً، وإضافة القيد قد تفشل
+-- أو تكسر مسارات دفع قائمة. فالضمان يُبنى هنا، خارجه.
+--
+-- العقد: صفّ واحد لكل `settlement_ref`. محاولة ثانية بنفس المرجع لا
+-- تخصم شيئاً وتُرجع نتيجة المحاولة الأولى كما هي.
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS `obligation_settlements` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+
+ -- المرجع الذي يبنيه محرك التسوية: obl_{ledgerId}_{attempts}
+ -- مبنيّ على عدّاد المحاولات لا على التاريخ، لأن العدّاد لا يزيد إلا
+ -- بعد تحديث دفتر الالتزامات بنجاح: نداء نجح هنا وضاع ردّه في الطريق
+ -- يُعاد لاحقاً بالمرجع نفسه فيستردّ نتيجته الأولى. مرجعٌ مبنيّ على
+ -- التاريخ كان سيصل غداً بمرجع جديد ويخصم المبلغ مرتين.
+ `settlement_ref` VARCHAR(120) NOT NULL,
+
+ `driver_id` VARCHAR(100) NOT NULL,
+ `product_code` VARCHAR(60) NOT NULL,
+
+ `requested_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما طلبه المحرك',
+ `deducted_amount` DECIMAL(12,3) NOT NULL COMMENT 'ما خُصم فعلاً بعد السقف',
+
+ -- لقطة الأساس الذي حُسب عليه السقف. بدونها لا يمكن لاحقاً شرح
+ -- «لماذا خُصم ثلاثة لا خمسة؟» — والسائق سيسأل.
+ `day_earnings` DECIMAL(12,3) NOT NULL DEFAULT 0,
+ `cap_percent` DECIMAL(5,2) NOT NULL DEFAULT 0,
+
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uq_settlement_ref` (`settlement_ref`),
+ KEY `idx_driver_date` (`driver_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php b/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php
new file mode 100644
index 00000000..4459a75e
--- /dev/null
+++ b/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php
@@ -0,0 +1,198 @@
+ 100) {
+ printFailure('cap_percent must be between 0 and 100');
+ exit;
+}
+
+/**
+ * يُرجع محاولة سابقة بنفس المرجع إن وُجدت.
+ *
+ * هذه الدالة هي كل ضمان عدم التكرار: تُستدعى قبل أي عمل، وتُستدعى
+ * ثانيةً عند خرق المفتاح الفريد — لأن بين الفحص والكتابة نافذة، ونسختان
+ * من الكرون قد تعملان معاً.
+ */
+function obligationPriorAttempt(PDO $con, string $ref): ?array
+{
+ $st = $con->prepare("
+ SELECT deducted_amount, requested_amount, day_earnings, cap_percent
+ FROM obligation_settlements WHERE settlement_ref = ? LIMIT 1
+ ");
+ $st->execute([$ref]);
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+ return $row ?: null;
+}
+
+function obligationReplay(array $prior, string $ref): void
+{
+ printSuccess([
+ 'settlement_ref' => $ref,
+ 'deducted' => (float) $prior['deducted_amount'],
+ 'requested' => (float) $prior['requested_amount'],
+ 'day_earnings' => (float) $prior['day_earnings'],
+ 'cap_percent' => (float) $prior['cap_percent'],
+ 'replayed' => true,
+ ]);
+}
+
+try {
+ if ($prior = obligationPriorAttempt($con, $settlementRef)) {
+ obligationReplay($prior, $settlementRef);
+ exit;
+ }
+
+ // الرصيد الحالي: driverWallet دفتر حركات لا رصيداً مخزّناً — الرصيد
+ // هو مجموع صفوفه، والخصم صفّ بمبلغ سالب.
+ $st = $con->prepare("SELECT COALESCE(SUM(amount), 0) FROM driverWallet WHERE driverID = ?");
+ $st->execute([$driverID]);
+ $balance = (float) $st->fetchColumn();
+
+ // أرباح اليوم = الصفوف الموجبة فقط. الأساس هو ما دخل اليوم لا الرصيد
+ // المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من
+ // دخل اليوم يبقى محسوساً كاقتطاع من كسبٍ حاضر.
+ $st = $con->prepare("
+ SELECT COALESCE(SUM(amount), 0) FROM driverWallet
+ WHERE driverID = ? AND amount > 0 AND DATE(dateCreated) = CURDATE()
+ ");
+ $st->execute([$driverID]);
+ $dayEarnings = (float) $st->fetchColumn();
+
+ // ما اقتطعه المحرك اليوم من كل المنتجات. بدون هذا الطرح يصير السقف
+ // لكل منتج على حدة: ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق.
+ $st = $con->prepare("
+ SELECT COALESCE(SUM(deducted_amount), 0) FROM obligation_settlements
+ WHERE driver_id = ? AND DATE(created_at) = CURDATE()
+ ");
+ $st->execute([$driverID]);
+ $takenToday = (float) $st->fetchColumn();
+
+ $allowance = round(($dayEarnings * $capPercent / 100) - $takenToday, 3);
+
+ // أرضية الرصيد: لا يدفع المحرك سائقاً إلى السالب مهما استحقّ عليه.
+ // الدَّين المرحَّل خيار السائق يواصل به العمل؛ الرصيد السالب المفاجئ
+ // يوقفه عن العمل، فيتعذّر السداد أصلاً.
+ $minBalance = (float) (getenv('OBLIGATION_MIN_BALANCE') ?: 0);
+ $headroom = round($balance - $minBalance, 3);
+
+ $deducted = min($amount, $allowance, $headroom);
+ if ($deducted < 0) {
+ $deducted = 0.0;
+ }
+ $deducted = round($deducted, 3);
+
+ $con->beginTransaction();
+
+ // السجل يُكتب دائماً — حتى حين لا يُخصم شيء. صفر مسجَّل يعني «حاولنا
+ // اليوم ولم يكن هناك ما يُخصم منه»، وهو ما يمنع ملاحقة السائق مراراً
+ // في اليوم نفسه ويشرح لاحقاً لماذا تأخّر التحصيل.
+ $con->prepare("
+ INSERT INTO obligation_settlements
+ (settlement_ref, driver_id, product_code, requested_amount,
+ deducted_amount, day_earnings, cap_percent)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ")->execute([
+ $settlementRef, $driverID, $productCode,
+ $amount, $deducted, $dayEarnings, $capPercent,
+ ]);
+
+ if ($deducted > 0) {
+ // paymentMethod عمود varchar(20) — لا يتّسع لاسم المنتج. التفصيل
+ // يعيش في paymentID (وهو settlement_ref) وفي جدول التسويات.
+ $con->prepare("
+ INSERT INTO driverWallet (driverID, paymentID, amount, paymentMethod)
+ VALUES (?, ?, ?, 'obligation')
+ ")->execute([$driverID, $settlementRef, -$deducted]);
+ }
+
+ $con->commit();
+
+ printSuccess([
+ 'settlement_ref' => $settlementRef,
+ 'deducted' => $deducted,
+ 'requested' => $amount,
+ 'day_earnings' => $dayEarnings,
+ 'cap_percent' => $capPercent,
+ 'balance_before' => $balance,
+ 'replayed' => false,
+ ]);
+} catch (PDOException $e) {
+ if ($con->inTransaction()) {
+ $con->rollBack();
+ }
+
+ // 23000 = خرق المفتاح الفريد على settlement_ref: نسخة أخرى سبقتنا
+ // بالمرجع نفسه. ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
+ if ($e->getCode() === '23000' && ($prior = obligationPriorAttempt($con, $settlementRef))) {
+ obligationReplay($prior, $settlementRef);
+ exit;
+ }
+
+ error_log('[obligation/deduct] ' . $e->getMessage());
+ http_response_code(500);
+ printFailure('Settlement failed');
+} catch (Exception $e) {
+ if ($con->inTransaction()) {
+ $con->rollBack();
+ }
+ error_log('[obligation/deduct] ' . $e->getMessage());
+ http_response_code(500);
+ printFailure('Settlement failed');
+}
diff --git a/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php b/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php
new file mode 100644
index 00000000..ebc074b1
--- /dev/null
+++ b/payment_server/v2/main/ride/driverWallet/income_summary_s2s.php
@@ -0,0 +1,77 @@
+ 180) {
+ $windowDays = 30;
+}
+
+try {
+ // الصفوف الموجبة وحدها. السالبة خصومات (عمولة، التزامات) وعدّها
+ // هنا كان سيجعل سائقاً يُخصم منه كثيراً يبدو ذا قناة نشطة.
+ //
+ // وباستثناء صفوف الالتزامات صراحةً: 'obligation' مرجعها هذا المحرك
+ // نفسه، ولو ظهرت يوماً بمبلغ موجب (تصحيح، استرداد) لصارت البوابة
+ // تقيس أثر نفسها.
+ $st = $con->prepare("
+ SELECT
+ COUNT(DISTINCT DATE(dateCreated)) AS days_with_income,
+ COALESCE(SUM(amount), 0) AS total_income,
+ MAX(DATE(dateCreated)) AS last_income_date
+ FROM driverWallet
+ WHERE driverID = ?
+ AND amount > 0
+ AND paymentMethod <> 'obligation'
+ AND dateCreated >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ ");
+ $st->execute([$driverID, $windowDays]);
+ $row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
+
+ printSuccess([
+ 'driver_id' => $driverID,
+ 'window_days' => $windowDays,
+ 'days_with_income' => (int) ($row['days_with_income'] ?? 0),
+ 'total_income' => (float) ($row['total_income'] ?? 0),
+ 'last_income_date' => $row['last_income_date'] ?? null,
+ ]);
+} catch (PDOException $e) {
+ error_log('[obligation/income_summary] ' . $e->getMessage());
+ http_response_code(500);
+ printFailure('Failed to read income summary');
+}
diff --git a/siro_admin/lib/controller/fuel/fuel_admin_controller.dart b/siro_admin/lib/controller/fuel/fuel_admin_controller.dart
new file mode 100644
index 00000000..87694383
--- /dev/null
+++ b/siro_admin/lib/controller/fuel/fuel_admin_controller.dart
@@ -0,0 +1,129 @@
+// fuel_admin_controller.dart — تحكم شاشات محفظة الوقود (لوحة إدارة سيرو)
+import 'package:get/get.dart';
+
+import 'fuel_admin_models.dart';
+import 'fuel_admin_service.dart';
+
+class FuelAdminController extends GetxController {
+ bool isLoading = false;
+ List stations = [];
+
+ /// حالة المنتج — لا حالة الشاشة. الشاشة تعمل دائماً؛ المنتج قد يكون
+ /// مُجهَّزاً وغير مُشغَّل حتى يتم الاتفاق مع سلسلة المحطات.
+ bool productActive = false;
+
+ String searchQuery = '';
+ bool isSaving = false;
+
+ bool isLoadingDrawdowns = false;
+ FuelDrawdownsPage? drawdowns;
+
+ String? lastError;
+
+ @override
+ void onInit() {
+ super.onInit();
+ fetchStations();
+ }
+
+ Future fetchStations() async {
+ isLoading = true;
+ update();
+
+ final res = await FuelAdminService.listStations(search: searchQuery);
+ if (res.success && res.data != null) {
+ stations = res.data!.stations;
+ productActive = res.data!.productActive;
+ lastError = null;
+ } else {
+ lastError = res.message;
+ }
+
+ isLoading = false;
+ update();
+ }
+
+ /// يُرجع المفتاح الجديد عند الإنشاء، أو null عند التعديل/الفشل.
+ ///
+ /// المفتاح يظهر مرة واحدة فقط — مخزَّن مُهشَّراً على الخادم ولا سبيل
+ /// لاستعادته. الشاشة مسؤولة عن عرضه فوراً قبل أي انتقال.
+ Future saveStation({
+ int? stationId,
+ required String nameAr,
+ String? chainName,
+ String? city,
+ String? address,
+ double? lat,
+ double? lng,
+ String? phone,
+ required double discountPercent,
+ required bool isActive,
+ }) async {
+ isSaving = true;
+ update();
+
+ final res = await FuelAdminService.saveStation(
+ stationId: stationId,
+ nameAr: nameAr,
+ chainName: chainName,
+ city: city,
+ address: address,
+ lat: lat,
+ lng: lng,
+ phone: phone,
+ discountPercent: discountPercent,
+ isActive: isActive,
+ );
+
+ isSaving = false;
+ lastError = res.success ? null : res.message;
+ update();
+
+ if (!res.success) return null;
+
+ await fetchStations();
+ return res.data?['api_key']?.toString();
+ }
+
+ Future rotateKey(int stationId) async {
+ final res = await FuelAdminService.rotateKey(stationId);
+ if (!res.success) {
+ lastError = res.message;
+ update();
+ return null;
+ }
+ return res.data?['api_key']?.toString();
+ }
+
+ /// تشغيل المنتج أو إيقافه. الخادم يرفض التشغيل بلا محطة فعّالة واحدة —
+ /// سائق يفعّل محفظته ثم لا يجد أين يصرف قسيمته تجربةٌ تُفقد الثقة.
+ Future toggleProduct(bool value) async {
+ final res = await FuelAdminService.toggleProduct(value);
+ if (res.success) {
+ productActive = res.data ?? value;
+ lastError = null;
+ update();
+ return true;
+ }
+ lastError = res.message;
+ update();
+ return false;
+ }
+
+ Future fetchDrawdowns({int? stationId, String? status}) async {
+ isLoadingDrawdowns = true;
+ update();
+
+ final res =
+ await FuelAdminService.listDrawdowns(stationId: stationId, status: status);
+ if (res.success) {
+ drawdowns = res.data;
+ lastError = null;
+ } else {
+ lastError = res.message;
+ }
+
+ isLoadingDrawdowns = false;
+ update();
+ }
+}
diff --git a/siro_admin/lib/controller/fuel/fuel_admin_models.dart b/siro_admin/lib/controller/fuel/fuel_admin_models.dart
new file mode 100644
index 00000000..ac59633b
--- /dev/null
+++ b/siro_admin/lib/controller/fuel/fuel_admin_models.dart
@@ -0,0 +1,146 @@
+// fuel_admin_models.dart — نماذج بيانات محفظة الوقود (لوحة إدارة سيرو)
+
+class FuelStation {
+ final int id;
+ final String nameAr;
+ final String? chainName;
+ final String? city;
+ final String? address;
+ final double? lat;
+ final double? lng;
+ final String? phone;
+ final double discountPercent;
+ final bool isActive;
+
+ /// أرقام الأداء: محطة بلا صرف اسمٌ في جدول لا شريك يعمل.
+ final int redeemCount;
+ final double redeemTotal;
+ final String? lastRedeemAt;
+
+ FuelStation({
+ required this.id,
+ required this.nameAr,
+ required this.discountPercent,
+ required this.isActive,
+ this.chainName,
+ this.city,
+ this.address,
+ this.lat,
+ this.lng,
+ this.phone,
+ this.redeemCount = 0,
+ this.redeemTotal = 0,
+ this.lastRedeemAt,
+ });
+
+ factory FuelStation.fromJson(Map j) => FuelStation(
+ id: int.tryParse(j['id'].toString()) ?? 0,
+ nameAr: j['name_ar']?.toString() ?? '',
+ chainName: j['chain_name']?.toString(),
+ city: j['city']?.toString(),
+ address: j['address']?.toString(),
+ lat: double.tryParse(j['lat']?.toString() ?? ''),
+ lng: double.tryParse(j['lng']?.toString() ?? ''),
+ phone: j['phone']?.toString(),
+ discountPercent:
+ double.tryParse(j['discount_percent']?.toString() ?? '0') ?? 0,
+ isActive: j['is_active'] == true,
+ redeemCount: int.tryParse(j['redeem_count']?.toString() ?? '0') ?? 0,
+ redeemTotal: double.tryParse(j['redeem_total']?.toString() ?? '0') ?? 0,
+ lastRedeemAt: j['last_redeem_at']?.toString(),
+ );
+}
+
+class FuelStationsPage {
+ final List stations;
+
+ /// حالة المنتج نفسه. قرار المالك: تُجهَّز الشاشات ولا يُشغَّل المنتج إلا
+ /// بعد إتمام الاتفاق مع سلسلة المحطات — فالشاشة تعمل كاملةً بينما
+ /// المنتج مُطفأ، والفرق يجب أن يكون ظاهراً لا مخفياً.
+ final bool productActive;
+
+ FuelStationsPage({required this.stations, required this.productActive});
+
+ factory FuelStationsPage.fromJson(Map j) => FuelStationsPage(
+ stations: (j['stations'] is List)
+ ? (j['stations'] as List)
+ .map((s) => FuelStation.fromJson(Map.from(s)))
+ .toList()
+ : [],
+ productActive: j['product_active'] == true,
+ );
+}
+
+class FuelVoucher {
+ final int id;
+ final String driverId;
+ final String code;
+ final double amountAuthorized;
+ final double amountRedeemed;
+ final String currency;
+ final String status;
+ final String? station;
+ final String? redeemedAt;
+ final String? createdAt;
+
+ FuelVoucher({
+ required this.id,
+ required this.driverId,
+ required this.code,
+ required this.amountAuthorized,
+ required this.amountRedeemed,
+ required this.currency,
+ required this.status,
+ this.station,
+ this.redeemedAt,
+ this.createdAt,
+ });
+
+ factory FuelVoucher.fromJson(Map j) => FuelVoucher(
+ id: int.tryParse(j['id'].toString()) ?? 0,
+ driverId: j['driver_id']?.toString() ?? '',
+ code: j['code']?.toString() ?? '',
+ amountAuthorized:
+ double.tryParse(j['amount_authorized']?.toString() ?? '0') ?? 0,
+ amountRedeemed:
+ double.tryParse(j['amount_redeemed']?.toString() ?? '0') ?? 0,
+ currency: j['currency']?.toString() ?? '',
+ status: j['status']?.toString() ?? '',
+ station: j['station']?.toString(),
+ redeemedAt: j['redeemed_at']?.toString(),
+ createdAt: j['created_at']?.toString(),
+ );
+}
+
+class FuelDrawdownsPage {
+ final List vouchers;
+ final double lentTotal;
+ final double collectedTotal;
+ final double outstandingTotal;
+ final int driversCount;
+
+ FuelDrawdownsPage({
+ required this.vouchers,
+ required this.lentTotal,
+ required this.collectedTotal,
+ required this.outstandingTotal,
+ required this.driversCount,
+ });
+
+ factory FuelDrawdownsPage.fromJson(Map j) {
+ final s = Map.from(j['summary'] ?? {});
+ return FuelDrawdownsPage(
+ vouchers: (j['vouchers'] is List)
+ ? (j['vouchers'] as List)
+ .map((v) => FuelVoucher.fromJson(Map.from(v)))
+ .toList()
+ : [],
+ lentTotal: double.tryParse(s['lent_total']?.toString() ?? '0') ?? 0,
+ collectedTotal:
+ double.tryParse(s['collected_total']?.toString() ?? '0') ?? 0,
+ outstandingTotal:
+ double.tryParse(s['outstanding_total']?.toString() ?? '0') ?? 0,
+ driversCount: int.tryParse(s['drivers_count']?.toString() ?? '0') ?? 0,
+ );
+ }
+}
diff --git a/siro_admin/lib/controller/fuel/fuel_admin_service.dart b/siro_admin/lib/controller/fuel/fuel_admin_service.dart
new file mode 100644
index 00000000..bba3ab78
--- /dev/null
+++ b/siro_admin/lib/controller/fuel/fuel_admin_service.dart
@@ -0,0 +1,127 @@
+// fuel_admin_service.dart — طبقة الاتصال بـ backend/Admin/fuel
+//
+// محفظة الوقود: السائق يتزوّد بلا نقد، والقيمة تُقيَّد ديناً يُخصم من
+// أرباحه. هذه الشاشة تدير المحطات الشريكة وتشغّل المنتج أو توقفه.
+import '../functions/crud.dart';
+import '../../constant/links.dart';
+import 'fuel_admin_models.dart';
+
+class FuelApiResult {
+ final bool success;
+ final T? data;
+ final String message;
+ FuelApiResult(this.success, this.data, this.message);
+}
+
+class FuelAdminService {
+ static String get _base => '${AppLink.server}/Admin/fuel';
+
+ static String _errMsg(dynamic res) {
+ if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
+ if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
+ if (res is Map && res['message'] is String) return res['message'];
+ return 'حدث خطأ، حاول مجدداً';
+ }
+
+ static Future> listStations({
+ String? search,
+ String? city,
+ bool onlyActive = false,
+ }) async {
+ final payload = {};
+ if (search != null && search.isNotEmpty) payload['search'] = search;
+ if (city != null && city.isNotEmpty) payload['city'] = city;
+ if (onlyActive) payload['only_active'] = '1';
+
+ final res = await CRUD().post(link: '$_base/list.php', payload: payload);
+
+ if (res is Map && res['status'] == 'success' && res['message'] is Map) {
+ return FuelApiResult(
+ true,
+ FuelStationsPage.fromJson(Map.from(res['message'])),
+ 'ok',
+ );
+ }
+ return FuelApiResult(false, null, _errMsg(res));
+ }
+
+ /// يُرجع `api_key` عند الإنشاء فقط — مخزَّن مُهشَّراً على الخادم، فلا
+ /// سبيل لعرضه ثانيةً. الشاشة مسؤولة عن إظهاره فوراً وبوضوح.
+ static Future>> saveStation({
+ int? stationId,
+ required String nameAr,
+ String? chainName,
+ String? city,
+ String? address,
+ double? lat,
+ double? lng,
+ String? phone,
+ required double discountPercent,
+ required bool isActive,
+ }) async {
+ final res = await CRUD().post(link: '$_base/save.php', payload: {
+ if (stationId != null) 'station_id': stationId.toString(),
+ 'name_ar': nameAr,
+ if (chainName != null && chainName.isNotEmpty) 'chain_name': chainName,
+ if (city != null && city.isNotEmpty) 'city': city,
+ if (address != null && address.isNotEmpty) 'address': address,
+ if (lat != null) 'lat': lat.toString(),
+ if (lng != null) 'lng': lng.toString(),
+ if (phone != null && phone.isNotEmpty) 'phone': phone,
+ 'discount_percent': discountPercent.toString(),
+ 'is_active': isActive ? '1' : '0',
+ });
+
+ if (res is Map && res['status'] == 'success') {
+ return FuelApiResult(
+ true,
+ Map.from(res['message'] ?? {}),
+ res['message'] is Map ? 'ok' : (res['message']?.toString() ?? 'ok'),
+ );
+ }
+ return FuelApiResult(false, null, _errMsg(res));
+ }
+
+ static Future>> rotateKey(int stationId) async {
+ final res = await CRUD().post(
+ link: '$_base/rotate_key.php',
+ payload: {'station_id': stationId.toString()},
+ );
+ if (res is Map && res['status'] == 'success' && res['message'] is Map) {
+ return FuelApiResult(true, Map.from(res['message']), 'ok');
+ }
+ return FuelApiResult(false, null, _errMsg(res));
+ }
+
+ static Future> toggleProduct(bool isActive) async {
+ final res = await CRUD().post(
+ link: '$_base/toggle_product.php',
+ payload: {'is_active': isActive ? '1' : '0'},
+ );
+ if (res is Map && res['status'] == 'success' && res['message'] is Map) {
+ return FuelApiResult(
+ true, res['message']['product_active'] == true, 'ok');
+ }
+ return FuelApiResult(false, null, _errMsg(res));
+ }
+
+ static Future> listDrawdowns({
+ int? stationId,
+ String? status,
+ }) async {
+ final payload = {};
+ if (stationId != null) payload['station_id'] = stationId.toString();
+ if (status != null && status.isNotEmpty) payload['status'] = status;
+
+ final res = await CRUD().post(link: '$_base/drawdowns.php', payload: payload);
+
+ if (res is Map && res['status'] == 'success' && res['message'] is Map) {
+ return FuelApiResult(
+ true,
+ FuelDrawdownsPage.fromJson(Map.from(res['message'])),
+ 'ok',
+ );
+ }
+ return FuelApiResult(false, null, _errMsg(res));
+ }
+}
diff --git a/siro_admin/lib/views/admin/admin_home_page.dart b/siro_admin/lib/views/admin/admin_home_page.dart
index 069de1ba..8727870a 100644
--- a/siro_admin/lib/views/admin/admin_home_page.dart
+++ b/siro_admin/lib/views/admin/admin_home_page.dart
@@ -42,6 +42,7 @@ import 'analytics/live_analytics_page.dart';
import 'package:siro_admin/views/widgets/responsive_layout.dart';
import 'package:siro_admin/views/widgets/web_sidebar.dart';
import '../transit/org_list_page.dart';
+import 'fuel/fuel_stations_page.dart';
import 'package:siro_admin/views/widgets/glass_container.dart';
class AdminHomePage extends StatefulWidget {
@@ -1352,6 +1353,13 @@ class _AdminHomePageState extends State
() => Get.to(() => const TransitOrgListPage())),
],
),
+ ActionCategory(
+ title: 'اقتصاد السائق',
+ items: [
+ ActionItem('محطات الوقود', Icons.local_gas_station_rounded, const Color(0xFF10B981),
+ () => Get.to(() => const FuelStationsPageView())),
+ ],
+ ),
ActionCategory(
title: 'إدارة النظام الجديد',
items: [
diff --git a/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart b/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart
new file mode 100644
index 00000000..05a5345c
--- /dev/null
+++ b/siro_admin/lib/views/admin/fuel/fuel_drawdowns_page.dart
@@ -0,0 +1,174 @@
+// fuel_drawdowns_page.dart — حركة القسائم وملخّص الائتمان
+//
+// الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وكم بقي في الشارع؟
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../../../controller/fuel/fuel_admin_controller.dart';
+import '../../../controller/fuel/fuel_admin_models.dart';
+
+class FuelDrawdownsPageView extends StatefulWidget {
+ const FuelDrawdownsPageView({super.key});
+
+ @override
+ State createState() => _FuelDrawdownsPageViewState();
+}
+
+class _FuelDrawdownsPageViewState extends State {
+ String? _status;
+
+ @override
+ void initState() {
+ super.initState();
+ Get.find().fetchDrawdowns();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = Theme.of(context).colorScheme;
+
+ return GetBuilder(
+ builder: (c) => Scaffold(
+ backgroundColor: cs.surface,
+ appBar: AppBar(
+ backgroundColor: cs.surface,
+ foregroundColor: cs.onSurface,
+ title: const Text('حركة القسائم'),
+ ),
+ body: Column(
+ children: [
+ if (c.drawdowns != null) _summary(c.drawdowns!, cs),
+ _filters(c, cs),
+ Expanded(
+ child: c.isLoadingDrawdowns
+ ? Center(child: CircularProgressIndicator(color: cs.primary))
+ : (c.drawdowns?.vouchers.isEmpty ?? true)
+ ? Center(
+ child: Text('لا توجد قسائم',
+ style: TextStyle(color: cs.onSurfaceVariant)))
+ : ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ itemCount: c.drawdowns!.vouchers.length,
+ itemBuilder: (_, i) =>
+ _voucherCard(c.drawdowns!.vouchers[i], cs),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// الملخّص على كامل المنتج لا على القائمة المعروضة: رقم يتغيّر بتغيّر
+ /// الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
+ Widget _summary(FuelDrawdownsPage d, ColorScheme cs) => Container(
+ margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: cs.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Row(
+ children: [
+ _stat('أُقرض', d.lentTotal.toString(), cs.onSurface, cs),
+ _stat('استُرد', d.collectedTotal.toString(), cs.primary, cs),
+ _stat('قائم', d.outstandingTotal.toString(), cs.tertiary, cs),
+ _stat('سائق', d.driversCount.toString(), cs.onSurfaceVariant, cs),
+ ],
+ ),
+ );
+
+ Widget _stat(String label, String value, Color color, ColorScheme cs) =>
+ Expanded(
+ child: Column(
+ children: [
+ Text(value,
+ style: TextStyle(
+ color: color, fontSize: 16, fontWeight: FontWeight.w700)),
+ const SizedBox(height: 2),
+ Text(label,
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)),
+ ],
+ ),
+ );
+
+ Widget _filters(FuelAdminController c, ColorScheme cs) {
+ const options = {
+ null: 'الكل',
+ 'issued': 'سارية',
+ 'redeemed': 'مصروفة',
+ 'expired': 'منتهية',
+ 'cancelled': 'ملغاة',
+ };
+
+ return SizedBox(
+ height: 46,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ children: options.entries.map((e) {
+ final selected = _status == e.key;
+ return Padding(
+ padding: const EdgeInsets.only(left: 8),
+ child: ChoiceChip(
+ label: Text(e.value),
+ selected: selected,
+ onSelected: (_) {
+ setState(() => _status = e.key);
+ c.fetchDrawdowns(status: e.key);
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ );
+ }
+
+ Widget _voucherCard(FuelVoucher v, ColorScheme cs) {
+ final (color, label) = switch (v.status) {
+ 'redeemed' => (cs.primary, 'مصروفة'),
+ 'issued' => (cs.tertiary, 'سارية'),
+ 'cancelled' => (cs.error, 'ملغاة'),
+ _ => (cs.onSurfaceVariant, 'منتهية'),
+ };
+
+ // المصروف لا المأذون: السائق قد يطلب عشرين ويتزوّد بخمسة عشر،
+ // والدَّين على ما صُرف. عرض المأذون هنا يضخّم الأرقام في عين القارئ.
+ final amount = v.status == 'redeemed' ? v.amountRedeemed : v.amountAuthorized;
+
+ return Card(
+ color: cs.surfaceContainerHighest,
+ margin: const EdgeInsets.only(bottom: 8),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: ListTile(
+ title: Row(
+ children: [
+ Text('$amount ${v.currency}',
+ style: TextStyle(
+ color: cs.onSurface, fontWeight: FontWeight.bold)),
+ const SizedBox(width: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text(label, style: TextStyle(color: color, fontSize: 11)),
+ ),
+ ],
+ ),
+ subtitle: Padding(
+ padding: const EdgeInsets.only(top: 4),
+ child: Text(
+ [
+ 'سائق ${v.driverId}',
+ if (v.station != null && v.station!.isNotEmpty) v.station!,
+ v.redeemedAt ?? v.createdAt ?? '',
+ ].where((s) => s.isNotEmpty).join(' · '),
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/siro_admin/lib/views/admin/fuel/fuel_station_form.dart b/siro_admin/lib/views/admin/fuel/fuel_station_form.dart
new file mode 100644
index 00000000..56fb732f
--- /dev/null
+++ b/siro_admin/lib/views/admin/fuel/fuel_station_form.dart
@@ -0,0 +1,212 @@
+// fuel_station_form.dart — إضافة محطة أو تعديلها
+//
+// يُرجع مفتاح المحطة الجديد عبر Get.back عند الإنشاء، وnull عند التعديل.
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../../../controller/fuel/fuel_admin_controller.dart';
+import '../../../controller/fuel/fuel_admin_models.dart';
+
+class FuelStationForm extends StatefulWidget {
+ final FuelStation? station;
+ const FuelStationForm({super.key, this.station});
+
+ @override
+ State createState() => _FuelStationFormState();
+}
+
+class _FuelStationFormState extends State {
+ final _formKey = GlobalKey();
+
+ late final TextEditingController _name;
+ late final TextEditingController _chain;
+ late final TextEditingController _city;
+ late final TextEditingController _address;
+ late final TextEditingController _phone;
+ late final TextEditingController _lat;
+ late final TextEditingController _lng;
+ late final TextEditingController _discount;
+
+ late bool _isActive;
+
+ bool get _isEdit => widget.station != null;
+
+ @override
+ void initState() {
+ super.initState();
+ final s = widget.station;
+ _name = TextEditingController(text: s?.nameAr ?? '');
+ _chain = TextEditingController(text: s?.chainName ?? '');
+ _city = TextEditingController(text: s?.city ?? '');
+ _address = TextEditingController(text: s?.address ?? '');
+ _phone = TextEditingController(text: s?.phone ?? '');
+ _lat = TextEditingController(text: s?.lat?.toString() ?? '');
+ _lng = TextEditingController(text: s?.lng?.toString() ?? '');
+ _discount = TextEditingController(text: (s?.discountPercent ?? 0).toString());
+ _isActive = s?.isActive ?? true;
+ }
+
+ @override
+ void dispose() {
+ for (final c in [_name, _chain, _city, _address, _phone, _lat, _lng, _discount]) {
+ c.dispose();
+ }
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = Theme.of(context).colorScheme;
+ final c = Get.find();
+
+ return Scaffold(
+ backgroundColor: cs.surface,
+ appBar: AppBar(
+ backgroundColor: cs.surface,
+ foregroundColor: cs.onSurface,
+ title: Text(_isEdit ? 'تعديل محطة' : 'محطة جديدة'),
+ ),
+ body: Form(
+ key: _formKey,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _field(cs, _name, 'اسم المحطة *', required: true),
+ _field(cs, _chain, 'السلسلة (إن كانت فرعاً)'),
+ _field(cs, _city, 'المدينة'),
+ _field(cs, _address, 'العنوان'),
+ _field(cs, _phone, 'الهاتف', keyboard: TextInputType.phone),
+ Row(
+ children: [
+ Expanded(
+ child: _field(cs, _lat, 'خط العرض',
+ keyboard: TextInputType.number)),
+ const SizedBox(width: 10),
+ Expanded(
+ child: _field(cs, _lng, 'خط الطول',
+ keyboard: TextInputType.number)),
+ ],
+ ),
+ _field(
+ cs,
+ _discount,
+ 'نسبة خصم الشريك (٪)',
+ keyboard: TextInputType.number,
+ validator: (v) {
+ final d = double.tryParse(v ?? '');
+ if (d == null) return 'أدخل رقماً';
+ if (d < 0 || d > 100) return 'بين 0 و 100';
+ return null;
+ },
+ ),
+ // الخصم يُسجَّل مع كل سحبة لا يُقرأ لاحقاً من هنا: النسبة
+ // تتغيّر بالتفاوض، وحساب تسويات الماضي بنسبة اليوم تزوير.
+ Padding(
+ padding: const EdgeInsets.only(right: 4, bottom: 12),
+ child: Text(
+ 'يُسجَّل مع كل عملية صرف. تعديله لا يغيّر عمليات سابقة.',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
+ ),
+ ),
+ SwitchListTile(
+ value: _isActive,
+ activeThumbColor: cs.primary,
+ contentPadding: EdgeInsets.zero,
+ title: Text('محطة فعّالة', style: TextStyle(color: cs.onSurface)),
+ subtitle: Text(
+ _isActive
+ ? 'تظهر للسائقين وتستطيع تأكيد القسائم'
+ : 'مخفيّة عن السائقين ولا تستطيع تأكيد أي قسيمة',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
+ ),
+ onChanged: (v) => setState(() => _isActive = v),
+ ),
+ const SizedBox(height: 20),
+ SizedBox(
+ height: 48,
+ child: ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: cs.primary,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12)),
+ ),
+ onPressed: c.isSaving ? null : () => _submit(c),
+ child: c.isSaving
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2))
+ : Text(_isEdit ? 'حفظ التعديلات' : 'إنشاء المحطة',
+ style: TextStyle(color: cs.onPrimary)),
+ ),
+ ),
+ if (!_isEdit)
+ Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: Text(
+ 'سيُولَّد مفتاح خاص بالمحطة عند الإنشاء، ويظهر مرة واحدة فقط.',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _field(
+ ColorScheme cs,
+ TextEditingController ctrl,
+ String label, {
+ bool required = false,
+ TextInputType? keyboard,
+ String? Function(String?)? validator,
+ }) =>
+ Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: TextFormField(
+ controller: ctrl,
+ keyboardType: keyboard,
+ style: TextStyle(color: cs.onSurface),
+ validator: validator ??
+ (required
+ ? (v) => (v == null || v.trim().isEmpty) ? 'مطلوب' : null
+ : null),
+ decoration: InputDecoration(
+ labelText: label,
+ labelStyle: TextStyle(color: cs.onSurfaceVariant),
+ filled: true,
+ fillColor: cs.surfaceContainerHighest,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ ),
+ ),
+ );
+
+ Future _submit(FuelAdminController c) async {
+ if (!(_formKey.currentState?.validate() ?? false)) return;
+
+ final key = await c.saveStation(
+ stationId: widget.station?.id,
+ nameAr: _name.text.trim(),
+ chainName: _chain.text.trim(),
+ city: _city.text.trim(),
+ address: _address.text.trim(),
+ lat: double.tryParse(_lat.text.trim()),
+ lng: double.tryParse(_lng.text.trim()),
+ phone: _phone.text.trim(),
+ discountPercent: double.tryParse(_discount.text.trim()) ?? 0,
+ isActive: _isActive,
+ );
+
+ if (c.lastError != null) {
+ Get.snackbar('تعذّر الحفظ', c.lastError!);
+ return;
+ }
+
+ Get.back(result: key);
+ }
+}
diff --git a/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart b/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart
new file mode 100644
index 00000000..5aea7c8a
--- /dev/null
+++ b/siro_admin/lib/views/admin/fuel/fuel_stations_page.dart
@@ -0,0 +1,402 @@
+// fuel_stations_page.dart — إدارة محطات الوقود الشريكة
+//
+// الشاشة تعمل كاملةً حتى والمنتج مُطفأ: قرار المالك أن تُجهَّز الأمور
+// وتُبنى الشاشات، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة
+// المحطات. لذلك اللافتة العلوية تقول الحالة صراحةً بدل إخفائها.
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:get/get.dart';
+
+import '../../../controller/fuel/fuel_admin_controller.dart';
+import '../../../controller/fuel/fuel_admin_models.dart';
+import 'fuel_drawdowns_page.dart';
+import 'fuel_station_form.dart';
+
+class FuelStationsPageView extends StatefulWidget {
+ const FuelStationsPageView({super.key});
+
+ @override
+ State createState() => _FuelStationsPageViewState();
+}
+
+class _FuelStationsPageViewState extends State {
+ final _searchCtrl = TextEditingController();
+
+ @override
+ void initState() {
+ super.initState();
+ Get.put(FuelAdminController());
+ }
+
+ @override
+ void dispose() {
+ _searchCtrl.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final cs = Theme.of(context).colorScheme;
+
+ return GetBuilder(
+ builder: (c) => Scaffold(
+ backgroundColor: cs.surface,
+ body: Column(
+ children: [
+ Container(
+ padding: const EdgeInsets.fromLTRB(16, 50, 16, 12),
+ child: Row(
+ children: [
+ GestureDetector(
+ onTap: () => Get.back(),
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: cs.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: cs.outline),
+ ),
+ child: Icon(Icons.arrow_back_ios_new_rounded,
+ color: cs.onSurfaceVariant, size: 16),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Text('محفظة الوقود — المحطات',
+ style: TextStyle(
+ color: cs.onSurface,
+ fontSize: 18,
+ fontWeight: FontWeight.w700)),
+ const Spacer(),
+ IconButton(
+ icon: Icon(Icons.receipt_long_rounded, color: cs.tertiary),
+ tooltip: 'حركة القسائم',
+ onPressed: () => Get.to(() => const FuelDrawdownsPageView()),
+ ),
+ IconButton(
+ icon: Icon(Icons.add, color: cs.primary),
+ tooltip: 'إضافة محطة',
+ onPressed: () => _openForm(c, null),
+ ),
+ ],
+ ),
+ ),
+ _productBanner(c, cs),
+ Padding(
+ padding: const EdgeInsets.all(12),
+ child: TextField(
+ controller: _searchCtrl,
+ style: TextStyle(color: cs.onSurface),
+ onSubmitted: (v) {
+ c.searchQuery = v;
+ c.fetchStations();
+ },
+ decoration: InputDecoration(
+ hintText: 'ابحث باسم المحطة أو السلسلة أو العنوان...',
+ hintStyle: TextStyle(color: cs.onSurfaceVariant),
+ prefixIcon: Icon(Icons.search, color: cs.onSurfaceVariant),
+ filled: true,
+ fillColor: cs.surfaceContainerHighest,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: c.isLoading
+ ? Center(child: CircularProgressIndicator(color: cs.primary))
+ : c.stations.isEmpty
+ ? _emptyState(cs)
+ : RefreshIndicator(
+ onRefresh: c.fetchStations,
+ child: ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ itemCount: c.stations.length,
+ itemBuilder: (_, i) =>
+ _stationCard(c, c.stations[i], cs),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ /// لافتة الحالة. «مُجهَّز وغير مُشغَّل» حالة مقصودة لا عطل — وإخفاؤها
+ /// يجعل من يعرض المنصة يظنّ المنتج حياً، أو يظنّه معطوباً.
+ Widget _productBanner(FuelAdminController c, ColorScheme cs) {
+ final active = c.productActive;
+ final color = active ? cs.primary : cs.tertiary;
+
+ return Container(
+ margin: const EdgeInsets.symmetric(horizontal: 12),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: color.withValues(alpha: 0.4)),
+ ),
+ child: Row(
+ children: [
+ Icon(active ? Icons.check_circle_rounded : Icons.pause_circle_rounded,
+ color: color, size: 20),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(active ? 'المنتج مُشغَّل' : 'مُجهَّز — غير مُشغَّل',
+ style: TextStyle(
+ color: color, fontWeight: FontWeight.w700, fontSize: 14)),
+ const SizedBox(height: 2),
+ Text(
+ active
+ ? 'السائقون المؤهَّلون يستطيعون التفعيل وإصدار القسائم'
+ : 'الشاشة تعمل للتجهيز والعرض. لا تفعيل ولا قسائم للسائقين.',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
+ ),
+ ],
+ ),
+ ),
+ Switch(
+ value: active,
+ activeThumbColor: cs.primary,
+ onChanged: (v) => _confirmToggle(c, v),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// تأكيد قبل التشغيل لا بعده. التشغيل يفتح باب إقراض حقيقي بمال
+ /// الشركة، ومفتاح يُقلب بالخطأ في عرضٍ أمام مستثمر ليس أمراً هيّناً.
+ Future _confirmToggle(FuelAdminController c, bool value) async {
+ final cs = Theme.of(context).colorScheme;
+
+ final ok = await Get.dialog(
+ AlertDialog(
+ backgroundColor: cs.surfaceContainerHighest,
+ title: Text(value ? 'تشغيل محفظة الوقود؟' : 'إيقاف محفظة الوقود؟',
+ style: TextStyle(color: cs.onSurface)),
+ content: Text(
+ value
+ ? 'سيتمكّن السائقون المؤهَّلون من التزوّد بالدَّين فوراً. لا تُشغّله قبل إتمام الاتفاق مع المحطات.'
+ : 'سيتوقّف التفعيل وإصدار القسائم. الالتزامات القائمة ودفترها لا تتأثر.',
+ style: TextStyle(color: cs.onSurfaceVariant),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Get.back(result: false),
+ child: const Text('إلغاء')),
+ TextButton(
+ onPressed: () => Get.back(result: true),
+ child: Text(value ? 'تشغيل' : 'إيقاف',
+ style: TextStyle(color: value ? cs.primary : cs.error)),
+ ),
+ ],
+ ),
+ );
+
+ if (ok != true) return;
+
+ final done = await c.toggleProduct(value);
+ if (!done && c.lastError != null) {
+ Get.snackbar('تعذّر التنفيذ', c.lastError!,
+ backgroundColor: cs.error.withValues(alpha: 0.15));
+ }
+ }
+
+ Widget _emptyState(ColorScheme cs) => Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.local_gas_station_rounded,
+ size: 48, color: cs.onSurfaceVariant),
+ const SizedBox(height: 12),
+ Text('لا توجد محطات بعد',
+ style: TextStyle(color: cs.onSurface, fontSize: 16)),
+ const SizedBox(height: 6),
+ Text('أضف أول محطة شريكة لتجهيز المنتج',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
+ ],
+ ),
+ );
+
+ Widget _stationCard(FuelAdminController c, FuelStation s, ColorScheme cs) {
+ final statusColor = s.isActive ? cs.primary : cs.onSurfaceVariant;
+
+ return Card(
+ color: cs.surfaceContainerHighest,
+ margin: const EdgeInsets.only(bottom: 10),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
+ child: ListTile(
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ title: Row(
+ children: [
+ Expanded(
+ child: Text(s.nameAr,
+ style: TextStyle(
+ color: cs.onSurface, fontWeight: FontWeight.bold)),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: statusColor.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text(s.isActive ? 'فعّالة' : 'موقوفة',
+ style: TextStyle(color: statusColor, fontSize: 11)),
+ ),
+ ],
+ ),
+ subtitle: Padding(
+ padding: const EdgeInsets.only(top: 6),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ [
+ if (s.chainName != null && s.chainName!.isNotEmpty) s.chainName!,
+ if (s.city != null && s.city!.isNotEmpty) s.city!,
+ ].join(' · '),
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
+ ),
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ Icon(Icons.percent_rounded, size: 13, color: cs.tertiary),
+ const SizedBox(width: 2),
+ Text('خصم ${s.discountPercent}',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
+ const SizedBox(width: 12),
+ Icon(Icons.local_gas_station, size: 13, color: cs.onSurfaceVariant),
+ const SizedBox(width: 2),
+ Text('${s.redeemCount} صرف',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
+ const SizedBox(width: 12),
+ Text('${s.redeemTotal}',
+ style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
+ ],
+ ),
+ ],
+ ),
+ ),
+ trailing: PopupMenuButton(
+ color: cs.surfaceContainerHighest,
+ icon: Icon(Icons.more_vert, color: cs.onSurfaceVariant, size: 18),
+ onSelected: (v) async {
+ if (v == 'edit') {
+ _openForm(c, s);
+ } else if (v == 'rotate') {
+ await _rotateKey(c, s);
+ }
+ },
+ itemBuilder: (_) => [
+ PopupMenuItem(
+ value: 'edit',
+ child: Text('تعديل', style: TextStyle(color: cs.onSurface))),
+ PopupMenuItem(
+ value: 'rotate',
+ child: Text('توليد مفتاح جديد',
+ style: TextStyle(color: cs.error))),
+ ],
+ ),
+ onTap: () => _openForm(c, s),
+ ),
+ );
+ }
+
+ Future _openForm(FuelAdminController c, FuelStation? station) async {
+ final apiKey = await Get.to(
+ () => FuelStationForm(station: station),
+ );
+ if (apiKey != null && apiKey.isNotEmpty) {
+ await _showKey(apiKey, station?.nameAr ?? 'المحطة الجديدة');
+ }
+ }
+
+ Future _rotateKey(FuelAdminController c, FuelStation s) async {
+ final cs = Theme.of(context).colorScheme;
+
+ final ok = await Get.dialog(
+ AlertDialog(
+ backgroundColor: cs.surfaceContainerHighest,
+ title: Text('توليد مفتاح جديد؟', style: TextStyle(color: cs.onSurface)),
+ content: Text(
+ 'المفتاح الحالي لـ«${s.nameAr}» سيتوقّف فوراً، ولن تستطيع المحطة تأكيد أي قسيمة حتى تستلم الجديد.',
+ style: TextStyle(color: cs.onSurfaceVariant),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Get.back(result: false),
+ child: const Text('إلغاء')),
+ TextButton(
+ onPressed: () => Get.back(result: true),
+ child: Text('توليد', style: TextStyle(color: cs.error))),
+ ],
+ ),
+ );
+
+ if (ok != true) return;
+
+ final key = await c.rotateKey(s.id);
+ if (key != null) {
+ await _showKey(key, s.nameAr);
+ } else if (c.lastError != null) {
+ Get.snackbar('تعذّر التوليد', c.lastError!,
+ backgroundColor: cs.error.withValues(alpha: 0.15));
+ }
+ }
+
+ /// المفتاح يظهر مرة واحدة — مخزَّن مُهشَّراً على الخادم فلا سبيل
+ /// لاستعادته. الحوار لا يُغلق باللمس خارجه: إغلاقه بالخطأ قبل النسخ
+ /// يعني محطة بلا مفتاح ودورة توليد جديدة.
+ Future _showKey(String key, String stationName) async {
+ final cs = Theme.of(context).colorScheme;
+
+ await Get.dialog(
+ barrierDismissible: false,
+ AlertDialog(
+ backgroundColor: cs.surfaceContainerHighest,
+ title: Text('مفتاح $stationName',
+ style: TextStyle(color: cs.onSurface, fontSize: 16)),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: cs.surface,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: cs.outline),
+ ),
+ child: SelectableText(key,
+ style: TextStyle(
+ color: cs.onSurface, fontSize: 12, fontFamily: 'monospace')),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'انسخه الآن وسلّمه للمحطة. لن يظهر مرة أخرى — المفتاح مخزَّن مشفّراً ولا يمكن استرجاعه.',
+ style: TextStyle(color: cs.error, fontSize: 11),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: key));
+ Get.snackbar('نُسخ', 'المفتاح في الحافظة');
+ },
+ child: Text('نسخ', style: TextStyle(color: cs.primary)),
+ ),
+ TextButton(
+ onPressed: () => Get.back(), child: const Text('تم')),
+ ],
+ ),
+ );
+ }
+}
diff --git a/siro_admin/lib/views/widgets/web_sidebar.dart b/siro_admin/lib/views/widgets/web_sidebar.dart
index a918e074..4b183c27 100644
--- a/siro_admin/lib/views/widgets/web_sidebar.dart
+++ b/siro_admin/lib/views/widgets/web_sidebar.dart
@@ -6,6 +6,7 @@ import 'package:siro_admin/views/admin/captain/captain.dart';
import 'package:siro_admin/views/admin/passenger/passenger.dart';
import 'package:siro_admin/views/admin/drivers/driver_tracker_screen.dart';
import 'package:siro_admin/views/transit/org_list_page.dart';
+import 'package:siro_admin/views/admin/fuel/fuel_stations_page.dart';
import 'package:siro_admin/views/admin/static/static.dart';
import 'package:siro_admin/controller/admin/static_controller.dart';
import 'package:siro_admin/views/admin/drivers/monitor_ride.dart';
@@ -172,6 +173,15 @@ class WebSidebar extends StatelessWidget {
onTap: () => Get.to(() => const TransitOrgListPage()),
),
+ _buildSectionHeader(context, 'اقتصاد السائق'),
+ _buildNavItem(
+ context,
+ title: 'محطات الوقود',
+ icon: Icons.local_gas_station_rounded,
+ index: 140,
+ onTap: () => Get.to(() => const FuelStationsPageView()),
+ ),
+
_buildSectionHeader(context, 'المالية والأسعار'),
_buildNavItem(
context,
diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart
index f56ed51e..6e7c77c7 100755
--- a/siro_driver/lib/constant/links.dart
+++ b/siro_driver/lib/constant/links.dart
@@ -278,6 +278,13 @@ class AppLink {
static String get getDriverDetails =>
"$seferCairoServer/ride/driverWallet/getDriverDetails.php";
+ // ================= Earnings Endpoints =================
+ // نقطة واحدة لكل أرقام شاشة الأرباح بختم زمني واحد. النداءات الأربعة
+ // السابقة كانت تصل في لحظات مختلفة فتُنتج أرقاماً لا تتفق.
+ static String get getEarningsSummary =>
+ "$endPoint/ride/earnings/summary.php";
+ static String get getEarningsTrips => "$endPoint/ride/earnings/trips.php";
+
// ================= Gamification Endpoints =================
static String get getWeeklyAggregate =>
"$endPoint/ride/gamification/getWeeklyAggregate.php";
diff --git a/siro_driver/lib/controller/home/captin/home_captain_controller.dart b/siro_driver/lib/controller/home/captin/home_captain_controller.dart
index 94b28a5a..e6ded9fe 100755
--- a/siro_driver/lib/controller/home/captin/home_captain_controller.dart
+++ b/siro_driver/lib/controller/home/captin/home_captain_controller.dart
@@ -299,7 +299,7 @@ class HomeCaptainController extends GetxController {
void goToWalletFromConnect() {
Get.back();
Get.back();
- Get.to(() => WalletCaptainRefactored());
+ Get.to(() => const WalletCaptainRefactored());
}
void changeRideId() {
diff --git a/siro_driver/lib/controller/home/statistics/earnings_controller.dart b/siro_driver/lib/controller/home/statistics/earnings_controller.dart
new file mode 100644
index 00000000..ca7bbbe7
--- /dev/null
+++ b/siro_driver/lib/controller/home/statistics/earnings_controller.dart
@@ -0,0 +1,176 @@
+// earnings_controller.dart — مصدر واحد لأرقام شاشة الأرباح
+//
+// كانت الأرقام تُبنى من أربعة نداءات مستقلة، فيصل كلٌّ منها في لحظة
+// مختلفة وتظهر أرقام لا تتفق. هنا نداء واحد بختم زمني واحد.
+//
+// والأهم: **الفشل لا يُترجم أصفاراً**. الكود السابق كان عند فشل الشبكة
+// يولّد سبعة أيام بأصفار ويعرضها كأنها أرباح السائق — وهو يرى دخله وقد
+// تبخّر. الحالتان هنا منفصلتان: `hasError` غير «كسبتَ صفراً».
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:siro_driver/constant/links.dart';
+import 'package:siro_driver/controller/functions/crud.dart';
+import 'package:siro_driver/main.dart';
+
+import 'earnings_models.dart';
+
+class EarningsController extends GetxController {
+ EarningsSummary? summary;
+ List trips = [];
+
+ bool isLoading = false;
+ bool isLoadingTrips = false;
+
+ /// سبب الفشل كنصّ للسائق، أو null. وجوده يعني «لا نعرف أرباحك الآن»
+ /// لا «أرباحك صفر» — والفرق بينهما هو الفرق بين عطلٍ يُحتمل وثقةٍ تُفقد.
+ String? errorMessage;
+
+ /// هل المعروض من الذاكرة المؤقتة؟ الشاشة تُظهر ذلك صراحةً مع وقت
+ /// القياس، فلا يقرأ السائق رقماً عمره ساعات على أنه لحظته.
+ bool isStale = false;
+
+ DateTime? lastFetch;
+
+ /// اليوم المختار لقائمة الرحلات (null = آخر سبعة أيام).
+ String? selectedDay;
+
+ static const _cacheKey = 'earnings_summary_cache';
+ static const _cacheAtKey = 'earnings_summary_cached_at';
+
+ /// دقيقتان لا ثلاث ساعات. الكاش الطويل السابق كان سبب تناقض شاشة
+ /// الأرباح مع شاشة الرصيد؛ ومدة قصيرة تكفي لمنع النداءات المتكررة
+ /// عند التنقّل بين الشاشات بلا أن تُقدّم للسائق ماضياً على أنه حاضر.
+ static const _freshFor = Duration(minutes: 2);
+
+ @override
+ void onInit() {
+ super.onInit();
+ _loadFromCache();
+ load();
+ }
+
+ void _loadFromCache() {
+ try {
+ final raw = box.read(_cacheKey);
+ if (raw == null) return;
+
+ summary = EarningsSummary.fromJson(
+ Map.from(jsonDecode(raw)));
+
+ final at = box.read(_cacheAtKey);
+ if (at != null) lastFetch = DateTime.tryParse(at.toString());
+ isStale = true;
+ update();
+ } catch (e) {
+ debugPrint('[Earnings] تعذّرت قراءة الكاش: $e');
+ }
+ }
+
+ Future load({bool force = false}) async {
+ if (!force && lastFetch != null && !isStale) {
+ if (DateTime.now().difference(lastFetch!) < _freshFor) return;
+ }
+
+ isLoading = summary == null; // دوّار فقط حين لا يوجد ما يُعرض
+ errorMessage = null;
+ update();
+
+ final res = await CRUD().get(link: AppLink.getEarningsSummary);
+
+ // الرسائل مفصّلة بحسب سبب الفشل: «تحقق من اتصالك» تُصلح بفعل،
+ // و«حدث خطأ» تترك السائق بلا حيلة.
+ if (res == 'no_internet') {
+ _fail('تعذّر الوصول للخادم — تحقّق من اتصالك');
+ return;
+ }
+ if (res == 'token_expired') {
+ _fail('انتهت الجلسة، حاول مجدداً');
+ return;
+ }
+ if (res == 'failure' || res == null) {
+ _fail('تعذّر تحميل أرباحك الآن');
+ return;
+ }
+
+ try {
+ final data = jsonDecode(res);
+ if (data['status'] != 'success' || data['message'] is! Map) {
+ _fail('تعذّر تحميل أرباحك الآن');
+ return;
+ }
+
+ summary = EarningsSummary.fromJson(
+ Map.from(data['message']));
+
+ box.write(_cacheKey, jsonEncode(data['message']));
+ box.write(_cacheAtKey, DateTime.now().toIso8601String());
+
+ lastFetch = DateTime.now();
+ isStale = false;
+ errorMessage = null;
+ } catch (e) {
+ debugPrint('[Earnings] تعذّر تحليل الرد: $e');
+ _fail('تعذّر تحميل أرباحك الآن');
+ return;
+ } finally {
+ isLoading = false;
+ update();
+ }
+
+ await loadTrips();
+ }
+
+ /// الفشل يُبقي آخر بيانات معروفة معروضة ومعلَّمة كقديمة، ولا يستبدلها
+ /// بأصفار. رقم قديم صادق أنفع من رقم حاضر كاذب.
+ void _fail(String message) {
+ errorMessage = message;
+ isLoading = false;
+ if (summary != null) isStale = true;
+ update();
+ }
+
+ Future loadTrips({String? day}) async {
+ selectedDay = day;
+ isLoadingTrips = true;
+ update();
+
+ final res = await CRUD().get(
+ link: AppLink.getEarningsTrips,
+ payload: {
+ if (day != null) 'date': day,
+ if (day == null) 'range': 'week',
+ },
+ );
+
+ try {
+ if (res is String && res != 'failure' && res != 'no_internet' && res != 'token_expired') {
+ final data = jsonDecode(res);
+ if (data['status'] == 'success' && data['message'] is Map) {
+ final list = data['message']['trips'];
+ trips = (list is List)
+ ? list
+ .map((t) => TripEarning.fromJson(Map.from(t)))
+ .toList()
+ : [];
+ }
+ }
+ } catch (e) {
+ debugPrint('[Earnings] تعذّر تحليل الرحلات: $e');
+ }
+
+ isLoadingTrips = false;
+ update();
+ }
+
+ /// نصّ «آخر تحديث» بصيغة يفهمها السائق بلا حساب.
+ String get lastUpdatedLabel {
+ if (lastFetch == null) return '';
+ final diff = DateTime.now().difference(lastFetch!);
+ if (diff.inMinutes < 1) return 'الآن';
+ if (diff.inMinutes < 60) return 'قبل ${diff.inMinutes} دقيقة';
+ if (diff.inHours < 24) return 'قبل ${diff.inHours} ساعة';
+ return 'قبل ${diff.inDays} يوم';
+ }
+}
diff --git a/siro_driver/lib/controller/home/statistics/earnings_models.dart b/siro_driver/lib/controller/home/statistics/earnings_models.dart
new file mode 100644
index 00000000..3db01bf9
--- /dev/null
+++ b/siro_driver/lib/controller/home/statistics/earnings_models.dart
@@ -0,0 +1,226 @@
+// earnings_models.dart — نماذج شاشة الأرباح
+//
+// كل الأرقام تصل من نقطة واحدة (`ride/earnings/summary.php`) بختم زمني
+// واحد، فلا مجال لأن يتناقض رقمان في الشاشة نفسها.
+
+class PeriodEarnings {
+ final int trips;
+
+ /// صافي السائق — حصته المثبَّتة لحظة عرض السعر، أي ما وُعد به قبل القبول.
+ final double net;
+
+ /// إجمالي ما دفعه الركاب.
+ final double gross;
+
+ /// عمولة المنصة. تُعرض صراحةً لا تُطرح بصمت: السائق الذي يرى تركيبة
+ /// المبلغ يفهم، والذي يرى الصافي وحده يخمّن ويشكّ.
+ final double commission;
+
+ final double distance;
+ final int minutes;
+
+ PeriodEarnings({
+ required this.trips,
+ required this.net,
+ required this.gross,
+ required this.commission,
+ required this.distance,
+ required this.minutes,
+ });
+
+ double get hours => minutes / 60.0;
+
+ factory PeriodEarnings.fromJson(Map j) => PeriodEarnings(
+ trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0,
+ net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
+ gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0,
+ commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0,
+ distance: double.tryParse(j['distance']?.toString() ?? '0') ?? 0,
+ minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
+ );
+
+ static PeriodEarnings empty() => PeriodEarnings(
+ trips: 0, net: 0, gross: 0, commission: 0, distance: 0, minutes: 0);
+}
+
+class DayEarnings {
+ final String day;
+ final int trips;
+ final double net;
+ final int minutes;
+
+ DayEarnings(
+ {required this.day,
+ required this.trips,
+ required this.net,
+ required this.minutes});
+
+ factory DayEarnings.fromJson(Map j) => DayEarnings(
+ day: j['day']?.toString() ?? '',
+ trips: int.tryParse(j['trips']?.toString() ?? '0') ?? 0,
+ net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
+ minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
+ );
+}
+
+class DeductionItem {
+ final String product;
+ final String name;
+ final double outstanding;
+
+ DeductionItem(
+ {required this.product, required this.name, required this.outstanding});
+
+ factory DeductionItem.fromJson(Map j) => DeductionItem(
+ product: j['product']?.toString() ?? '',
+ name: j['name']?.toString() ?? '',
+ outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0,
+ );
+}
+
+/// الاقتطاعات (تأمين، وقود) تُعرض بجانب الأرباح التي اقتُطعت منها.
+/// اقتطاعٌ لا يظهر في مكانه يُقرأ سرقةً مهما كان مشروعاً.
+class Deductions {
+ final double today;
+ final double week;
+ final double month;
+ final double outstanding;
+ final List items;
+
+ Deductions({
+ required this.today,
+ required this.week,
+ required this.month,
+ required this.outstanding,
+ required this.items,
+ });
+
+ bool get hasAny => today > 0 || outstanding > 0;
+
+ factory Deductions.fromJson(Map j) => Deductions(
+ today: double.tryParse(j['today']?.toString() ?? '0') ?? 0,
+ week: double.tryParse(j['week']?.toString() ?? '0') ?? 0,
+ month: double.tryParse(j['month']?.toString() ?? '0') ?? 0,
+ outstanding: double.tryParse(j['outstanding']?.toString() ?? '0') ?? 0,
+ items: (j['items'] is List)
+ ? (j['items'] as List)
+ .map((i) => DeductionItem.fromJson(Map.from(i)))
+ .toList()
+ : [],
+ );
+}
+
+class EarningsSummary {
+ final PeriodEarnings today;
+ final PeriodEarnings week;
+ final PeriodEarnings month;
+ final PeriodEarnings prevWeek;
+
+ /// نسبة التغيّر عن الأسبوع الماضي، أو null إن لم يكن هناك ما يُقارَن به.
+ /// المقارنة بالذات لا بالسائقين الآخرين: ترتيبٌ يُحبط أكثر من يحتاج
+ /// التحفيز — فهو من سيقع في أسفله.
+ final double? trendPercent;
+
+ final List daily;
+ final DayEarnings? bestDay;
+ final Deductions deductions;
+ final String asOf;
+
+ EarningsSummary({
+ required this.today,
+ required this.week,
+ required this.month,
+ required this.prevWeek,
+ required this.daily,
+ required this.deductions,
+ required this.asOf,
+ this.trendPercent,
+ this.bestDay,
+ });
+
+ factory EarningsSummary.fromJson(Map j) => EarningsSummary(
+ today: PeriodEarnings.fromJson(Map.from(j['today'] ?? {})),
+ week: PeriodEarnings.fromJson(Map.from(j['week'] ?? {})),
+ month: PeriodEarnings.fromJson(Map.from(j['month'] ?? {})),
+ prevWeek:
+ PeriodEarnings.fromJson(Map.from(j['prev_week'] ?? {})),
+ trendPercent: j['trend_percent'] == null
+ ? null
+ : double.tryParse(j['trend_percent'].toString()),
+ daily: (j['daily'] is List)
+ ? (j['daily'] as List)
+ .map((d) => DayEarnings.fromJson(Map.from(d)))
+ .toList()
+ : [],
+ bestDay: j['best_day'] is Map
+ ? DayEarnings.fromJson(Map.from(j['best_day']))
+ : null,
+ deductions:
+ Deductions.fromJson(Map.from(j['deductions'] ?? {})),
+ asOf: j['as_of']?.toString() ?? '',
+ );
+}
+
+class TripBreakdown {
+ final double gross;
+ final double commission;
+ final double bonus;
+ final double net;
+
+ /// إعفاء العمولة (سلسلة ٠٪) — هدية تدفع المنصة ثمنها ولم تكن تظهر
+ /// للسائق في أي شاشة. نميّزها لتُعرض مكسباً لا سطراً فارغاً.
+ final bool zeroCommission;
+
+ TripBreakdown({
+ required this.gross,
+ required this.commission,
+ required this.bonus,
+ required this.net,
+ required this.zeroCommission,
+ });
+
+ factory TripBreakdown.fromJson(Map j) => TripBreakdown(
+ gross: double.tryParse(j['gross']?.toString() ?? '0') ?? 0,
+ commission: double.tryParse(j['commission']?.toString() ?? '0') ?? 0,
+ bonus: double.tryParse(j['bonus']?.toString() ?? '0') ?? 0,
+ net: double.tryParse(j['net']?.toString() ?? '0') ?? 0,
+ zeroCommission: j['zero_commission'] == true,
+ );
+}
+
+class TripEarning {
+ final int rideId;
+ final String at;
+ final String from;
+ final String to;
+ final String carType;
+ final String paymentMethod;
+ final double distanceKm;
+ final int minutes;
+ final TripBreakdown breakdown;
+
+ TripEarning({
+ required this.rideId,
+ required this.at,
+ required this.from,
+ required this.to,
+ required this.carType,
+ required this.paymentMethod,
+ required this.distanceKm,
+ required this.minutes,
+ required this.breakdown,
+ });
+
+ factory TripEarning.fromJson(Map j) => TripEarning(
+ rideId: int.tryParse(j['ride_id']?.toString() ?? '0') ?? 0,
+ at: j['at']?.toString() ?? '',
+ from: j['from']?.toString() ?? '',
+ to: j['to']?.toString() ?? '',
+ carType: j['car_type']?.toString() ?? '',
+ paymentMethod: j['payment_method']?.toString() ?? '',
+ distanceKm: double.tryParse(j['distance_km']?.toString() ?? '0') ?? 0,
+ minutes: int.tryParse(j['minutes']?.toString() ?? '0') ?? 0,
+ breakdown:
+ TripBreakdown.fromJson(Map.from(j['breakdown'] ?? {})),
+ );
+}
diff --git a/siro_driver/lib/controller/home/statistics/statistics_controller.dart b/siro_driver/lib/controller/home/statistics/statistics_controller.dart
deleted file mode 100644
index bef6175e..00000000
--- a/siro_driver/lib/controller/home/statistics/statistics_controller.dart
+++ /dev/null
@@ -1,293 +0,0 @@
-import 'dart:convert';
-import 'package:flutter/material.dart';
-import 'package:get/get.dart';
-import 'package:siro_driver/constant/box_name.dart';
-import 'package:siro_driver/constant/links.dart';
-import 'package:siro_driver/controller/functions/crud.dart';
-import 'package:siro_driver/models/model/driver/rides_summary_model.dart';
-import '../../../main.dart';
-
-class StatisticsController extends GetxController {
- bool isLoading = false;
-
- // ═══ Weekly Data ═══
- List weeklyStats = [];
- double weeklyEarnings = 0;
- int weeklyTrips = 0;
- double weeklyHours = 0;
-
- // ═══ Monthly Data ═══
- List monthlyEarnings = [];
- List monthlyRides = [];
- List monthlyDuration = [];
- double monthlyTotalEarnings = 0;
- int monthlyTotalTrips = 0;
- double monthlyTotalHours = 0;
- String bestDay = '--';
- double bestDayEarnings = 0;
-
- // ═══ Tab State ═══
- int selectedTab = 0; // 0=weekly, 1=monthly
-
- @override
- void onInit() {
- super.onInit();
- reloadData();
- }
-
- void changeTab(int tab) {
- selectedTab = tab;
- update();
- }
-
- // ═══════ جلب البيانات الأسبوعية ═══════
- Future fetchWeeklyData() async {
- isLoading = true;
- update();
-
- try {
- var res = await CRUD().get(
- link: AppLink.getWeeklyAggregate,
- payload: {
- 'driver_id': box.read(BoxName.driverID).toString(),
- },
- );
-
- if (res != null && res != 'failure') {
- box.write('stats_weekly_cache', res);
- var data = jsonDecode(res);
- if (data['message'] is List) {
- weeklyStats = (data['message'] as List)
- .map((e) => DayStat.fromJson(e))
- .toList();
- weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings);
- weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips);
- weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours);
- }
- }
- } catch (e) {
- debugPrint('❌ [Stats] Weekly fetch error: $e');
- // Fallback: generate from local data
- _generateLocalWeeklyData();
- }
-
- isLoading = false;
- update();
- }
-
- // ═══════ جلب البيانات الشهرية ═══════
- Future fetchMonthlyData() async {
- try {
- // 1. أرباح شهرية
- var earningsRes = await CRUD().getWallet(
- link: AppLink.getAllPaymentFromRide,
- payload: {'driverID': box.read(BoxName.driverID).toString()},
- );
- if (earningsRes != null && earningsRes != 'failure') {
- box.write('stats_monthly_earnings_cache', earningsRes);
- var data = jsonDecode(earningsRes);
- if (data['message'] is List) {
- monthlyEarnings = (data['message'] as List)
- .map((e) => MonthlyPriceDriverModel.fromJson(e))
- .toList();
- monthlyTotalEarnings =
- monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay);
-
- // أفضل يوم
- if (monthlyEarnings.isNotEmpty) {
- var best = monthlyEarnings
- .reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b);
- bestDay = best.day.toString();
- bestDayEarnings = best.pricePerDay;
- }
- }
- }
-
- // 2. رحلات شهرية
- var ridesRes = await CRUD().get(
- link: AppLink.getRidesDriverByDay,
- payload: {'driver_id': box.read(BoxName.driverID).toString()},
- );
- if (ridesRes != null && ridesRes != 'failure') {
- box.write('stats_monthly_rides_cache', ridesRes);
- var data = jsonDecode(ridesRes);
- if (data['message'] is List) {
- monthlyRides = (data['message'] as List)
- .map((e) => MonthlyRideModel.fromJson(e))
- .toList();
- monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide);
- }
- }
-
- // 3. ساعات شهرية
- var durationRes = await CRUD().get(
- link: AppLink.getTotalDriverDuration,
- payload: {'driver_id': box.read(BoxName.driverID).toString()},
- );
- if (durationRes != null && durationRes != 'failure') {
- box.write('stats_monthly_duration_cache', durationRes);
- var data = jsonDecode(durationRes);
- if (data['message'] is List) {
- monthlyDuration = (data['message'] as List)
- .map((e) => MonthlyDataModel.fromJson(e))
- .toList();
- monthlyTotalHours =
- monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble());
- }
- }
- } catch (e) {
- debugPrint('❌ [Stats] Monthly fetch error: $e');
- }
-
- update();
- }
-
- void _generateLocalWeeklyData() {
- // Fallback بيانات محلية عند عدم توفر الـ API
- final now = DateTime.now();
- weeklyStats = List.generate(7, (i) {
- final day = now.subtract(Duration(days: 6 - i));
- return DayStat(
- date: day,
- dayName: _getDayName(day.weekday),
- earnings: 0,
- trips: 0,
- hours: 0,
- );
- });
- }
-
- String _getDayName(int weekday) {
- const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
- return days[weekday - 1];
- }
-
- bool _isFetching = false;
-
- Future reloadData({bool force = false}) async {
- if (_isFetching) return;
- _isFetching = true;
-
- // التحقق من التخزين المؤقت (3 ساعات)
- String? lastFetchStr = box.read('stats_last_fetch');
- if (!force && lastFetchStr != null) {
- try {
- DateTime lastFetch = DateTime.parse(lastFetchStr);
- if (DateTime.now().difference(lastFetch).inHours < 3) {
- debugPrint('ℹ️ [Statistics] Loading cached statistics (last fetch: $lastFetchStr)');
-
- // تحميل الكاش الأسبوعي
- String? weeklyCache = box.read('stats_weekly_cache');
- if (weeklyCache != null) {
- var data = jsonDecode(weeklyCache);
- if (data['message'] is List) {
- weeklyStats = (data['message'] as List)
- .map((e) => DayStat.fromJson(e))
- .toList();
- weeklyEarnings = weeklyStats.fold(0, (s, d) => s + d.earnings);
- weeklyTrips = weeklyStats.fold(0, (s, d) => s + d.trips);
- weeklyHours = weeklyStats.fold(0, (s, d) => s + d.hours);
- }
- }
-
- // تحميل الكاش الشهري (الأرباح)
- String? earningsCache = box.read('stats_monthly_earnings_cache');
- if (earningsCache != null) {
- var data = jsonDecode(earningsCache);
- if (data['message'] is List) {
- monthlyEarnings = (data['message'] as List)
- .map((e) => MonthlyPriceDriverModel.fromJson(e))
- .toList();
- monthlyTotalEarnings =
- monthlyEarnings.fold(0, (s, d) => s + d.pricePerDay);
- if (monthlyEarnings.isNotEmpty) {
- var best = monthlyEarnings
- .reduce((a, b) => a.pricePerDay > b.pricePerDay ? a : b);
- bestDay = best.day.toString();
- bestDayEarnings = best.pricePerDay;
- }
- }
- }
-
- // تحميل الكاش الشهري (الرحلات)
- String? ridesCache = box.read('stats_monthly_rides_cache');
- if (ridesCache != null) {
- var data = jsonDecode(ridesCache);
- if (data['message'] is List) {
- monthlyRides = (data['message'] as List)
- .map((e) => MonthlyRideModel.fromJson(e))
- .toList();
- monthlyTotalTrips = monthlyRides.fold(0, (s, d) => s + d.countRide);
- }
- }
-
- // تحميل الكاش الشهري (المدد)
- String? durationCache = box.read('stats_monthly_duration_cache');
- if (durationCache != null) {
- var data = jsonDecode(durationCache);
- if (data['message'] is List) {
- monthlyDuration = (data['message'] as List)
- .map((e) => MonthlyDataModel.fromJson(e))
- .toList();
- monthlyTotalHours =
- monthlyDuration.fold(0, (s, d) => s + d.totalDuration.toDouble());
- }
- }
-
- _isFetching = false;
- update();
- return;
- }
- } catch (e) {
- debugPrint('⚠️ [Statistics] Failed to load cached statistics: $e');
- }
- }
-
- isLoading = true;
- update();
-
- try {
- debugPrint('📊 [Statistics] Reloading data from server...');
- await fetchWeeklyData();
- await fetchMonthlyData();
- box.write('stats_last_fetch', DateTime.now().toIso8601String());
- debugPrint('📊 [Statistics] Data reload complete.');
- } catch (e) {
- debugPrint('❌ [Statistics] Error reloading data: $e');
- } finally {
- _isFetching = false;
- isLoading = false;
- update();
- }
- }
-}
-
-// ═══════ نموذج إحصائية اليوم ═══════
-class DayStat {
- final DateTime date;
- final String dayName;
- final double earnings;
- final int trips;
- final double hours;
-
- DayStat({
- required this.date,
- required this.dayName,
- required this.earnings,
- required this.trips,
- required this.hours,
- });
-
- factory DayStat.fromJson(Map json) {
- final date =
- DateTime.tryParse(json['day']?.toString() ?? '') ?? DateTime.now();
- const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
- return DayStat(
- date: date,
- dayName: dayNames[date.weekday - 1],
- earnings: double.tryParse(json['earnings']?.toString() ?? '0') ?? 0,
- trips: int.tryParse(json['trips']?.toString() ?? '0') ?? 0,
- hours: double.tryParse(json['hours']?.toString() ?? '0') ?? 0,
- );
- }
-}
diff --git a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart
index 4cb844a4..5f136b49 100755
--- a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart
+++ b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart
@@ -12,7 +12,7 @@ import 'package:siro_driver/main.dart';
import 'package:siro_driver/views/Rate/rate_app_page.dart';
import 'package:siro_driver/views/auth/captin/contact_us_page.dart';
import 'package:siro_driver/views/auth/captin/invite_driver_screen.dart';
-import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart';
+import 'package:siro_driver/views/home/statistics/earnings_page.dart';
import 'package:siro_driver/views/gamification/challenges_page.dart';
import 'package:siro_driver/views/gamification/leaderboard_page.dart';
import 'package:siro_driver/views/home/journal/schedule_page.dart';
@@ -67,12 +67,12 @@ class AppDrawer extends StatelessWidget {
title: 'Balance'.tr,
icon: Icons.account_balance_wallet,
color: Colors.green,
- onTap: () => Get.to(() => WalletCaptainRefactored())),
+ onTap: () => Get.to(() => const WalletCaptainRefactored())),
DrawerItem(
- title: 'Statistics'.tr,
- icon: Icons.bar_chart_rounded,
- color: Colors.deepPurple,
- onTap: () => Get.to(() => StatisticsDashboard())),
+ title: 'أرباحي',
+ icon: Icons.payments_rounded,
+ color: Colors.teal,
+ onTap: () => Get.to(() => EarningsPage())),
DrawerItem(
title: 'Challenges'.tr,
icon: Icons.bolt_rounded,
diff --git a/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart
index 444440c4..6f75c240 100755
--- a/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart
+++ b/siro_driver/lib/views/home/Captin/home_captain/widget/left_menu_map_captain.dart
@@ -16,7 +16,7 @@ import '../../../../../controller/functions/crud.dart';
import '../../../../../controller/home/captin/order_request_controller.dart';
import '../../../../../print.dart';
import '../../../../Rate/ride_calculate_driver.dart';
-import 'package:siro_driver/views/home/statistics/statistics_dashboard.dart';
+import 'package:siro_driver/views/home/statistics/earnings_page.dart';
// ─────────────────────────────────────────────
// Design Tokens (Responsive)
@@ -103,7 +103,7 @@ GetBuilder leftMainMenuCaptainIcons() {
color: _T.blue,
tooltip: 'Statistics'.tr,
onTap: () {
- Get.to(() => StatisticsDashboard());
+ Get.to(() => EarningsPage());
},
onLongPress: () =>
box.write(BoxName.statusDriverLocation, 'off'),
diff --git a/siro_driver/lib/views/home/my_wallet/walet_captain.dart b/siro_driver/lib/views/home/my_wallet/walet_captain.dart
index ec53105c..fb1b38cb 100755
--- a/siro_driver/lib/views/home/my_wallet/walet_captain.dart
+++ b/siro_driver/lib/views/home/my_wallet/walet_captain.dart
@@ -26,14 +26,29 @@ import 'widgets/financial_summary_card.dart';
import 'widgets/promo_gamification_card.dart';
import 'widgets/transaction_preview_item.dart';
-class WalletCaptainRefactored extends StatelessWidget {
- WalletCaptainRefactored({super.key});
+class WalletCaptainRefactored extends StatefulWidget {
+ const WalletCaptainRefactored({super.key});
+ @override
+ State createState() =>
+ _WalletCaptainRefactoredState();
+}
+
+class _WalletCaptainRefactoredState extends State {
final CaptainWalletController controller = Get.put(CaptainWalletController());
@override
- Widget build(BuildContext context) {
+ void initState() {
+ super.initState();
+ // كان التحديث يُنادى داخل build. وbuild يعمل عشرات المرات — عند كل
+ // إعادة رسم، وكل تغيّر حالة، وكل دوران للشاشة — فكان كل واحدة منها
+ // تُطلق نداءات شبكية. مكانه هنا: مرة واحدة عند فتح الشاشة، والباقي
+ // بالسحب للتحديث أو بزر التحديث.
controller.refreshCaptainWallet();
+ }
+
+ @override
+ Widget build(BuildContext context) {
return Scaffold(
backgroundColor: FinanceDesignSystem.backgroundColor,
appBar: AppBar(
diff --git a/siro_driver/lib/views/home/statistics/earnings_page.dart b/siro_driver/lib/views/home/statistics/earnings_page.dart
new file mode 100644
index 00000000..ed57237b
--- /dev/null
+++ b/siro_driver/lib/views/home/statistics/earnings_page.dart
@@ -0,0 +1,729 @@
+// earnings_page.dart — شاشة أرباح السائق
+//
+// الترتيب مقصود ويخالف الشاشة السابقة: **اليوم أولاً**. الشاشة القديمة
+// كانت تبدأ بترويسة زخرفية بارتفاع ٢٠٠ ثم تبويبَي أسبوعي/شهري — والسائق
+// يفتح الشاشة ليعرف كم كسب اليوم، لا ليقرأ تقريراً شهرياً.
+//
+// ثم الاقتطاعات، ثم أسبوعه، ثم تفصيل كل رحلة.
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:siro_driver/constant/currency.dart';
+import 'package:siro_driver/constant/finance_design_system.dart';
+import 'package:siro_driver/controller/gamification/gamification_controller.dart';
+import 'package:siro_driver/controller/home/statistics/earnings_controller.dart';
+import 'package:siro_driver/controller/home/statistics/earnings_models.dart';
+
+import 'widgets/behavior_and_achievements.dart';
+import 'widgets/daily_goal_widget.dart';
+import 'widgets/level_progress_widget.dart';
+import 'widgets/stat_summary_card.dart';
+
+class EarningsPage extends StatelessWidget {
+ EarningsPage({super.key});
+
+ // Get.put هنا لا في build: الشاشة السابقة كانت تنادي التحديث داخل
+ // build، وbuild يعمل عشرات المرات — أي نداء شبكي مع كل إعادة رسم.
+ final EarningsController c = Get.put(EarningsController());
+ final GamificationController gam = Get.put(GamificationController());
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: FinanceDesignSystem.backgroundColor,
+ appBar: AppBar(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ centerTitle: true,
+ title: Text('أرباحي',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ color: FinanceDesignSystem.primaryDark)),
+ leading: IconButton(
+ icon: Icon(Icons.arrow_back_ios_new_rounded,
+ color: FinanceDesignSystem.primaryDark, size: 20),
+ onPressed: () => Get.back(),
+ ),
+ ),
+ body: GetBuilder(
+ builder: (ctl) {
+ if (ctl.isLoading && ctl.summary == null) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ // لا بيانات ولا كاش: نعرض الخطأ صريحاً مع زر إعادة. الشاشة
+ // السابقة كانت تعرض سبعة أيام بأصفار في هذه الحالة — فيرى
+ // السائق دخله وقد تبخّر، وهي أسوأ رسالة يمكن أن يتلقّاها.
+ if (ctl.summary == null) {
+ return _errorState(ctl);
+ }
+
+ final s = ctl.summary!;
+
+ // الترتيب هو القرار الأهم في هذه الشاشة بعد دمج الإحصائيات
+ // فيها: **المال أولاً، ثم التقدّم**. السائق يفتحها ليعرف كم
+ // كسب، لا ليرى مستواه — ووضع التلعيب قبل الأرباح كان يجعله
+ // يمرّر ثلاث شاشات ليصل إلى ما جاء من أجله.
+ return RefreshIndicator(
+ onRefresh: () async {
+ await ctl.load(force: true);
+ await gam.fetchGamificationData(force: true);
+ },
+ child: ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.symmetric(
+ horizontal: FinanceDesignSystem.horizontalPadding,
+ vertical: 8),
+ children: [
+ if (ctl.errorMessage != null) _staleBanner(ctl),
+ _todayCard(s, ctl),
+ const SizedBox(height: 16),
+ if (s.deductions.hasAny) ...[
+ _deductionsCard(s.deductions),
+ const SizedBox(height: 16),
+ ],
+ _weekCard(s, ctl),
+ const SizedBox(height: 16),
+ _monthCard(s),
+ const SizedBox(height: 16),
+ _tripsSection(ctl),
+ const SizedBox(height: 24),
+ _progressSection(),
+ const SizedBox(height: 40),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ /// قسم التقدّم — ما كان شاشة «الإحصائيات» المنفصلة.
+ ///
+ /// في GetBuilder خاصّ به: بيانات التلعيب تصل من نقطة أخرى وفي وقت
+ /// آخر، وربطها بمُنشئ الأرباح كان سيعيد رسم الشاشة كلها — بأرقام
+ /// المال وقوائم الرحلات — كلما تحدّث عدّاد إنجاز.
+ Widget _progressSection() => GetBuilder(
+ builder: (gc) {
+ if (gc.isLoading) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 24),
+ child: Center(child: CircularProgressIndicator()),
+ );
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('تقدّمي', style: FinanceDesignSystem.headingStyle),
+ const SizedBox(height: 12),
+
+ // هدف اليوم بعد الأرباح مباشرةً منطقياً — لكنه هنا لأنه
+ // تحفيز لا معلومة: مكانه بعد أن يعرف السائق أرقامه.
+ DailyGoalWidget(controller: gc),
+ const SizedBox(height: 16),
+
+ LevelProgressWidget(controller: gc),
+ const SizedBox(height: 16),
+
+ GridView.count(
+ crossAxisCount: 2,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ crossAxisSpacing: 12,
+ mainAxisSpacing: 12,
+ childAspectRatio: 1.5,
+ children: [
+ StatSummaryCard(
+ icon: Icons.local_taxi_rounded,
+ label: 'Total Trips'.tr,
+ value: gc.totalTrips.toString(),
+ color: FinanceDesignSystem.accentBlue,
+ ),
+ StatSummaryCard(
+ icon: Icons.star_rounded,
+ label: 'Rating'.tr,
+ value: gc.averageRating.toStringAsFixed(1),
+ color: const Color(0xFFFFD700),
+ ),
+ StatSummaryCard(
+ icon: Icons.whatshot_rounded,
+ label: 'Day Streak'.tr,
+ value: '${gc.consecutiveDays}',
+ color: const Color(0xFFFF5722),
+ ),
+ StatSummaryCard(
+ icon: Icons.people_rounded,
+ label: 'Referrals'.tr,
+ value: gc.totalReferrals.toString(),
+ color: const Color(0xFF9C27B0),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+
+ BehaviorSection(gc: gc),
+ const SizedBox(height: 24),
+
+ AchievementsSection(gc: gc),
+ ],
+ );
+ },
+ );
+
+ Widget _errorState(EarningsController ctl) => Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.cloud_off_rounded,
+ size: 48, color: FinanceDesignSystem.textMuted),
+ const SizedBox(height: 12),
+ Text(
+ ctl.errorMessage ?? 'تعذّر تحميل أرباحك',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: FinanceDesignSystem.primaryDark, fontSize: 15),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'أرباحك محفوظة ولم يضع منها شيء — المشكلة في التحميل فقط.',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary, fontSize: 12),
+ ),
+ const SizedBox(height: 20),
+ ElevatedButton.icon(
+ onPressed: () => ctl.load(force: true),
+ icon: const Icon(Icons.refresh_rounded),
+ label: const Text('إعادة المحاولة'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ /// بيانات قديمة معروضة بعد فشل التحديث. إظهار ذلك صراحةً أصدق من
+ /// تقديم رقمٍ عمره ساعات على أنه لحظته.
+ Widget _staleBanner(EarningsController ctl) => Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: Colors.orange.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.history_rounded, color: Colors.orange, size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ '${ctl.errorMessage} — هذه أرقام ${ctl.lastUpdatedLabel}',
+ style: const TextStyle(color: Colors.orange, fontSize: 12),
+ ),
+ ),
+ TextButton(
+ onPressed: () => ctl.load(force: true),
+ child: const Text('تحديث', style: TextStyle(fontSize: 12)),
+ ),
+ ],
+ ),
+ );
+
+ /// بطاقة اليوم — الرقم الذي فُتحت الشاشة من أجله.
+ Widget _todayCard(EarningsSummary s, EarningsController ctl) {
+ final t = s.today;
+
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ gradient: FinanceDesignSystem.balanceGradient,
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
+ boxShadow: [FinanceDesignSystem.softShadow],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Text('أرباح اليوم',
+ style: TextStyle(color: Colors.white70, fontSize: 13)),
+ const Spacer(),
+ Text(ctl.lastUpdatedLabel,
+ style: const TextStyle(color: Colors.white54, fontSize: 11)),
+ ],
+ ),
+ const SizedBox(height: 6),
+ Text('${t.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 34,
+ fontWeight: FontWeight.bold)),
+ const SizedBox(height: 14),
+ Row(
+ children: [
+ _miniStat(Icons.local_taxi_rounded, '${t.trips}', 'رحلة'),
+ _miniStat(Icons.schedule_rounded,
+ t.hours.toStringAsFixed(1), 'ساعة'),
+ _miniStat(Icons.route_rounded,
+ t.distance.toStringAsFixed(0), 'كم'),
+ ],
+ ),
+ // تركيبة المبلغ ظاهرة: إجمالي ← عمولة ← صافي. السائق الذي يرى
+ // التركيبة يفهم، والذي يرى الصافي وحده يخمّن ويشكّ.
+ if (t.gross > 0) ...[
+ const SizedBox(height: 14),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _breakdownBit('دفع الركاب', t.gross),
+ _breakdownBit('عمولة المنصة', t.commission, dim: true),
+ _breakdownBit('لك', t.net, bold: true),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _miniStat(IconData icon, String value, String label) => Expanded(
+ child: Row(
+ children: [
+ Icon(icon, color: Colors.white70, size: 16),
+ const SizedBox(width: 6),
+ Text(value,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 15,
+ fontWeight: FontWeight.w700)),
+ const SizedBox(width: 3),
+ Text(label,
+ style: const TextStyle(color: Colors.white54, fontSize: 11)),
+ ],
+ ),
+ );
+
+ Widget _breakdownBit(String label, double value,
+ {bool dim = false, bool bold = false}) =>
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label,
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.6), fontSize: 10)),
+ const SizedBox(height: 2),
+ Text(value.toStringAsFixed(2),
+ style: TextStyle(
+ color: dim
+ ? Colors.white.withValues(alpha: 0.7)
+ : Colors.white,
+ fontSize: bold ? 15 : 13,
+ fontWeight: bold ? FontWeight.bold : FontWeight.w500)),
+ ],
+ );
+
+ /// الاقتطاعات بجانب الأرباح التي اقتُطعت منها. عرضها في شاشة المنتج
+ /// وحدها كان سيجعل الخصم مفاجأةً — وهي أخطر ما يصيب منتجاً ائتمانياً.
+ Widget _deductionsCard(Deductions d) => Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.cardColor,
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
+ border: Border.all(color: FinanceDesignSystem.borderColor),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.account_balance_wallet_rounded,
+ size: 18, color: FinanceDesignSystem.accentBlue),
+ const SizedBox(width: 8),
+ Text('الاقتطاعات', style: FinanceDesignSystem.headingStyle),
+ const Spacer(),
+ if (d.today > 0)
+ Text('اليوم ${d.today.toStringAsFixed(2)}',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: 12)),
+ ],
+ ),
+ const SizedBox(height: 10),
+ ...d.items.map((i) => Padding(
+ padding: const EdgeInsets.only(bottom: 6),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(i.name,
+ style: TextStyle(
+ color: FinanceDesignSystem.primaryDark,
+ fontSize: 13))),
+ Text(
+ 'متبقٍّ ${i.outstanding.toStringAsFixed(2)} ${CurrencyHelper.currency}',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: 12)),
+ ],
+ ),
+ )),
+ if (d.outstanding > 0) ...[
+ const SizedBox(height: 6),
+ Text(
+ 'يُقتطع تدريجياً من أرباحك بسقف يومي، ولا يُخصم منك في يوم بلا دخل.',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary, fontSize: 11),
+ ),
+ ],
+ ],
+ ),
+ );
+
+ Widget _weekCard(EarningsSummary s, EarningsController ctl) {
+ final maxNet = s.daily.fold(
+ 0, (m, d) => d.net > m ? d.net : m);
+
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.cardColor,
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
+ border: Border.all(color: FinanceDesignSystem.borderColor),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text('آخر ٧ أيام', style: FinanceDesignSystem.headingStyle),
+ const Spacer(),
+ if (s.trendPercent != null) _trendChip(s.trendPercent!),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${s.week.net.toStringAsFixed(2)} ${CurrencyHelper.currency} · ${s.week.trips} رحلة',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary, fontSize: 13)),
+ const SizedBox(height: 16),
+ SizedBox(
+ height: 110,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: s.daily.map((d) {
+ final selected = ctl.selectedDay == d.day;
+ final ratio = maxNet > 0 ? d.net / maxNet : 0.0;
+
+ return Expanded(
+ child: GestureDetector(
+ // نقر العمود يعرض رحلات ذلك اليوم. الرسم البياني الذي
+ // لا يُنقر عليه زينة؛ والسائق يسأل عن يومٍ بعينه.
+ onTap: () => ctl.loadTrips(day: selected ? null : d.day),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Text(d.net > 0 ? d.net.toStringAsFixed(0) : '',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: 9)),
+ const SizedBox(height: 4),
+ Container(
+ height: 60 * (ratio == 0 ? 0.03 : ratio),
+ margin: const EdgeInsets.symmetric(horizontal: 3),
+ decoration: BoxDecoration(
+ color: selected
+ ? FinanceDesignSystem.accentBlue
+ : FinanceDesignSystem.accentBlue
+ .withValues(alpha: 0.35),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(_dayLabel(d.day),
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: 10)),
+ ],
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// الشهر — يحلّ محل الرسم الشهري في الشاشة القديمة.
+ ///
+ /// أرقام لا رسم بياني: الرسم الشهري كان ثلاثين عموداً ضيّقاً لا يُقرأ
+ /// منها شيء على شاشة هاتف. السائق يسأل عن مجموع شهره ومعدّله اليومي،
+ /// لا عن شكل توزيعه.
+ Widget _monthCard(EarningsSummary s) {
+ final m = s.month;
+ final avgPerDay = DateTime.now().day > 0 ? m.net / DateTime.now().day : 0.0;
+ final perHour = m.hours > 0 ? m.net / m.hours : 0.0;
+
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.cardColor,
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
+ border: Border.all(color: FinanceDesignSystem.borderColor),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('هذا الشهر', style: FinanceDesignSystem.headingStyle),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ _monthStat('الصافي', m.net.toStringAsFixed(0)),
+ _monthStat('رحلة', '${m.trips}'),
+ _monthStat('ساعة', m.hours.toStringAsFixed(0)),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ // معدّل الساعة هو الرقم الذي يقرّر به السائق متى يعمل —
+ // وأنفع من أي مجموع، لأن المجموع يكافئ الساعات لا الكفاءة.
+ _monthStat('معدّل الساعة', perHour.toStringAsFixed(1)),
+ _monthStat('معدّل اليوم', avgPerDay.toStringAsFixed(1)),
+ _monthStat('العمولة', m.commission.toStringAsFixed(0)),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _monthStat(String label, String value) => Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(value,
+ style: TextStyle(
+ color: FinanceDesignSystem.primaryDark,
+ fontSize: 17,
+ fontWeight: FontWeight.bold)),
+ const SizedBox(height: 2),
+ Text(label,
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary, fontSize: 11)),
+ ],
+ ),
+ );
+
+ Widget _trendChip(double trend) {
+ final up = trend >= 0;
+ final color =
+ up ? FinanceDesignSystem.successGreen : FinanceDesignSystem.dangerRed;
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(up ? Icons.trending_up_rounded : Icons.trending_down_rounded,
+ size: 14, color: color),
+ const SizedBox(width: 4),
+ // المقارنة بأسبوعه هو لا بالسائقين الآخرين.
+ Text('${trend.abs().toStringAsFixed(0)}٪ عن الأسبوع الماضي',
+ style: TextStyle(color: color, fontSize: 11)),
+ ],
+ ),
+ );
+ }
+
+ String _dayLabel(String iso) {
+ final d = DateTime.tryParse(iso);
+ if (d == null) return '';
+ const names = ['اثن', 'ثلا', 'أرب', 'خمي', 'جمع', 'سبت', 'أحد'];
+ return names[d.weekday - 1];
+ }
+
+ Widget _tripsSection(EarningsController ctl) => Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ ctl.selectedDay == null
+ ? 'رحلات الأسبوع'
+ : 'رحلات ${ctl.selectedDay}',
+ style: FinanceDesignSystem.headingStyle),
+ const Spacer(),
+ if (ctl.selectedDay != null)
+ TextButton(
+ onPressed: () => ctl.loadTrips(),
+ child: const Text('كل الأسبوع',
+ style: TextStyle(fontSize: 12))),
+ ],
+ ),
+ const SizedBox(height: 8),
+ if (ctl.isLoadingTrips)
+ const Padding(
+ padding: EdgeInsets.all(20),
+ child: Center(child: CircularProgressIndicator()),
+ )
+ else if (ctl.trips.isEmpty)
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 20),
+ child: Center(
+ child: Text('لا رحلات في هذه الفترة',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: 13)),
+ ),
+ )
+ else
+ ...ctl.trips.map(_tripTile),
+ ],
+ );
+
+ /// تفصيل الرحلة الواحدة — الإجابة على السؤال الأول عند كل سائق:
+ /// «لماذا أخذت هذا المبلغ من هذه الرحلة؟»
+ Widget _tripTile(TripEarning t) {
+ final b = t.breakdown;
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 8),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.cardColor,
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
+ border: Border.all(color: FinanceDesignSystem.borderColor),
+ ),
+ child: Theme(
+ data: ThemeData(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ tilePadding: const EdgeInsets.symmetric(horizontal: 14),
+ childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+ title: Row(
+ children: [
+ Expanded(
+ child: Text(
+ '${t.from} ← ${t.to}',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: FinanceDesignSystem.primaryDark,
+ fontSize: 13,
+ fontWeight: FontWeight.w600),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text('${b.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
+ style: TextStyle(
+ color: FinanceDesignSystem.successGreen,
+ fontWeight: FontWeight.bold,
+ fontSize: 14)),
+ ],
+ ),
+ subtitle: Padding(
+ padding: const EdgeInsets.only(top: 4),
+ child: Row(
+ children: [
+ Text(
+ '${_timeLabel(t.at)} · ${t.distanceKm.toStringAsFixed(1)} كم · ${t.minutes} د',
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary, fontSize: 11),
+ ),
+ if (b.zeroCommission) ...[
+ const SizedBox(width: 6),
+ Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.successGreen
+ .withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Text('عمولة معفاة',
+ style: TextStyle(
+ color: FinanceDesignSystem.successGreen,
+ fontSize: 9)),
+ ),
+ ],
+ ],
+ ),
+ ),
+ children: [
+ _row('ما دفعه الراكب', b.gross),
+ if (b.bonus > 0) _row('مكافأة', b.bonus, positive: true),
+ _row('عمولة المنصة', b.commission, negative: true),
+ Divider(color: FinanceDesignSystem.borderColor, height: 16),
+ _row('صافي لك', b.net, bold: true),
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ Icon(
+ t.paymentMethod.toLowerCase().contains('cash')
+ ? Icons.money_rounded
+ : Icons.credit_card_rounded,
+ size: 13,
+ color: FinanceDesignSystem.textMuted),
+ const SizedBox(width: 5),
+ Text(t.paymentMethod,
+ style: TextStyle(
+ color: FinanceDesignSystem.textMuted, fontSize: 11)),
+ const Spacer(),
+ Text('#${t.rideId}',
+ style: TextStyle(
+ color: FinanceDesignSystem.textMuted, fontSize: 11)),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _row(String label, double value,
+ {bool bold = false, bool negative = false, bool positive = false}) =>
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 3),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(label,
+ style: TextStyle(
+ color: FinanceDesignSystem.textSecondary,
+ fontSize: bold ? 13 : 12)),
+ ),
+ Text(
+ '${negative ? '− ' : positive ? '+ ' : ''}${value.toStringAsFixed(2)}',
+ style: TextStyle(
+ color: negative
+ ? FinanceDesignSystem.dangerRed
+ : positive
+ ? FinanceDesignSystem.successGreen
+ : FinanceDesignSystem.primaryDark,
+ fontSize: bold ? 14 : 12,
+ fontWeight: bold ? FontWeight.bold : FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ );
+
+ String _timeLabel(String at) {
+ final d = DateTime.tryParse(at);
+ if (d == null) return '';
+ final h = d.hour.toString().padLeft(2, '0');
+ final m = d.minute.toString().padLeft(2, '0');
+ return '$h:$m';
+ }
+}
diff --git a/siro_driver/lib/views/home/statistics/statistics_dashboard.dart b/siro_driver/lib/views/home/statistics/statistics_dashboard.dart
deleted file mode 100644
index f8c6048e..00000000
--- a/siro_driver/lib/views/home/statistics/statistics_dashboard.dart
+++ /dev/null
@@ -1,522 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:get/get.dart';
-import 'package:siro_driver/constant/finance_design_system.dart';
-import 'package:siro_driver/controller/gamification/gamification_controller.dart';
-import 'package:siro_driver/controller/home/statistics/statistics_controller.dart';
-
-import 'widgets/stat_summary_card.dart';
-import 'widgets/daily_goal_widget.dart';
-import 'widgets/level_progress_widget.dart';
-import 'widgets/today_chart_widget.dart';
-import 'widgets/weekly_chart_widget.dart';
-import 'widgets/monthly_chart_widget.dart';
-
-class StatisticsDashboard extends StatelessWidget {
- StatisticsDashboard({super.key});
-
- final GamificationController gamController =
- Get.put(GamificationController());
- final StatisticsController statsController = Get.put(StatisticsController());
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: FinanceDesignSystem.backgroundColor,
- body: GetBuilder(
- builder: (gc) {
- return GetBuilder(
- builder: (sc) {
- if (gc.isLoading || sc.isLoading) {
- return Center(
- child: CircularProgressIndicator(
- color: FinanceDesignSystem.primaryDark),
- );
- }
-
- return CustomScrollView(
- physics: const BouncingScrollPhysics(),
- slivers: [
- // ═══════ App Bar ═══════
- SliverAppBar(
- expandedHeight: 200,
- pinned: true,
- stretch: true,
- backgroundColor: Get.isDarkMode
- ? FinanceDesignSystem.primaryDark
- : const Color(0xFF0A0E21),
- leading: IconButton(
- icon: const Icon(Icons.arrow_back_ios_new_rounded,
- color: Colors.white, size: 20),
- onPressed: () => Get.back(),
- ),
- actions: [
- IconButton(
- icon: const Icon(Icons.refresh_rounded,
- color: Colors.white),
- onPressed: () {
- gc.fetchGamificationData(force: true);
- sc.reloadData(force: true);
- },
- ),
- ],
- flexibleSpace: FlexibleSpaceBar(
- centerTitle: true,
- title: Text(
- 'Statistics'.tr,
- style: const TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 18,
- ),
- ),
- background: Stack(
- fit: StackFit.expand,
- children: [
- Container(
- decoration: BoxDecoration(
- gradient: FinanceDesignSystem.balanceGradient,
- ),
- ),
- // أيقونة زخرفية
- Positioned(
- right: -40,
- top: -20,
- child: Icon(
- Icons.bar_chart_rounded,
- size: 200,
- color: Colors.white.withOpacity(0.04),
- ),
- ),
- Positioned(
- left: -30,
- bottom: 20,
- child: Icon(
- Icons.emoji_events_rounded,
- size: 120,
- color: Colors.white.withOpacity(0.03),
- ),
- ),
- // بطاقة المستوى في الهيدر
- Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const SizedBox(height: 40),
- Text(
- gc.currentLevel.emoji,
- style: const TextStyle(fontSize: 40),
- ),
- const SizedBox(height: 4),
- Text(
- Get.locale?.languageCode == 'ar'
- ? gc.currentLevel.nameAr
- : gc.currentLevel.nameEn,
- style: TextStyle(
- color: Colors.white.withOpacity(0.9),
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- '${gc.unlockedCount}/${gc.totalAchievements} ${'Achievements'.tr}',
- style: TextStyle(
- color: Colors.white.withOpacity(0.5),
- fontSize: 12,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
-
- // ═══════ المحتوى ═══════
- SliverPadding(
- padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
- sliver: SliverList(
- delegate: SliverChildListDelegate([
- // 1. بطاقات الإحصائيات الأربعة
- _buildSummaryCards(gc),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 2. الهدف اليومي
- DailyGoalWidget(controller: gc),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 3. تقدم المستوى
- LevelProgressWidget(controller: gc),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 4. إحصائيات اليوم
- TodayChartWidget(),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 5. الرسم البياني الأسبوعي
- const WeeklyChartWidget(),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 6. التقرير الشهري
- const MonthlyChartWidget(),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 7. تقييم سلوك القيادة
- _buildBehaviorSection(gc),
- const SizedBox(
- height: FinanceDesignSystem.verticalSectionPadding),
-
- // 8. الإنجازات
- _buildAchievementsSection(gc),
- ]),
- ),
- ),
- ],
- );
- },
- );
- },
- ),
- );
- }
-
- // ═══════ بطاقات الإحصائيات ═══════
- Widget _buildSummaryCards(GamificationController gc) {
- return GridView.count(
- crossAxisCount: 2,
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- crossAxisSpacing: 12,
- mainAxisSpacing: 12,
- childAspectRatio: 1.5,
- children: [
- StatSummaryCard(
- icon: Icons.local_taxi_rounded,
- label: 'Total Trips'.tr,
- value: gc.totalTrips.toString(),
- color: FinanceDesignSystem.accentBlue,
- ),
- StatSummaryCard(
- icon: Icons.star_rounded,
- label: 'Rating'.tr,
- value: gc.averageRating.toStringAsFixed(1),
- color: const Color(0xFFFFD700),
- ),
- StatSummaryCard(
- icon: Icons.whatshot_rounded,
- label: 'Day Streak'.tr,
- value: '${gc.consecutiveDays}',
- color: const Color(0xFFFF5722),
- ),
- StatSummaryCard(
- icon: Icons.people_rounded,
- label: 'Referrals'.tr,
- value: gc.totalReferrals.toString(),
- color: const Color(0xFF9C27B0),
- ),
- ],
- );
- }
-
- // ═══════ قسم تقييم السلوك ═══════
- Widget _buildBehaviorSection(GamificationController gc) {
- bool isExcellent = gc.behaviorScore >= 90;
- bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90;
-
- Color statusColor = isExcellent
- ? Colors.green
- : isGood
- ? Colors.orange
- : Colors.red;
-
- String statusText = isExcellent
- ? 'Excellent'.tr
- : isGood
- ? 'Good'.tr
- : 'Needs Improvement'.tr;
-
- return Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.cardColor,
- borderRadius: BorderRadius.circular(24),
- boxShadow: const [
- BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Row(
- children: [
- Icon(Icons.shield_rounded,
- color: FinanceDesignSystem.accentBlue),
- const SizedBox(width: 8),
- Text('Driving Behavior'.tr,
- style: FinanceDesignSystem.headingStyle),
- ],
- ),
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: statusColor.withOpacity(0.1),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- statusText,
- style: TextStyle(
- color: statusColor,
- fontWeight: FontWeight.bold,
- fontSize: 12,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 20),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- _behaviorStatItem(
- title: 'Score'.tr,
- value: '${gc.behaviorScore}%',
- icon: Icons.speed_rounded,
- color: FinanceDesignSystem.accentBlue,
- ),
- _behaviorStatItem(
- title: 'Max Speed'.tr,
- value: '${gc.maxSpeed.toStringAsFixed(0)} km/h',
- icon: Icons.directions_car_rounded,
- color: Colors.orange,
- ),
- _behaviorStatItem(
- title: 'Hard Brakes'.tr,
- value: '${gc.hardBrakes}',
- icon: Icons.warning_rounded,
- color: Colors.red,
- ),
- ],
- ),
- const SizedBox(height: 16),
- ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: LinearProgressIndicator(
- value: gc.behaviorScore / 100,
- backgroundColor: FinanceDesignSystem.backgroundColor,
- valueColor: AlwaysStoppedAnimation(statusColor),
- minHeight: 8,
- ),
- ),
- ],
- ),
- );
- }
-
- Widget _behaviorStatItem(
- {required String title,
- required String value,
- required IconData icon,
- required Color color}) {
- return Column(
- children: [
- Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: color.withOpacity(0.1),
- shape: BoxShape.circle,
- ),
- child: Icon(icon, color: color, size: 24),
- ),
- const SizedBox(height: 8),
- Text(value,
- style: TextStyle(
- fontWeight: FontWeight.bold,
- fontSize: 16,
- color: FinanceDesignSystem.primaryDark)),
- Text(title,
- style: TextStyle(
- fontSize: 12, color: FinanceDesignSystem.textSecondary)),
- ],
- );
- }
-
- // ═══════ قسم الإنجازات ═══════
- Widget _buildAchievementsSection(GamificationController gc) {
- final unlockedAch = gc.achievements.where((a) => a.isUnlocked).toList();
- final lockedAch = gc.achievements.where((a) => !a.isUnlocked).toList();
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.accentBlue.withOpacity(0.1),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- '${gc.unlockedCount}/${gc.totalAchievements}',
- style: TextStyle(
- color: FinanceDesignSystem.accentBlue,
- fontWeight: FontWeight.bold,
- fontSize: 12,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
-
- // الإنجازات المفتوحة
- if (unlockedAch.isNotEmpty) ...[
- ...unlockedAch.map((a) => _buildAchievementCard(a, true)),
- const SizedBox(height: 16),
- ],
-
- // الإنجازات المقفلة
- ...lockedAch.map((a) => _buildAchievementCard(a, false)),
- ],
- );
- }
-
- Widget _buildAchievementCard(Achievement ach, bool unlocked) {
- final isAr = Get.locale?.languageCode == 'ar';
- return Container(
- margin: const EdgeInsets.only(bottom: 12),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: unlocked ? Colors.white : Colors.grey.shade50,
- borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
- border: unlocked
- ? Border.all(color: ach.color.withOpacity(0.3), width: 1.5)
- : null,
- boxShadow: unlocked
- ? [
- BoxShadow(
- color: ach.color.withOpacity(0.1),
- blurRadius: 10,
- offset: const Offset(0, 4),
- ),
- ]
- : null,
- ),
- child: Row(
- children: [
- // أيقونة الإنجاز
- Container(
- width: 50,
- height: 50,
- decoration: BoxDecoration(
- color:
- unlocked ? ach.color.withOpacity(0.15) : Colors.grey.shade200,
- borderRadius: BorderRadius.circular(14),
- ),
- child: Icon(
- ach.icon,
- color: unlocked ? ach.color : Colors.grey.shade400,
- size: 26,
- ),
- ),
- const SizedBox(width: 14),
-
- // المعلومات
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- isAr ? ach.titleAr : ach.titleEn,
- style: TextStyle(
- fontWeight: FontWeight.bold,
- fontSize: 14,
- color: unlocked
- ? FinanceDesignSystem.primaryDark
- : Colors.grey.shade500,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- isAr ? ach.descriptionAr : ach.descriptionEn,
- style: TextStyle(
- fontSize: 11,
- color: Colors.grey.shade500,
- ),
- ),
- if (!unlocked) ...[
- const SizedBox(height: 8),
- // شريط التقدم
- Stack(
- children: [
- Container(
- height: 6,
- decoration: BoxDecoration(
- color: Colors.grey.shade200,
- borderRadius: BorderRadius.circular(3),
- ),
- ),
- FractionallySizedBox(
- widthFactor: ach.progress,
- child: Container(
- height: 6,
- decoration: BoxDecoration(
- color: ach.color.withOpacity(0.6),
- borderRadius: BorderRadius.circular(3),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 4),
- Text(
- '${ach.currentProgress}/${ach.target}',
- style: TextStyle(
- fontSize: 10,
- color: Colors.grey.shade400,
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ],
- ),
- ),
-
- // أيقونة الحالة
- if (unlocked)
- Container(
- padding: const EdgeInsets.all(6),
- decoration: BoxDecoration(
- color: ach.color.withOpacity(0.15),
- shape: BoxShape.circle,
- ),
- child: Icon(
- Icons.check_rounded,
- color: ach.color,
- size: 18,
- ),
- )
- else
- Icon(
- Icons.lock_outline_rounded,
- color: Colors.grey.shade300,
- size: 20,
- ),
- ],
- ),
- );
- }
-}
diff --git a/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart b/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart
new file mode 100644
index 00000000..e8babfca
--- /dev/null
+++ b/siro_driver/lib/views/home/statistics/widgets/behavior_and_achievements.dart
@@ -0,0 +1,311 @@
+// behavior_and_achievements.dart — سلوك القيادة والإنجازات
+//
+// نُقلا من `statistics_dashboard.dart` (حيث كانا دالتين خاصّتين) إلى
+// ودجتين مستقلّتين عند دمج شاشتَي الأرباح والإحصائيات في شاشة واحدة.
+// نسخهما كان سيعني نسختين تفترقان عند أول تعديل.
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import '../../../../constant/finance_design_system.dart';
+import '../../../../controller/gamification/gamification_controller.dart';
+
+class BehaviorSection extends StatelessWidget {
+ final GamificationController gc;
+ const BehaviorSection({super.key, required this.gc});
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isExcellent = gc.behaviorScore >= 90;
+ final bool isGood = gc.behaviorScore >= 75 && gc.behaviorScore < 90;
+
+ final Color statusColor = isExcellent
+ ? Colors.green
+ : isGood
+ ? Colors.orange
+ : Colors.red;
+
+ final String statusText = isExcellent
+ ? 'Excellent'.tr
+ : isGood
+ ? 'Good'.tr
+ : 'Needs Improvement'.tr;
+
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.cardColor,
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: const [
+ BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.shield_rounded,
+ color: FinanceDesignSystem.accentBlue),
+ const SizedBox(width: 8),
+ Text('Driving Behavior'.tr,
+ style: FinanceDesignSystem.headingStyle),
+ ],
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: statusColor.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ statusText,
+ style: TextStyle(
+ color: statusColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _statItem(
+ title: 'Score'.tr,
+ value: '${gc.behaviorScore}%',
+ icon: Icons.speed_rounded,
+ color: FinanceDesignSystem.accentBlue,
+ ),
+ _statItem(
+ title: 'Max Speed'.tr,
+ value: '${gc.maxSpeed.toStringAsFixed(0)} km/h',
+ icon: Icons.directions_car_rounded,
+ color: Colors.orange,
+ ),
+ _statItem(
+ title: 'Hard Brakes'.tr,
+ value: '${gc.hardBrakes}',
+ icon: Icons.warning_rounded,
+ color: Colors.red,
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: LinearProgressIndicator(
+ value: gc.behaviorScore / 100,
+ backgroundColor: FinanceDesignSystem.backgroundColor,
+ valueColor: AlwaysStoppedAnimation(statusColor),
+ minHeight: 8,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _statItem({
+ required String title,
+ required String value,
+ required IconData icon,
+ required Color color,
+ }) =>
+ Column(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.1),
+ shape: BoxShape.circle,
+ ),
+ child: Icon(icon, color: color, size: 24),
+ ),
+ const SizedBox(height: 8),
+ Text(value,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 16,
+ color: FinanceDesignSystem.primaryDark)),
+ Text(title,
+ style: TextStyle(
+ fontSize: 12, color: FinanceDesignSystem.textSecondary)),
+ ],
+ );
+}
+
+class AchievementsSection extends StatelessWidget {
+ final GamificationController gc;
+ const AchievementsSection({super.key, required this.gc});
+
+ @override
+ Widget build(BuildContext context) {
+ final unlocked = gc.achievements.where((a) => a.isUnlocked).toList();
+ final locked = gc.achievements.where((a) => !a.isUnlocked).toList();
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('Achievements'.tr, style: FinanceDesignSystem.headingStyle),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.accentBlue.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ '${gc.unlockedCount}/${gc.totalAchievements}',
+ style: TextStyle(
+ color: FinanceDesignSystem.accentBlue,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ if (unlocked.isNotEmpty) ...[
+ ...unlocked.map((a) => _AchievementCard(ach: a, unlocked: true)),
+ const SizedBox(height: 16),
+ ],
+ ...locked.map((a) => _AchievementCard(ach: a, unlocked: false)),
+ ],
+ );
+ }
+}
+
+class _AchievementCard extends StatelessWidget {
+ final Achievement ach;
+ final bool unlocked;
+
+ const _AchievementCard({required this.ach, required this.unlocked});
+
+ @override
+ Widget build(BuildContext context) {
+ final isAr = Get.locale?.languageCode == 'ar';
+
+ return Container(
+ margin: const EdgeInsets.only(bottom: 12),
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ // كان اللون هنا Colors.white ثابتاً — يختفي النص عليه في الوضع
+ // الداكن. نستعمل لون البطاقة من نظام التصميم.
+ color: unlocked
+ ? FinanceDesignSystem.cardColor
+ : FinanceDesignSystem.cardColor.withValues(alpha: 0.6),
+ borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
+ border: unlocked
+ ? Border.all(color: ach.color.withValues(alpha: 0.3), width: 1.5)
+ : Border.all(color: FinanceDesignSystem.borderColor),
+ boxShadow: unlocked
+ ? [
+ BoxShadow(
+ color: ach.color.withValues(alpha: 0.1),
+ blurRadius: 10,
+ offset: const Offset(0, 4),
+ ),
+ ]
+ : null,
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 50,
+ height: 50,
+ decoration: BoxDecoration(
+ color: unlocked
+ ? ach.color.withValues(alpha: 0.15)
+ : FinanceDesignSystem.textMuted.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Icon(
+ ach.icon,
+ color: unlocked ? ach.color : FinanceDesignSystem.textMuted,
+ size: 26,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ isAr ? ach.titleAr : ach.titleEn,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ color: unlocked
+ ? FinanceDesignSystem.primaryDark
+ : FinanceDesignSystem.textSecondary,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ isAr ? ach.descriptionAr : ach.descriptionEn,
+ style: TextStyle(
+ fontSize: 11,
+ color: FinanceDesignSystem.textSecondary),
+ ),
+ if (!unlocked) ...[
+ const SizedBox(height: 8),
+ Stack(
+ children: [
+ Container(
+ height: 6,
+ decoration: BoxDecoration(
+ color: FinanceDesignSystem.textMuted
+ .withValues(alpha: 0.25),
+ borderRadius: BorderRadius.circular(3),
+ ),
+ ),
+ FractionallySizedBox(
+ widthFactor: ach.progress,
+ child: Container(
+ height: 6,
+ decoration: BoxDecoration(
+ color: ach.color.withValues(alpha: 0.6),
+ borderRadius: BorderRadius.circular(3),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${ach.currentProgress}/${ach.target}',
+ style: TextStyle(
+ fontSize: 10,
+ color: FinanceDesignSystem.textMuted,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ if (unlocked)
+ Container(
+ padding: const EdgeInsets.all(6),
+ decoration: BoxDecoration(
+ color: ach.color.withValues(alpha: 0.15),
+ shape: BoxShape.circle,
+ ),
+ child: Icon(Icons.check_rounded, color: ach.color, size: 18),
+ )
+ else
+ Icon(Icons.lock_outline_rounded,
+ color: FinanceDesignSystem.textMuted, size: 20),
+ ],
+ ),
+ );
+ }
+}
diff --git a/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart
deleted file mode 100644
index a55f92e4..00000000
--- a/siro_driver/lib/views/home/statistics/widgets/monthly_chart_widget.dart
+++ /dev/null
@@ -1,160 +0,0 @@
-import 'package:siro_driver/constant/currency.dart';
-import 'package:flutter/material.dart';
-import 'package:get/get.dart';
-import 'package:fl_chart/fl_chart.dart';
-import '../../../../constant/finance_design_system.dart';
-import '../../../../controller/home/statistics/statistics_controller.dart';
-
-class MonthlyChartWidget extends StatelessWidget {
- const MonthlyChartWidget({super.key});
-
- @override
- Widget build(BuildContext context) {
- return GetBuilder(
- builder: (sc) {
- return Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.cardColor,
- borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.03),
- blurRadius: 10,
- offset: const Offset(0, 4))
- ],
- ),
- child:
- Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
- Text('Monthly Report'.tr,
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.bold,
- color: FinanceDesignSystem.primaryDark)),
- const SizedBox(height: 16),
- // Summary Row
- Row(children: [
- _summaryTile(
- 'Total Earnings'.tr,
- '${sc.monthlyTotalEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}',
- FinanceDesignSystem.successGreen),
- const SizedBox(width: 12),
- _summaryTile('Total Trips'.tr, '${sc.monthlyTotalTrips}',
- FinanceDesignSystem.accentBlue),
- const SizedBox(width: 12),
- _summaryTile(
- 'Best Day'.tr, '${sc.bestDay}', const Color(0xFFFFD700)),
- ]),
- const SizedBox(height: 20),
- // Monthly Earnings Line Chart
- SizedBox(
- height: 200,
- child: sc.monthlyEarnings.isEmpty
- ? Center(
- child: Text('No data yet'.tr,
- style: TextStyle(color: Colors.grey.shade400)))
- : LineChart(LineChartData(
- lineTouchData: LineTouchData(
- enabled: true,
- touchTooltipData: LineTouchTooltipData(
- getTooltipItems: (spots) => spots
- .map((s) => LineTooltipItem(
- '${s.y.toStringAsFixed(0)} ${CurrencyHelper.currency}',
- const TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 12),
- ))
- .toList(),
- ),
- ),
- gridData: FlGridData(
- show: true,
- drawVerticalLine: false,
- getDrawingHorizontalLine: (v) => FlLine(
- color: FinanceDesignSystem.borderColor,
- strokeWidth: 1),
- ),
- titlesData: FlTitlesData(
- bottomTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- interval: 5,
- getTitlesWidget: (v, m) => Padding(
- padding: const EdgeInsets.only(top: 8),
- child: Text('${v.toInt()}',
- style: TextStyle(
- fontSize: 10,
- color: FinanceDesignSystem.textSecondary)),
- ),
- )),
- leftTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- topTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- rightTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- ),
- borderData: FlBorderData(show: false),
- lineBarsData: [
- LineChartBarData(
- spots: sc.monthlyEarnings
- .map((e) =>
- FlSpot(e.day.toDouble(), e.pricePerDay))
- .toList(),
- isCurved: true,
- curveSmoothness: 0.3,
- color: FinanceDesignSystem.accentBlue,
- barWidth: 3,
- dotData: FlDotData(
- show: true,
- getDotPainter: (s, p, d, i) => FlDotCirclePainter(
- radius: 3,
- color: FinanceDesignSystem.accentBlue,
- strokeWidth: 1,
- strokeColor: Colors.white),
- ),
- belowBarData: BarAreaData(
- show: true,
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: [
- FinanceDesignSystem.accentBlue.withOpacity(0.2),
- FinanceDesignSystem.accentBlue.withOpacity(0.0)
- ],
- ),
- ),
- ),
- ],
- )),
- ),
- ]),
- );
- },
- );
- }
-
- Widget _summaryTile(String label, String value, Color color) {
- return Expanded(
- child: Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: color.withOpacity(0.08),
- borderRadius: BorderRadius.circular(12)),
- child: Column(children: [
- Text(value,
- style: TextStyle(
- fontSize: 14, fontWeight: FontWeight.w800, color: color)),
- const SizedBox(height: 2),
- Text(label,
- style: TextStyle(
- fontSize: 9, color: FinanceDesignSystem.textSecondary),
- textAlign: TextAlign.center,
- maxLines: 1,
- overflow: TextOverflow.ellipsis),
- ]),
- ),
- );
- }
-}
diff --git a/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart
deleted file mode 100644
index 1bae2b17..00000000
--- a/siro_driver/lib/views/home/statistics/widgets/today_chart_widget.dart
+++ /dev/null
@@ -1,86 +0,0 @@
-import 'package:siro_driver/constant/currency.dart';
-import 'package:flutter/material.dart';
-import 'package:get/get.dart';
-import 'package:siro_driver/controller/home/captin/home_captain_controller.dart';
-import '../../../../constant/finance_design_system.dart';
-
-class TodayChartWidget extends StatelessWidget {
- TodayChartWidget({super.key});
-
- @override
- Widget build(BuildContext context) {
- return GetBuilder(
- builder: (hc) {
- return Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.cardColor,
- borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.03),
- blurRadius: 10,
- offset: const Offset(0, 4)),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('Today Overview'.tr,
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.bold,
- color: FinanceDesignSystem.primaryDark)),
- const SizedBox(height: 4),
- Text('Summary of your daily activity'.tr,
- style: TextStyle(
- fontSize: 11, color: FinanceDesignSystem.textSecondary)),
- const SizedBox(height: 20),
- _buildRow(
- Icons.monetization_on_rounded,
- 'Earnings'.tr,
- '${hc.totalMoneyToday} ${CurrencyHelper.currency}',
- FinanceDesignSystem.successGreen),
- const Divider(height: 24),
- _buildRow(Icons.local_taxi_rounded, 'Rides'.tr, hc.countRideToday,
- FinanceDesignSystem.accentBlue),
- const Divider(height: 24),
- Obx(() => _buildRow(Icons.timer_rounded, 'Online Duration'.tr,
- hc.totalDurationDisplay.value, const Color(0xFFFF9800))),
- const Divider(height: 24),
- _buildRow(Icons.cancel_outlined, 'Refused'.tr, hc.countRefuse,
- FinanceDesignSystem.dangerRed),
- ],
- ),
- );
- },
- );
- }
-
- Widget _buildRow(IconData icon, String label, String value, Color color) {
- return Row(
- children: [
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: color.withOpacity(0.1),
- borderRadius: BorderRadius.circular(10)),
- child: Icon(icon, color: color, size: 20),
- ),
- const SizedBox(width: 14),
- Expanded(
- child: Text(label,
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w500,
- color: FinanceDesignSystem.primaryDark))),
- Text(value,
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w800,
- color: FinanceDesignSystem.primaryDark,
- fontFamily: 'digit')),
- ],
- );
- }
-}
diff --git a/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart b/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart
deleted file mode 100644
index d8f30a2d..00000000
--- a/siro_driver/lib/views/home/statistics/widgets/weekly_chart_widget.dart
+++ /dev/null
@@ -1,173 +0,0 @@
-import 'package:siro_driver/constant/currency.dart';
-import 'package:flutter/material.dart';
-import 'package:get/get.dart';
-import 'package:fl_chart/fl_chart.dart';
-import '../../../../constant/finance_design_system.dart';
-import '../../../../controller/home/statistics/statistics_controller.dart';
-
-class WeeklyChartWidget extends StatelessWidget {
- const WeeklyChartWidget({super.key});
-
- @override
- Widget build(BuildContext context) {
- return GetBuilder(
- builder: (sc) {
- return Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.cardColor,
- borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.03),
- blurRadius: 10,
- offset: const Offset(0, 4))
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
- Text('Weekly Earnings'.tr,
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.bold,
- color: FinanceDesignSystem.primaryDark)),
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: FinanceDesignSystem.successGreen.withOpacity(0.1),
- borderRadius: BorderRadius.circular(12)),
- child: Text(
- '${sc.weeklyEarnings.toStringAsFixed(0)} ${CurrencyHelper.currency}',
- style: TextStyle(
- fontSize: 12,
- fontWeight: FontWeight.bold,
- color: FinanceDesignSystem.successGreen)),
- ),
- ]),
- const SizedBox(height: 8),
- Row(children: [
- _miniStat(Icons.local_taxi_rounded, '${sc.weeklyTrips}',
- 'Rides'.tr, FinanceDesignSystem.accentBlue),
- const SizedBox(width: 16),
- _miniStat(
- Icons.timer_rounded,
- '${sc.weeklyHours.toStringAsFixed(1)}h',
- 'Hours'.tr,
- const Color(0xFFFF9800)),
- ]),
- const SizedBox(height: 20),
- SizedBox(
- height: 180,
- child: sc.weeklyStats.isEmpty
- ? Center(
- child: Text('No data yet'.tr,
- style: TextStyle(color: Colors.grey.shade400)))
- : BarChart(
- BarChartData(
- alignment: BarChartAlignment.spaceAround,
- maxY: _getMaxY(sc.weeklyStats),
- barTouchData: BarTouchData(
- enabled: true,
- touchTooltipData: BarTouchTooltipData(
- getTooltipItem: (group, gi, rod, ri) =>
- BarTooltipItem(
- '${rod.toY.toStringAsFixed(0)} ${CurrencyHelper.currency}',
- const TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 12),
- ),
- ),
- ),
- titlesData: FlTitlesData(
- show: true,
- bottomTitles: AxisTitles(
- sideTitles: SideTitles(
- showTitles: true,
- getTitlesWidget: (v, m) {
- final idx = v.toInt();
- if (idx >= 0 &&
- idx < sc.weeklyStats.length) {
- return Padding(
- padding:
- const EdgeInsets.only(top: 8),
- child: Text(
- sc.weeklyStats[idx].dayName.tr,
- style: TextStyle(
- fontSize: 10,
- color: FinanceDesignSystem
- .textSecondary)));
- }
- return const SizedBox.shrink();
- })),
- leftTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- topTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- rightTitles: const AxisTitles(
- sideTitles: SideTitles(showTitles: false)),
- ),
- borderData: FlBorderData(show: false),
- gridData: const FlGridData(show: false),
- barGroups: List.generate(sc.weeklyStats.length, (i) {
- final stat = sc.weeklyStats[i];
- final isToday = i == sc.weeklyStats.length - 1;
- return BarChartGroupData(x: i, barRods: [
- BarChartRodData(
- toY: stat.earnings,
- width: 20,
- borderRadius: const BorderRadius.vertical(
- top: Radius.circular(6)),
- gradient: LinearGradient(
- begin: Alignment.bottomCenter,
- end: Alignment.topCenter,
- colors: isToday
- ? [
- FinanceDesignSystem.accentBlue,
- const Color(0xFF82B1FF)
- ]
- : [
- FinanceDesignSystem.primaryDark
- .withOpacity(0.6),
- FinanceDesignSystem.primaryDark
- .withOpacity(0.3)
- ],
- ),
- ),
- ]);
- }),
- ),
- ),
- ),
- ],
- ),
- );
- },
- );
- }
-
- Widget _miniStat(IconData icon, String value, String label, Color color) {
- return Row(children: [
- Icon(icon, size: 16, color: color),
- const SizedBox(width: 4),
- Text(value,
- style: TextStyle(
- fontSize: 13,
- fontWeight: FontWeight.bold,
- color: FinanceDesignSystem.primaryDark)),
- const SizedBox(width: 4),
- Text(label,
- style: TextStyle(
- fontSize: 11, color: FinanceDesignSystem.textSecondary)),
- ]);
- }
-
- double _getMaxY(List stats) {
- if (stats.isEmpty) return 100;
- final max = stats.map((s) => s.earnings).reduce((a, b) => a > b ? a : b);
- return max <= 0 ? 100 : max * 1.2;
- }
-}