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
@@ -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;
}