feat: المجموعة J — الرصيد التشغيلي + تصحيح نموذج العمولة (B3/B6)

فلسفة العمولة الصحيحة (docs/18): السائق يقبض أجرة الراكب **كاملة**،
والعمولة تُخصم من رصيد تشغيلي مدفوع سلفاً. ما بُني في f3fdc1b كان نموذج
أوبر (اقتطاع من الأرباح) — عكس المطلوب.

- J1: driver_credits + credit_txns. خصم ذرّي كنمط I1 لكن **بلا شرط رصيد**
  ولا قيد >= 0: الدين مسموح عمداً (قرار المالك: الرحلة لا تُقطع أبداً)
- J2: خصم العمولة عند completed لا paid — الرحلة تمّت فالعمولة استُحقّت.
  قاعدة واحدة للكاش والمحفظة (§5.5): لا تفريع حسب وسيلة الدفع
- J3: price_for_driver = price_for_passenger. TariffEngine.split →
  commission، وبلا سقف بالأجرة لأن الخصم على رصيد منفصل يجوز أن يسلب
- J4: الحجب عند تجاوز credit_floor فقط، عند setOnline و accept — لا أثناء
  رحلة. الحجب يمنع الاتصال أصلاً وإلا عُرضت رحلات يُرفض قبولها
- J6: مكافأة التسجيل عند الاعتماد (3 JOD / 300 SYP جديدة / 300 EGP).
  حارسان: فحص تطبيقي للحالة الشائعة + فهرس فريد جزئي للسباق
- J7: credit_floor لكل عملة
- J8: GET /credit + /credit/transactions

تصحيح B3: مشوار الوصول تعويضُ إلغاء بعد انقضاء الانتظار المجاني — لا بند
في كل أجرة. أُزيل مفتاح pickup_leg.charge.

هجرة: DriverCredit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 14:03:23 +03:00
co-authored by Claude Opus 4.8
parent 2edd8f6916
commit d9b0faab2d
17 changed files with 736 additions and 142 deletions
+20 -21
View File
@@ -97,8 +97,11 @@ export class TariffEngine {
}
/**
* رسم مشوار وصول السائق للراكب (docs/17 — B3).
* يرجع صفراً ما لم يُفعِّله المالك صراحةً في التعرفة.
* قيمة مشوار وصول السائق للراكب (docs/17 — B3).
*
* **ليست بنداً في أجرة الرحلة.** تُحسب دائماً وتُخزَّن، لكنها لا تُحصَّل إلا
* كـ**تعويض إلغاء**: إن انتظر السائق دقائقه المجانية ثم أُلغيت الرحلة، يقبض
* قيمة المشوار الذي قطعه بلا مقابل.
*/
static pickupCharge(
def: TariffDefinition,
@@ -106,7 +109,6 @@ export class TariffEngine {
durationMin: number,
at: Date = new Date(),
): number {
if (!def.pickup_leg?.charge) return 0;
const win = TariffEngine.pickWindow(def.windows, at);
const charge =
Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min;
@@ -114,30 +116,27 @@ export class TariffEngine {
}
/**
* فصل السعر (docs/17 — B6): يقسم ما يدفعه الراكب إلى عمولة المنصة وحصة
* السائق. العمولة لا تتجاوز الأجرة مهما كانت الإعدادات — وإلا صار دخل
* السائق سالباً.
* حساب العمولة (docs/18).
*
* **العمولة لا تُقتطع من أجرة السائق** — السائق يقبض الأجرة كاملة من الراكب،
* والعمولة تُخصم من رصيده التشغيلي المدفوع سلفاً. هذه الدالة تحسب المبلغ
* المستحق فقط؛ من يخصمه هو `DriverCreditService`.
*
* لذلك **لا سقف** يمنع تجاوز العمولة للأجرة هنا: الخصم يقع على رصيد منفصل
* يجوز أن يصير سالباً، لا على دخل السائق.
*/
static split(
static commission(
def: TariffDefinition,
passengerTotal: number,
): { forPassenger: number; forDriver: number; commission: number; rate: number } {
): { amount: number; rate: number } {
const total = Math.max(0, passengerTotal ?? 0);
const c = def.commission;
if (!c) {
return { forPassenger: TariffEngine.n(total), forDriver: TariffEngine.n(total), commission: 0, rate: 0 };
}
if (!c) return { amount: 0, rate: 0 };
const percent = c.percent ?? 0;
let commission = total * (percent / 100) + (c.flat ?? 0);
if (c.min != null && commission < c.min) commission = c.min;
if (commission > total) commission = total;
commission = TariffEngine.n(commission);
return {
forPassenger: TariffEngine.n(total),
forDriver: TariffEngine.n(total - commission),
commission,
rate: percent,
};
let amount = total * (percent / 100) + (c.flat ?? 0);
if (c.min != null && amount < c.min) amount = c.min;
return { amount: TariffEngine.n(amount), rate: percent };
}
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {