235 lines
9.9 KiB
PHP
235 lines
9.9 KiB
PHP
<?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();
|
||
}
|