199 lines
9.2 KiB
PHP
199 lines
9.2 KiB
PHP
<?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');
|
||
}
|