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
+2
View File
@@ -26,6 +26,7 @@ import { NabehModule } from './integrations/nabeh/nabeh.module';
import { RideTypesModule } from './modules/ride-types/ride-types.module';
import { DispatchModule } from './modules/dispatch/dispatch.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { CreditModule } from './modules/credit/credit.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { StorageModule } from './common/storage/storage.module';
@@ -77,6 +78,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
RealtimeModule,
FraudModule,
WalletModule,
CreditModule,
PaymentsModule,
TripsModule,
DispatchModule,
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* الرصيد التشغيلي للسائق (docs/18 — المجموعة J).
* عمولة مدفوعة سلفاً، منفصلة تماماً عن محفظة أرباحه.
*
* **بلا قيد `balance >= 0`** — بخلاف `tripz_wallets`: الدين مسموح عمداً
* (قرار المالك: الرحلة لا تُقطع أبداً ولو صار الرصيد سالباً).
*/
export class DriverCredit1721850000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_driver_credits (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
balance numeric(12,3) NOT NULL DEFAULT 0,
currency varchar NOT NULL DEFAULT 'JOD',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_driver_credits_tenant_driver"
ON tripz_driver_credits (tenant_id, driver_id)
`);
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_credit_txns (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
amount numeric(12,3) NOT NULL,
type varchar NOT NULL,
balance_after numeric(12,3),
trip_id uuid,
ref varchar,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(`
CREATE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_driver_time"
ON tripz_credit_txns (tenant_id, driver_id, created_at)
`);
// مكافأة التسجيل مرة واحدة لكل سائق — تُفرض على القاعدة لا بفحص تطبيقي،
// فتسجيلان متزامنان لا يمنحانها مرتين (docs/18 — J6).
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_signup_once"
ON tripz_credit_txns (tenant_id, driver_id)
WHERE type = 'signup_bonus'
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`);
await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`);
}
}
@@ -0,0 +1,42 @@
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DriverCreditService, CREDIT_FLOOR } from './driver-credit.service';
import { DriversService } from '../drivers/drivers.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('credit')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('credit')
export class CreditController {
constructor(
private readonly credit: DriverCreditService,
private readonly drivers: DriversService,
) {}
/** رصيدي التشغيلي — يعرضه تطبيق السائق (docs/18 — J8). */
@Get()
async mine(@CurrentUser() user: AuthUser) {
const driver = await this.drivers.findByUser(user.tenantId, user.userId);
if (!driver) throw new ForbiddenException('Not a driver');
const c = await this.credit.get(user.tenantId, driver.id);
const floor = CREDIT_FLOOR[c.currency] ?? 0;
const balance = Number(c.balance);
return {
balance,
currency: c.currency,
floor,
inDebt: balance < 0,
blocked: balance < floor,
};
}
@Get('transactions')
async history(@CurrentUser() user: AuthUser) {
const driver = await this.drivers.findByUser(user.tenantId, user.userId);
if (!driver) throw new ForbiddenException('Not a driver');
return this.credit.history(user.tenantId, driver.id);
}
}
@@ -0,0 +1,22 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DriverCredit } from './entities/driver-credit.entity';
import { CreditTxn } from './entities/credit-txn.entity';
import { DriverCreditService } from './driver-credit.service';
import { CreditController } from './credit.controller';
import { DriversModule } from '../drivers/drivers.module';
/**
* دورة متبادلة مقصودة: drivers يحتاج الرصيد (حجب/مكافأة)، والكنترولر هنا
* يحتاج drivers (ليحوّل user_id إلى driver_id) — forwardRef يفكّها.
*/
@Module({
imports: [
TypeOrmModule.forFeature([DriverCredit, CreditTxn]),
forwardRef(() => DriversModule),
],
controllers: [CreditController],
providers: [DriverCreditService],
exports: [DriverCreditService],
})
export class CreditModule {}
@@ -0,0 +1,152 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { DriverCredit } from './entities/driver-credit.entity';
import { CreditTxn } from './entities/credit-txn.entity';
import { DriverCreditService, SIGNUP_BONUS, CREDIT_FLOOR } from './driver-credit.service';
const TENANT = '11111111-1111-1111-1111-111111111111';
const DRIVER = '22222222-2222-2222-2222-222222222222';
describe('DriverCreditService (docs/18)', () => {
let ds: DataSource;
let credit: DriverCreditService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' });
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [DriverCredit, CreditTxn],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
credit = new DriverCreditService(ds.getRepository(DriverCredit), ds.getRepository(CreditTxn));
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('حساب جديد يبدأ بصفر', async () => {
expect(Number((await credit.get(TENANT, DRIVER)).balance)).toBe(0);
});
it('الشحن يرفع الرصيد', async () => {
await credit.topup(TENANT, DRIVER, 10, 'pay-1');
expect(Number((await credit.get(TENANT, DRIVER)).balance)).toBe(10);
});
it('مثال المالك المرجعي: شحن 4 ثم عمولة 0.4 → 3.6', async () => {
await credit.topup(TENANT, DRIVER, 4);
const c = await credit.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1');
expect(Number(c.balance)).toBeCloseTo(3.6);
});
describe('الرصيد السالب مسموح — الرحلة لا تُقطع', () => {
it('العمولة تُخصم ولو تجاوزت الرصيد', async () => {
await credit.topup(TENANT, DRIVER, 1);
const c = await credit.chargeCommission(TENANT, DRIVER, 3, 'trip-1');
expect(Number(c.balance)).toBeCloseTo(-2); // دين، لا رفض
});
it('الخصم من رصيد صفر ينجح ويصير ديناً', async () => {
const c = await credit.chargeCommission(TENANT, DRIVER, 0.5, 'trip-1');
expect(Number(c.balance)).toBeCloseTo(-0.5);
});
});
describe('الحجب عند تجاوز الأرضية فقط', () => {
it('دين ضمن الأرضية لا يحجب', async () => {
await credit.chargeCommission(TENANT, DRIVER, 2, 'trip-1'); // −2، والأرضية −5
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false);
});
it('تجاوز الأرضية يحجب', async () => {
await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1'); // −6 < −5
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(true);
});
it('الشحن يفكّ الحجب', async () => {
await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1');
await credit.topup(TENANT, DRIVER, 10);
expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false);
});
});
describe('مكافأة التسجيل — مرة واحدة لكل سائق', () => {
it('تُمنح بقيمة العملة', async () => {
const c = await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
expect(Number(c.balance)).toBe(SIGNUP_BONUS.JOD);
});
it('استدعاء ثانٍ لا يمنحها مرة أخرى', async () => {
await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
const c = await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
expect(Number(c.balance)).toBe(SIGNUP_BONUS.JOD); // لا مضاعفة
});
it('كل عملة ومكافأتها', () => {
expect(SIGNUP_BONUS.SYP).toBe(300); // بالعملة السورية الجديدة
expect(SIGNUP_BONUS.EGP).toBe(300);
});
});
it('الدفتر يطابق الرصيد ويسجّل سبب كل خصم', async () => {
await credit.topup(TENANT, DRIVER, 10, 'pay-1');
await credit.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1');
await credit.promoBonus(TENANT, DRIVER, 5, 'promo-1');
const txns = await credit.history(TENANT, DRIVER);
expect(txns).toHaveLength(3);
const sum = txns.reduce((acc, t) => acc + Number(t.amount), 0);
expect(sum).toBeCloseTo(Number((await credit.get(TENANT, DRIVER)).balance));
// كل خصم عمولة يشير لرحلته — وإلا لم يعرف السائق لماذا نقص رصيده
const commissionTxn = txns.find((t) => t.type === 'commission')!;
expect(commissionTxn.trip_id).toBe('trip-1');
expect(Number(commissionTxn.amount)).toBeCloseTo(-0.4);
});
it('أنواع الهدايا منفصلة عن المدفوع فعلاً (محاسبياً)', async () => {
await credit.topup(TENANT, DRIVER, 10);
await credit.grantSignupBonus(TENANT, DRIVER, 'JOD');
await credit.promoBonus(TENANT, DRIVER, 5);
const txns = await credit.history(TENANT, DRIVER);
const paid = txns.filter((t) => t.type === 'topup');
const gifts = txns.filter((t) => ['signup_bonus', 'promo_bonus'].includes(t.type));
expect(paid).toHaveLength(1);
expect(gifts).toHaveLength(2);
});
it('المستأجرون معزولون', async () => {
await credit.topup(TENANT, DRIVER, 10);
expect(Number((await credit.get('33333333-3333-3333-3333-333333333333', DRIVER)).balance)).toBe(0);
});
it('الأرضية معرَّفة لكل عملة مدعومة', () => {
expect(CREDIT_FLOOR.JOD).toBeLessThan(0);
expect(CREDIT_FLOOR.SYP).toBeLessThan(0);
expect(CREDIT_FLOOR.EGP).toBeLessThan(0);
});
});
@@ -0,0 +1,165 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { DriverCredit } from './entities/driver-credit.entity';
import { CreditTxn, CreditTxnType } from './entities/credit-txn.entity';
/** مكافأة التسجيل لكل عملة (docs/18 §5.4 — قرار المالك). */
export const SIGNUP_BONUS: Record<string, number> = {
JOD: 3,
SYP: 300, // بالعملة السورية الجديدة (حُذف صفران)
EGP: 300,
};
/** أقصى دين مسموح قبل الحجب، لكل عملة (docs/18 §5.2). */
export const CREDIT_FLOOR: Record<string, number> = {
JOD: -5,
SYP: -500,
EGP: -500,
};
@Injectable()
export class DriverCreditService {
private readonly logger = new Logger('DriverCredit');
constructor(
@InjectRepository(DriverCredit) private readonly credits: Repository<DriverCredit>,
@InjectRepository(CreditTxn) private readonly txns: Repository<CreditTxn>,
) {}
async get(tenantId: string, driverId: string, currency = 'JOD'): Promise<DriverCredit> {
await this.ensure(this.credits.manager, tenantId, driverId, currency);
return (await this.credits.findOne({
where: { tenant_id: tenantId, driver_id: driverId },
}))!;
}
/** شحن مدفوع — يُنادى بعد نجاح الدفع فقط (docs/18 §5.3). */
topup(tenantId: string, driverId: string, amount: number, ref?: string) {
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
return this.apply(tenantId, driverId, amount, 'topup', { ref });
}
/** حافز الباقة — قيد منفصل عن المدفوع فعلاً (تكلفة تسويق لا إيراد). */
promoBonus(tenantId: string, driverId: string, amount: number, ref?: string) {
if (!(amount > 0)) throw new BadRequestException('amount must be > 0');
return this.apply(tenantId, driverId, amount, 'promo_bonus', { ref });
}
/**
* خصم عمولة رحلة (docs/18 §5.2).
* **لا يفشل أبداً** — ولو صار الرصيد سالباً. الرحلة تمّت فالعمولة استُحقّت؛
* رفض الخصم يعني عمولة ضائعة إلى الأبد. الدين يُحاسَب لاحقاً عبر الأرضية.
*/
chargeCommission(tenantId: string, driverId: string, amount: number, tripId: string) {
if (!(amount > 0)) return this.get(tenantId, driverId);
return this.apply(tenantId, driverId, -amount, 'commission', { tripId });
}
/**
* مكافأة التسجيل — **مرة واحدة لكل سائق** (docs/18 — J6).
*
* حارسان: فحص هنا للحالة الشائعة (اعتماد يتكرر)، وفهرس فريد جزئي على
* القاعدة للسباق الحقيقي (اعتمادان متزامنان). الفحص وحده لا يكفي، والفهرس
* وحده يجعل الحالة الشائعة استثناءً — فكلاهما.
*/
async grantSignupBonus(
tenantId: string,
driverId: string,
currency = 'JOD',
): Promise<DriverCredit> {
const amount = SIGNUP_BONUS[currency] ?? 0;
if (amount <= 0) return this.get(tenantId, driverId, currency);
const already = await this.txns.findOne({
where: { tenant_id: tenantId, driver_id: driverId, type: 'signup_bonus' },
});
if (already) return this.get(tenantId, driverId, currency);
try {
return await this.apply(tenantId, driverId, amount, 'signup_bonus', {}, currency);
} catch (e: any) {
// 23505 = انتهاك تفرّد → سبقنا إليها نداء متزامن. ليست حالة خطأ.
if (e?.code === '23505' || e?.driverError?.code === '23505') {
this.logger.debug(`signup bonus already granted to ${driverId}`);
return this.get(tenantId, driverId, currency);
}
throw e;
}
}
/** هل تجاوز السائق حدّ الدين؟ يُفحص عند الاتصال والقبول — لا أثناء رحلة. */
async isBlocked(tenantId: string, driverId: string, currency = 'JOD'): Promise<boolean> {
const c = await this.get(tenantId, driverId, currency);
const floor = CREDIT_FLOOR[c.currency ?? currency] ?? 0;
return Number(c.balance) < floor;
}
history(tenantId: string, driverId: string) {
return this.txns.find({
where: { tenant_id: tenantId, driver_id: driverId },
order: { created_at: 'DESC' },
take: 100,
});
}
// ---- داخلي ----
private async ensure(
em: EntityManager,
tenantId: string,
driverId: string,
currency: string,
): Promise<void> {
await em
.createQueryBuilder()
.insert()
.into(DriverCredit)
.values({ tenant_id: tenantId, driver_id: driverId, balance: 0, currency })
.orIgnore()
.execute();
}
/**
* تعديل ذرّي — نفس نمط I1: عبارة `UPDATE` واحدة، والقيد والرصيد في معاملة
* واحدة. الفرق الجوهري عن المحفظة: **لا شرط `balance >= amount`** هنا،
* فالسالب مسموح عمداً.
*/
private async apply(
tenantId: string,
driverId: string,
delta: number,
type: CreditTxnType,
meta: { tripId?: string; ref?: string } = {},
currency = 'JOD',
): Promise<DriverCredit> {
return this.credits.manager.transaction(async (em) => {
await this.ensure(em, tenantId, driverId, currency);
const res = await em
.createQueryBuilder()
.update(DriverCredit)
.set({ balance: () => 'balance + :delta' })
.where('tenant_id = :tenantId AND driver_id = :driverId')
.setParameters({ delta, tenantId, driverId })
.returning('*')
.execute();
const row = res.raw?.[0];
if (!row) throw new BadRequestException('Driver credit account not found');
const credit = { ...row, balance: Number(row.balance) } as DriverCredit;
await em.getRepository(CreditTxn).insert({
tenant_id: tenantId,
driver_id: driverId,
amount: delta,
type,
balance_after: credit.balance,
trip_id: meta.tripId ?? null,
ref: meta.ref ?? null,
});
return credit;
});
}
}
@@ -0,0 +1,53 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* أنواع حركات الرصيد التشغيلي (docs/18).
* `topup` و`signup_bonus` و`promo_bonus` مفصولة عمداً: المدفوع فعلاً إيراد،
* والهدايا تكلفة تسويق — خلطها يُفسد المحاسبة.
*/
export type CreditTxnType =
| 'topup' // شحن مدفوع — إيراد
| 'signup_bonus' // مكافأة تسجيل — تكلفة تجنيد
| 'promo_bonus' // حافز باقة («اشحن 50 خذ 55») — تكلفة تسويق
| 'commission' // خصم عمولة رحلة
| 'adjustment'; // تسوية يدوية من الأدمن
/** حركة على الرصيد التشغيلي. الجدول: tripz_credit_txns. دفتر append-only. */
@Entity('credit_txns')
@Index(['tenant_id', 'driver_id', 'created_at'])
export class CreditTxn {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
driver_id: string;
/** موجب = إضافة · سالب = خصم. القيمة الموقَّعة تجعل مجموع الدفتر = الرصيد. */
@Column({ type: 'numeric', precision: 12, scale: 3 })
amount: number;
@Column({ type: 'varchar' })
type: CreditTxnType;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
balance_after: number | null;
/** الرحلة سبب الخصم — بدونه لا يعرف السائق «لماذا نقص رصيدي؟» (docs/18 §6). */
@Column({ type: 'uuid', nullable: true })
trip_id: string | null;
@Column({ type: 'varchar', nullable: true })
ref: string | null;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* الرصيد التشغيلي للسائق — عمولة مدفوعة **سلفاً** (docs/18).
* الجدول: tripz_driver_credits.
*
* ليس محفظة أرباح السائق (`wallets`): تلك أمواله، وهذه رصيد يشتريه ليعمل.
* لا تُخلطان أبداً.
*
* **الرصيد هنا يجوز أن يكون سالباً** (دين) — بخلاف `wallets` التي يحرسها
* قيد `balance >= 0`.
*/
@Entity('driver_credits')
@Index(['tenant_id', 'driver_id'], { unique: true })
export class DriverCredit {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
driver_id: string;
/** موجب = رصيد متاح · سالب = دين على السائق. */
@Column({ type: 'numeric', precision: 12, scale: 3, default: 0 })
balance: number;
@Column({ default: 'JOD' })
currency: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { UsersModule } from '../users/users.module';
import { LocationsModule } from '../locations/locations.module';
import { CreditModule } from '../credit/credit.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver]), UsersModule, LocationsModule],
imports: [
TypeOrmModule.forFeature([Driver]),
UsersModule,
LocationsModule,
forwardRef(() => CreditModule),
],
controllers: [DriversController],
providers: [DriversService],
exports: [DriversService],
+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(
@@ -63,14 +63,10 @@ export interface TariffDefinition {
free_waiting_min?: number;
/**
* فصل السعر (docs/17 — B6): ما يدفعه الراكب مقابل ما يقبضه السائق.
* العمولة = percent% من الأجرة + flat، وبحدّ أدنى min. غيابها = بلا عمولة.
* عمولة المنصة (docs/18): `percent%` من الأجرة + `flat`، بحدّ أدنى `min`.
* غيابها = بلا عمولة.
*
* **لا تُقتطع من أجرة السائق** — تُخصم من رصيده التشغيلي المدفوع سلفاً.
*/
commission?: { percent?: number; flat?: number; min?: number };
/**
* مشوار وصول السائق للراكب (docs/17 — B3). `charge: false` افتراضاً —
* تفعيله يرفع الأجرة على كل رحلة، فهو قرار مالك لا افتراض صامت.
*/
pickup_leg?: { charge: boolean };
}
@@ -44,58 +44,38 @@ describe('TariffEngine.waitingCharge — الانتظار (B2)', () => {
});
});
describe('TariffEngine.split — فصل السعر (B6)', () => {
it('بلا عمولة: السائق يقبض كل ما دفعه الراكب', () => {
const s = TariffEngine.split(def(), 10);
expect(s.forPassenger).toBe(10);
expect(s.forDriver).toBe(10);
expect(s.commission).toBe(0);
/**
* العمولة (docs/18): تُحسب هنا فقط — ولا تُقتطع من أجرة السائق إطلاقاً.
* السائق يقبض الأجرة كاملة، والخصم يقع على رصيده التشغيلي.
*/
describe('TariffEngine.commission — العمولة (docs/18)', () => {
it('بلا إعداد عمولة: صفر', () => {
expect(TariffEngine.commission(def(), 10).amount).toBe(0);
});
it('عمولة نسبية', () => {
const s = TariffEngine.split(def({ commission: { percent: 15 } }), 10);
expect(s.commission).toBeCloseTo(1.5);
expect(s.forDriver).toBeCloseTo(8.5);
expect(s.rate).toBe(15);
const c = TariffEngine.commission(def({ commission: { percent: 10 } }), 4);
expect(c.amount).toBeCloseTo(0.4); // مثال المالك المرجعي: 10% من 4 دنانير
expect(c.rate).toBe(10);
});
it('نسبة + مقطوع', () => {
const s = TariffEngine.split(def({ commission: { percent: 10, flat: 0.25 } }), 10);
expect(s.commission).toBeCloseTo(1.25);
expect(s.forDriver).toBeCloseTo(8.75);
expect(TariffEngine.commission(def({ commission: { percent: 10, flat: 0.25 } }), 10).amount)
.toBeCloseTo(1.25);
});
it('حدّ أدنى للعمولة', () => {
const s = TariffEngine.split(def({ commission: { percent: 10, min: 0.5 } }), 2);
expect(s.commission).toBeCloseTo(0.5); // 0.2 أقل من الحد
expect(s.forDriver).toBeCloseTo(1.5);
expect(TariffEngine.commission(def({ commission: { percent: 10, min: 0.5 } }), 2).amount)
.toBeCloseTo(0.5); // 0.2 أقل من الحد
});
it('العمولة لا تتجاوز الأجرة — دخل السائق لا يصير سالباً', () => {
const s = TariffEngine.split(def({ commission: { percent: 50, min: 5 } }), 2);
expect(s.commission).toBe(2);
expect(s.forDriver).toBe(0);
it('لا سقف بالأجرة — الخصم على رصيد منفصل يجوز أن يصير سالباً', () => {
// في نموذج الاقتطاع كان هذا يُقصّ إلى 2؛ هنا لا يُقصّ.
expect(TariffEngine.commission(def({ commission: { percent: 50, min: 5 } }), 2).amount)
.toBeCloseTo(5);
});
it('الراكب + السائق يتوازنان دائماً', () => {
for (const total of [0, 1.337, 10, 99.999]) {
const s = TariffEngine.split(def({ commission: { percent: 15 } }), total);
expect(s.forDriver + s.commission).toBeCloseTo(s.forPassenger, 3);
}
});
});
describe('TariffEngine.pickupCharge — مشوار الوصول (B3)', () => {
it('لا يُحتسب ما لم يُفعَّل صراحةً', () => {
expect(TariffEngine.pickupCharge(def(), 5, 10, DAY)).toBe(0);
});
it('عند تفعيله يُحسب بمسافة النافذة وزمنها', () => {
const charge = TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 5, 10, DAY);
expect(charge).toBeCloseTo(5 * 0.28 + 10 * 0.06);
});
it('لا رسم على مشوار صفري', () => {
expect(TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 0, 0, DAY)).toBe(0);
it('أجرة صفرية = عمولة بالحد الأدنى إن وُجد', () => {
expect(TariffEngine.commission(def({ commission: { percent: 10 } }), 0).amount).toBe(0);
});
});
+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 {
@@ -14,6 +14,7 @@ import { FraudModule } from '../fraud/fraud.module';
import { WalletModule } from '../wallet/wallet.module';
import { UsersModule } from '../users/users.module';
import { LocationsModule } from '../locations/locations.module';
import { CreditModule } from '../credit/credit.module';
@Module({
imports: [
@@ -27,6 +28,7 @@ import { LocationsModule } from '../locations/locations.module';
WalletModule,
UsersModule,
LocationsModule,
CreditModule,
],
controllers: [TripsController],
providers: [TripsService, TripStateService],
+80 -37
View File
@@ -21,6 +21,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { TripState, TripStateService } from './trip-state.service';
import { UsersService } from '../users/users.service';
import { DriverLocationService } from '../locations/driver-location.service';
import { DriverCreditService } from '../credit/driver-credit.service';
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
@@ -74,6 +75,7 @@ export class TripsService {
private readonly state: TripStateService,
private readonly users: UsersService,
private readonly locations: DriverLocationService,
private readonly credit: DriverCreditService,
) {}
get(tenantId: string, id: string): Promise<Trip | null> {
@@ -114,8 +116,7 @@ export class TripsService {
let currency: string | null = null;
let tariffId: string | null = null;
let tariffVersion: number | null = null;
// فصل تقديري يُعرض على السائق ليعرف دخله قبل القبول (docs/17 — B6).
let priceForDriver: number | null = null;
// العمولة المتوقعة — تُعرض للسائق ليعرف ما سيُخصم من رصيده (docs/18).
let commissionAmount: number | null = null;
let commissionRate: number | null = null;
try {
@@ -130,10 +131,9 @@ export class TripsService {
const def = (await this.tariff.getActive(tenantId, city, serviceClass))?.definition;
if (def) {
const split = TariffEngine.split(def, q.quote.total);
priceForDriver = split.forDriver;
commissionAmount = split.commission;
commissionRate = split.rate;
const c = TariffEngine.commission(def, q.quote.total);
commissionAmount = c.amount;
commissionRate = c.rate;
}
} catch {
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
@@ -156,7 +156,8 @@ export class TripsService {
tariff_version: tariffVersion,
quoted_fare: quotedFare,
price_for_passenger: quotedFare,
price_for_driver: priceForDriver,
// السائق يقبض ما يدفعه الراكب كاملاً (docs/18).
price_for_driver: quotedFare,
commission_amount: commissionAmount,
commission_rate: commissionRate,
currency: currency ?? undefined,
@@ -180,6 +181,11 @@ export class TripsService {
if (driver.verification_status !== 'approved') {
throw new ForbiddenException('Driver not approved');
}
// الدين تجاوز الأرضية → لا يقبل رحلات جديدة (docs/18 — J4).
// يُفحص هنا لا أثناء الرحلة: رحلة جارية لا تُقطع أبداً.
if (await this.credit.isBlocked(tenantId, driver.id)) {
throw new ForbiddenException('credit_exhausted');
}
const assignedAt = new Date();
// 1) حسم سريع في Redis: الخاسر يخرج بلا لمس القاعدة إطلاقاً.
@@ -302,26 +308,30 @@ export class TripsService {
await this.syncState(trip, snapshot, toStatus);
await this.recordEvent(trip, snapshot.status, toStatus, actor);
// تسوية المحفظة عند الدفع (payment_method === wallet).
// الراكب يُخصم منه price_for_passenger، والسائق يُضاف له price_for_driver —
// الفرق هو عمولة المنصة (docs/17 — B6). كانا متساويين قبل فصل السعر، أي
// أن العمولة كانت تضيع.
// العمولة تُخصم من الرصيد التشغيلي عند **الإنهاء** لا عند الدفع: الرحلة
// تمّت فالعمولة استُحقّت، كاشاً كانت أو محفظة (docs/18 — J2).
// الخصم لا يفشل ولو صار الرصيد سالباً؛ الدين يُحاسَب عبر الأرضية.
if (toStatus === 'completed' && snapshot.driver_id && trip.commission_amount != null) {
const commission = Number(trip.commission_amount);
if (commission > 0) {
try {
await this.credit.chargeCommission(tenantId, snapshot.driver_id, commission, trip.id);
} catch (e: any) {
this.logger.error(`commission charge failed for trip ${trip.id}: ${e?.message}`);
}
}
}
// تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من
// الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد
// التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة).
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
const passengerPays = Number(trip.price_for_passenger ?? trip.final_fare);
const driverEarns = Number(trip.price_for_driver ?? trip.final_fare);
const fare = Number(trip.price_for_passenger ?? trip.final_fare);
try {
await this.wallet.debit(tenantId, trip.rider_id, passengerPays, 'trip_fare', trip.id);
if (snapshot.driver_user_id && driverEarns > 0) {
await this.wallet.credit(
tenantId,
snapshot.driver_user_id,
driverEarns,
'trip_earning',
trip.id,
);
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
if (snapshot.driver_user_id && fare > 0) {
await this.wallet.credit(tenantId, snapshot.driver_user_id, fare, 'trip_earning', trip.id);
}
// TODO(I2): إيداع commission_amount في محفظة المنصة (مكافئ siroWallet).
// حتى تُبنى، العمولة محسوبة ومسجّلة على الرحلة لكنها لا تُقيَّد في محفظة.
} catch (e: any) {
this.logger.warn(`wallet settle failed: ${e?.message}`);
}
@@ -344,7 +354,7 @@ export class TripsService {
// كشف إساءة الإلغاء (قد يرمي عند الحد الصارم)
await this.fraud.recordCancellation(tenantId, actor, actorUserId, tripId);
const fee = CANCEL_FEE_BY_STAGE[snapshot.status] ?? 0;
const fee = await this.cancelFee(tenantId, tripId, snapshot);
const res = await this.trips.update(
{ tenant_id: tenantId, id: tripId, status: snapshot.status },
{ status: 'cancelled', cancelled_by: actor, cancel_fee: fee },
@@ -469,6 +479,7 @@ export class TripsService {
return {
pickup_distance_km: leg.distanceKm,
pickup_duration_min: leg.durationMin,
// تُخزَّن دائماً لكنها لا تُحصَّل إلا كتعويض إلغاء (docs/17 — B3).
pickup_charge: def
? TariffEngine.pickupCharge(def, leg.distanceKm, leg.durationMin)
: 0,
@@ -479,6 +490,36 @@ export class TripsService {
}
}
/**
* رسم الإلغاء (docs/17 — B3 بعد تصحيح المالك).
*
* القاعدة: إن كان السائق قد **وصل وانتظر دقائقه المجانية** ثم أُلغيت الرحلة،
* فالرسم = قيمة **مشوار الوصول** الذي قطعه بلا مقابل — تعويضاً له، لا رسماً
* ثابتاً اعتباطياً. ما دون ذلك: رسم المرحلة المعتاد.
*/
private async cancelFee(
tenantId: string,
tripId: string,
snapshot: TripState,
): Promise<number> {
const staged = CANCEL_FEE_BY_STAGE[snapshot.status] ?? 0;
if (snapshot.status !== 'driver_arrived' || !snapshot.arrived_at) return staged;
const def = await this.tariffDef(tenantId, snapshot);
if (!def) return staged;
const waitedMin = (Date.now() - snapshot.arrived_at.getTime()) / 60000;
const freeMin = def.free_waiting_min ?? 5;
if (waitedMin < freeMin) return staged; // لم يُكمل انتظاره المجاني بعد
const row = await this.trips.findOne({
where: { tenant_id: tenantId, id: tripId },
select: { pickup_charge: true },
});
const pickup = Number(row?.pickup_charge ?? 0);
return pickup > 0 ? pickup : staged;
}
/** تعريف التعرفة الفعّالة للرحلة (من كاش Redis — docs/17 G5). */
private async tariffDef(tenantId: string, snapshot: TripState) {
const t = await this.tariff.getActive(tenantId, snapshot.city, snapshot.service_class);
@@ -502,20 +543,20 @@ export class TripsService {
});
const base = snapshot.quoted_fare ?? 0;
const waiting = Number(row?.waiting_charge ?? 0);
const pickup = Number(row?.pickup_charge ?? 0);
const total = Number((base + waiting + pickup).toFixed(3));
// مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3).
const total = Number((base + waiting).toFixed(3));
const def = await this.tariffDef(tenantId, snapshot);
const split = def
? TariffEngine.split(def, total)
: { forPassenger: total, forDriver: total, commission: 0, rate: 0 };
const commission = def ? TariffEngine.commission(def, total) : { amount: 0, rate: 0 };
return {
final_fare: split.forPassenger,
price_for_passenger: split.forPassenger,
price_for_driver: split.forDriver,
commission_amount: split.commission,
commission_rate: split.rate,
final_fare: total,
// السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من
// رصيده التشغيلي لا من هذا المبلغ.
price_for_passenger: total,
price_for_driver: total,
commission_amount: commission.amount,
commission_rate: commission.rate,
};
}
@@ -554,8 +595,10 @@ export class TripsService {
tripDistanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min),
quotedFare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
// ما يقبضه السائق فعلاً — لا أجرة الراكب (docs/17 — B6).
earnings: trip.price_for_driver == null ? null : Number(trip.price_for_driver),
// السائق يقبض الأجرة كاملة؛ والعمولة تُعرض منفصلة لأنها ستُخصم من
// رصيده التشغيلي لا من هذا المبلغ (docs/18).
earnings: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
commission: trip.commission_amount == null ? null : Number(trip.commission_amount),
currency: trip.currency ?? null,
serviceClass: trip.service_class,
paymentMethod: trip.payment_method,