Files
tripz-llc/backend/obligations/functions.php
T
2026-08-09 16:56:13 +03:00

235 lines
9.9 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}