Files
tripz-llc/payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php
T
2026-08-09 16:56:13 +03:00

199 lines
9.2 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
/**
* 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');
}