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

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
+234
View File
@@ -0,0 +1,234 @@
<?php
/**
* obligations/functions.php — دوال محرك الالتزامات المشتركة
* ─────────────────────────────────────────────────────────────
* ‏الواجهة التي تستعملها المنتجات (تأمين، وقود، صيانة) لفتح التزام
* ‏وإغلاقه وقراءة المستحق عليه. لا شيء هنا يلمس مالاً: الخصم حكرٌ على
* ‏`bot/cron_obligation_settlement.php` وحده.
*
* ‏سبب وجود هذا الملف أن `driver_assurance/` كان يكتب في جداوله الخاصة،
* ‏وبقاؤه كذلك بعد الترحيل كان يعني أن كل اشتراك **جديد** لا يظهر في
* ‏الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل
* ‏لمرة واحدة يعالج الماضي؛ هذه الدوال تمنع تكرار الثغرة في المستقبل.
*/
const OBLIGATION_HTTP_TIMEOUT = 15;
function obligationWalletServerUrl(): string
{
// ‏bootstrap.php يعرّف GLOBAL_COUNTRY ثابتاً ولا يضعه في البيئة —
// ‏استخدم الثابت لا getenv (نفس فخّ foodWalletServerUrl).
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
return match ($country) {
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
};
}
/**
* ‏نداء خادم-لخادم إلى سيرفر المحفظة. يُرجع الحمولة أو null عند الفشل.
*
* ‏printSuccess في سيرفر المحفظة يغلّف الحمولة في المفتاح "message" —
* ‏اصطلاحه لا خطأ تسمية هنا.
*/
function obligationWalletCall(string $path, array $params): ?array
{
$key = getenv('S2S_SHARED_KEY') ?: '';
if ($key === '') {
error_log('[obligation] S2S_SHARED_KEY غير معرّف');
return null;
}
$ch = curl_init(obligationWalletServerUrl() . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => OBLIGATION_HTTP_TIMEOUT,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . $key,
],
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($body === false || $code !== 200) {
error_log("[obligation] نداء المحفظة فشل ($path): HTTP $code $err");
return null;
}
$json = json_decode((string) $body, true);
if (!is_array($json) || ($json['status'] ?? '') !== 'success' || !is_array($json['message'] ?? null)) {
error_log("[obligation] رد غير متوقّع ($path): " . substr((string) $body, 0, 200));
return null;
}
return $json['message'];
}
/**
* ‏ملخّص دخل السائق المارّ بالمحفظة في آخر ثلاثين يوماً، أو null عند
* ‏تعذّر الوصول. يُقرأ من سيرفر المحفظة لأن الحركات تعيش هناك.
*/
function obligationWalletIncomeSummary(string $driverId): ?array
{
return obligationWalletCall(
'/v2/main/ride/driverWallet/income_summary_s2s.php',
['driverID' => $driverId, 'window_days' => 30]
);
}
/**
* ‏بوابة الائتمان: هل تمرّ أرباح هذا السائق بالمحفظة بما يكفي؟
*
* ‏قرار المالك (2026-08-09): لا التزام ائتماني لمن لا تمرّ أرباحه
* ‏بالمحفظة. السبب أن سقف الخصم اليومي نسبة من الدخل المارّ بها، فسائق
* ‏الكاش سقفه صفر دائماً ولا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو
* ‏صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها.
*
* ‏الفشل مغلق: تعذُّر الوصول إلى سيرفر المحفظة يعني **رفضاً** لا قبولاً.
* ‏عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد
* ‏لا يُسترد منه — والخطأ الثاني وحده هو الذي يكلّف مالاً.
*
* ‏`$summary` يُمرَّر جاهزاً حين تُفحص عدة منتجات لسائق واحد (شاشة
* ‏الخطط): الملخّص عن السائق لا عن المنتج، ونداء لكل خطة يعني أربعة
* ‏نداءات شبكية لبناء شاشة واحدة.
*
* @return array{eligible: bool, reasons: string[], summary: array|null}
*/
function obligationCheckWalletIncome(array $product, string $driverId, ?array $summary = null): array
{
if ((int) ($product['requires_wallet_income'] ?? 1) !== 1) {
return ['eligible' => true, 'reasons' => [], 'summary' => null];
}
$minDays = (int) ($product['min_wallet_days_30d'] ?? 0);
$minIncome = (float) ($product['min_wallet_income_30d'] ?? 0);
$summary ??= obligationWalletIncomeSummary($driverId);
if ($summary === null) {
return [
'eligible' => false,
'reasons' => ['تعذّر التحقق من حركة محفظتك حالياً. حاول لاحقاً.'],
'summary' => null,
];
}
$days = (int) ($summary['days_with_income'] ?? 0);
$income = (float) ($summary['total_income'] ?? 0);
$reasons = [];
if ($days < $minDays) {
$reasons[] = "أرباحك مرّت بالمحفظة في $days يوماً خلال آخر ٣٠ يوماً، والمطلوب $minDays";
}
if ($minIncome > 0 && $income < $minIncome) {
$reasons[] = 'إجمالي ما مرّ بمحفظتك خلال آخر ٣٠ يوماً أقل من الحد المطلوب';
}
return [
'eligible' => empty($reasons),
'reasons' => $reasons,
'summary' => $summary,
];
}
/**
* ‏يقرأ منتج التزام برمزه. يُرجع null إن لم يوجد أو كان مُطفأً.
*/
function obligationProduct(PDO $con, string $productCode): ?array
{
$st = $con->prepare("
SELECT * FROM obligation_products WHERE code = ? AND is_active = 1 LIMIT 1
");
$st->execute([$productCode]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* ‏يفتح التزاماً لسائق على منتج، ويُرجع معرّفه.
*
* ‏`cycle_amount` يُنسخ من المنتج لحظة الفتح ولا يُقرأ منه لاحقاً: رفع
* ‏سعر المنتج غداً يجب ألّا يغيّر قسط من اشترك اليوم بأثر رجعي.
*/
function obligationOpen(
PDO $con,
string $driverId,
string $productCode,
array $snapshot = [],
float $principal = 0.0,
?string $externalRef = null
): int {
$product = obligationProduct($con, $productCode);
if (!$product) {
throw new RuntimeException("منتج الالتزام غير موجود أو غير مفعّل: $productCode");
}
// ‏البوابة تُفحص هنا أيضاً لا في نقطة الاستدعاء وحدها. النقطة قد
// ‏تفحص وتنسى، أو يُضاف منتج جديد بنقطة جديدة تُغفلها — والفحص عند
// ‏الكتابة هو الوحيد الذي لا يمكن الالتفاف عليه بالسهو.
$gate = obligationCheckWalletIncome($product, $driverId);
if (!$gate['eligible']) {
throw new RuntimeException('بوابة الائتمان: ' . implode('، ', $gate['reasons']));
}
$con->prepare("
INSERT INTO driver_obligations
(driver_id, product_id, external_ref, status, started_at,
cycle_amount, principal_amount, rides_at_signup, rating_at_signup)
VALUES (?, ?, ?, 'active', CURDATE(), ?, ?, ?, ?)
")->execute([
$driverId,
(int) $product['id'],
$externalRef,
(float) ($snapshot['cycle_amount'] ?? 0),
$principal,
(int) ($snapshot['rides'] ?? 0),
(float) ($snapshot['rating'] ?? 0),
]);
return (int) $con->lastInsertId();
}
/**
* ‏يُغلق التزاماً نشطاً. لا يمسّ القيود المستحقّة — تصفيرها عند الإلغاء
* ‏كانت ستجعل الإلغاء وسيلةً للتهرّب من أيام استفاد منها السائق فعلاً.
*/
function obligationClose(PDO $con, string $driverId, string $productCode, string $reason): bool
{
$st = $con->prepare("
UPDATE driver_obligations o
JOIN obligation_products p ON p.id = o.product_id
SET o.status = 'cancelled', o.ended_at = CURDATE(), o.cancel_reason = ?
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
");
$st->execute([mb_substr($reason, 0, 255), $driverId, $productCode]);
return $st->rowCount() > 0;
}
/**
* ‏مجموع ما بقي على السائق في منتج ما.
*
* ‏الأساس `amount_remaining` لا `amount`: قيدٌ حُصِّل نصفه لم يعد مستحقاً
* ‏بكامله، وعرض الأصل للسائق بعد اقتطاعٍ منه مطالبةٌ بما دُفع.
*/
function obligationPendingTotal(PDO $con, string $driverId, string $productCode): float
{
$st = $con->prepare("
SELECT COALESCE(SUM(amount_remaining), 0)
FROM obligation_ledger
WHERE driver_id = ? AND product_code = ?
AND status IN ('pending', 'partial')
");
$st->execute([$driverId, $productCode]);
return (float) $st->fetchColumn();
}