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(); }