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
+17 -2
View File
@@ -1,17 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Driver } from './entities/driver.entity';
import { UsersService } from '../users/users.service';
import { DriverLocationService } from '../locations/driver-location.service';
import { DriverCreditService } from '../credit/driver-credit.service';
@Injectable()
export class DriversService {
private readonly logger = new Logger('Drivers');
constructor(
@InjectRepository(Driver)
private readonly repo: Repository<Driver>,
private readonly users: UsersService,
private readonly locations: DriverLocationService,
private readonly credit: DriverCreditService,
) {}
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
@@ -59,7 +63,13 @@ export class DriversService {
const driver = await this.findById(tenantId, driverId);
if (!driver) throw new NotFoundException('Driver not found');
driver.verification_status = 'approved';
return this.repo.save(driver);
const saved = await this.repo.save(driver);
// مكافأة التسجيل عند الاعتماد لا عند التقديم — حتى لا يحصدها من رُفض
// (docs/18 — J6). آمنة للتكرار: القاعدة تضمن مرة واحدة لكل سائق.
await this.credit.grantSignupBonus(tenantId, driver.id).catch((e: any) =>
this.logger.warn(`signup bonus failed for ${driver.id}: ${e?.message}`),
);
return saved;
}
/** يبدّل حالة الاتصال؛ عند الاتصال يدخل فهرس المتاحين، وعند الفصل يُزال. */
@@ -70,6 +80,11 @@ export class DriversService {
): Promise<Driver> {
const driver = await this.findByUser(tenantId, userId);
if (!driver) throw new NotFoundException('Driver profile not found');
// الدين تجاوز الأرضية → لا يتصل أصلاً؛ وإلا عُرضت عليه رحلات يُرفض قبولها
// فيبدو النظام معطوباً بدل أن يفهم أن عليه الشحن (docs/18 — J4).
if (online && (await this.credit.isBlocked(tenantId, driver.id))) {
throw new ForbiddenException('credit_exhausted');
}
driver.is_online = online;
await this.repo.save(driver);
await this.locations.setAvailability(