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
@@ -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;
@@ -0,0 +1,198 @@
<?php
/**
* deduct_s2s_obligation.php — خصم التزام من محفظة السائق
* ─────────────────────────────────────────────────────────────
* ‏نقطة الخصم الوحيدة لمحرك الالتزامات (تأمين، وقود، صيانة، تمويل).
*
* ‏لماذا لا نستخدم add_s2s_reward.php بمبلغ سالب؟
*
* ١. ‏لا يضمن عدم التكرار. لا مفتاح فريد على paymentID ولا فحص إلا
* ‏لتحديات daily_/weekly_. إيداع مكرّر يُسترد، أما خصم مكرّر من
* ‏رصيد سائق فهو عطل لا يُصلحه اعتذار.
*
* ٢. ‏السقف اليومي يجب أن يُحسب حيث تعيش البيانات. أرباح اليوم في
* ‏هذه القاعدة لا في القاعدة الرئيسية، وحسابه هناك كان سيعني
* ‏نقل الرصيد عبر الشبكة ثم اتخاذ قرار مالي على لقطة قديمة.
*
* ‏العقد مع المحرك:
* ‏المدخل : settlement_ref, driverID, product_code, amount, cap_percent
* ‏المخرج : deducted (ما خُصم فعلاً — قد يكون أقل من المطلوب أو صفراً)
*
* ‏الخصم الجزئي ليس فشلاً بل السلوك الطبيعي: السقف يحمي السائق، والمتبقي
* ‏يُرحَّل ليوم أفضل. المحرك هو من يقرّر ماذا يفعل بالمتبقي.
*
* ‏إعادة الطلب بنفس settlement_ref آمنة دائماً: لا تخصم شيئاً وتُرجع
* ‏نتيجة المحاولة الأولى حرفياً. المحرك يبني المرجع على عدّاد محاولات
* ‏القيد لا على التاريخ، فنداء ضاع ردّه يُعاد بالمرجع نفسه مهما تأخّر.
*/
require_once __DIR__ . '/../../jwtconnect.php';
// ‏jwtconnect يقبل ستة مسارات مصادقة. الخصم من مال السائق يقبل واحداً:
// ‏نداء خادم-لخادم. لا JWT سائق — لا معنى لأن يطلب سائق خصم نفسه.
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
if (empty($expectedKey) || empty($providedKey) || !hash_equals($expectedKey, $providedKey)) {
http_response_code(401);
printFailure('Unauthorized: Invalid or missing X-S2S-Api-Key.');
exit;
}
$settlementRef = filterRequest('settlement_ref');
$driverID = filterRequest('driverID');
$productCode = filterRequest('product_code');
$amount = filterRequest('amount', 'numeric');
$capPercent = filterRequest('cap_percent', 'numeric');
if (empty($settlementRef) || empty($driverID) || empty($productCode) || $amount === null) {
printFailure('Missing required parameters: settlement_ref, driverID, product_code, amount');
exit;
}
$amount = round((float) $amount, 3);
$capPercent = $capPercent === null ? 25.0 : (float) $capPercent;
if ($amount <= 0) {
printFailure('amount must be greater than zero');
exit;
}
// ‏سقف بلا حدّ أعلى يعني تفريغ محفظة السائق في يوم واحد بضغطة إعداد
// ‏خاطئة. مئة بالمئة مسموحة كحدّ أقصى صريح لا كنتيجة خطأ مطبعي.
if ($capPercent < 0 || $capPercent > 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');
}
@@ -0,0 +1,77 @@
<?php
/**
* income_summary_s2s.php — ملخّص دخل السائق المارّ بالمحفظة
* ─────────────────────────────────────────────────────────────
* ‏يجيب على سؤال واحد: هل قناة تحصيل هذا السائق حيّة؟
*
* ‏تستعمله بوابة الائتمان في محرك الالتزامات. سائق الكاش الذي يحصّل
* ‏نقداً ولا تمرّ أرباحه بالمحفظة يظهر بأرباح صفر كل يوم، فسقف الخصم
* ‏اليومي صفر، فلا يُحصَّل منه شيء أبداً — ومنحه وقوداً أو صيانة بالدَّين
* ‏يعني تسليمه قيمةً فعلية مقابل وعد بالسداد من قناة لا يمرّ بها ماله.
*
* ‏المقياس الأساسي أيام لا مبلغ: السؤال ليس «كم يكسب؟» بل «هل المال
* ‏يمرّ من هنا بانتظام؟». دخل واحد كبير قد يكون استرداداً أو تسوية
* ‏حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ تعمل.
*
* ‏قراءة فقط — لا تكتب شيئاً ولا تلمس رصيداً.
*/
require_once __DIR__ . '/../../jwtconnect.php';
// ‏نداء خادم-لخادم حصراً. الرد يُبنى عليه قرار منح ائتمان، فلا يُفتح
// ‏لجلسة سائق قد تسأل عن غيره.
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
if (empty($expectedKey) || empty($providedKey) || !hash_equals($expectedKey, $providedKey)) {
http_response_code(401);
printFailure('Unauthorized: Invalid or missing X-S2S-Api-Key.');
exit;
}
$driverID = filterRequest('driverID');
$windowDays = (int) (filterRequest('window_days', 'numeric') ?: 30);
if (empty($driverID)) {
printFailure('Missing required parameter: driverID');
exit;
}
// ‏نافذة محدودة: مسح سنوات من الحركات لكل فحص أهلية حِمل بلا فائدة —
// ‏دخلُ سائقٍ قبل ستة أشهر لا يقول شيئاً عن قناته اليوم.
if ($windowDays < 1 || $windowDays > 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');
}