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:
co-authored by
Claude Opus 4.8
parent
2edd8f6916
commit
d9b0faab2d
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-11
@@ -191,21 +191,24 @@
|
||||
|
||||
---
|
||||
|
||||
## المجموعة J — الرصيد التشغيلي وعمولة السائق 🔴 يسبق I2
|
||||
## المجموعة J — الرصيد التشغيلي وعمولة السائق — ✅ منفَّذة (عدا J5)
|
||||
> **المستند الكامل: [18-driver-credit-commission](18-driver-credit-commission.md).**
|
||||
> السائق يشحن رصيداً تشغيلياً سلفاً · الراكب يدفع له الأجرة كاملة · العمولة تُخصم من الرصيد لا من الأجرة.
|
||||
> نموذج معتمد عالمياً في أسواق الكاش (inDrive · Yango · سيرو) — وليس نموذج أوبر.
|
||||
|
||||
| # | البند |
|
||||
|---|-------|
|
||||
| J1 | جدول `driver_credit` + دفتر قيود — خصم ذرّي داخل معاملة (نمط I1 حرفياً) |
|
||||
| J2 | خصم العمولة عند `completed` من الرصيد التشغيلي (لا من الأجرة) |
|
||||
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger` |
|
||||
| J4 | **الحجب عند نفاد الرصيد** — بدونه ينهار النموذج (سائق برصيد صفر يعمل مجاناً للأبد) |
|
||||
| J5 | باقات شحن لكل عملة + الحافز كقيد منفصل |
|
||||
| J6 | رصيد ترحيبي / فترة سماح للسائق الجديد (حاجز الدخول) |
|
||||
| J7 | أرضية سالبة محدودة لسباق «أنهى ورصيده لا يكفي» |
|
||||
| J8 | نقاط: رصيدي · تاريخ الخصومات · الشحن |
|
||||
| # | البند | الحالة |
|
||||
|---|-------|--------|
|
||||
| J1 | `driver_credits` + `credit_txns` — خصم ذرّي (نمط I1) لكن **بلا شرط رصيد**: الدين مسموح عمداً، ولا قيد `>= 0` على الجدول | ✅ |
|
||||
| J2 | خصم العمولة عند `completed` من الرصيد التشغيلي — كاشاً كانت أو محفظة (قاعدة واحدة) | ✅ |
|
||||
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger`؛ `TariffEngine.split` → `commission` (بلا سقف بالأجرة) | ✅ |
|
||||
| J4 | الحجب عند تجاوز `credit_floor` فقط — عند `setOnline` و`accept`، **لا** أثناء رحلة جارية | ✅ |
|
||||
| J6 | مكافأة التسجيل عند **الاعتماد** لا التقديم: 3 JOD · 300 SYP · 300 EGP. حارسان: فحص تطبيقي + فهرس فريد جزئي | ✅ |
|
||||
| J7 | `credit_floor` لكل عملة: −5 JOD · −500 SYP · −500 EGP | ✅ |
|
||||
| J8 | `GET /credit` (الرصيد · الأرضية · مَدين؟ · محجوب؟) و`GET /credit/transactions` | ✅ |
|
||||
| — | **تصحيح B3**: مشوار الوصول = تعويض إلغاء بعد انقضاء الانتظار المجاني — لا بند في كل أجرة | ✅ |
|
||||
| J5 | باقات شحن لكل عملة (`promo_bonus` جاهز كنوع قيد؛ الباقات نفسها لم تُبنَ) | ⏳ |
|
||||
|
||||
**الأرقام ثوابت في `driver-credit.service.ts` مؤقتاً** (المكافأة والأرضية) — تُنقل لإعداد المستأجر عند بناء J5.
|
||||
|
||||
> **أثره على I2**: محفظة المنصة لم تعد تستقبل عمولة من كل رحلة — بل تستقبل **مدفوعات الشحن**. هذا يبسّط I2 كثيراً.
|
||||
|
||||
|
||||
@@ -58,34 +58,43 @@ commission_amount = split(price_for_passenger) // تُحسب وتُسجَ
|
||||
- دفتر قيود append-only + خصم ذرّي — **نفس نمط I1 حرفياً** (`UPDATE … WHERE balance >= :amount`).
|
||||
- الخصم يحدث عند `completed` (لا عند `paid`): الرحلة تمّت فالعمولة استُحقّت، بغضّ النظر عن وسيلة الدفع.
|
||||
|
||||
### 5.2 الحجب عند نفاد الرصيد — **الالتزام الأهم**
|
||||
بلا حجب، النموذج **ينهار**: سائق برصيد صفر يعمل مجاناً إلى الأبد.
|
||||
### 5.2 الرصيد السالب مسموح — والرحلة لا تُقطع أبداً (قرار المالك 2026-07-17)
|
||||
> «نعطي رصيداً سالباً مثل أوبر… ولا تتوقف العملية عند الرصيد. لو بدأ رحلة وعمولتها أكبر من رصيده، يكمل عادي وتُضاف إلى الدين — حتى نُسهّل التجنيد.»
|
||||
|
||||
فالتصميم **ليس** حجباً عند الصفر:
|
||||
```
|
||||
driver_credit < min_balance → لا يدخل فهرس geo:drivers:available
|
||||
→ لا تُعرض عليه رحلات
|
||||
→ إشعار: «اشحن رصيدك التشغيلي»
|
||||
الرحلة الجارية → لا تُقطع أبداً مهما كان الرصيد. العمولة تُخصم ولو صار سالباً.
|
||||
الدين ضمن الأرضية → يبقى متاحاً ويعمل طبيعياً (إشعار تذكير فقط).
|
||||
الدين تجاوز الأرضية → يخرج من فهرس المتاحين: «اشحن رصيدك التشغيلي».
|
||||
```
|
||||
- الفحص عند: `setOnline` وقبل `accept` وبعد كل خصم.
|
||||
- `min_balance` لكل مستأجر/عملة (قد يكون صفراً أو أعلى من أغلى عمولة متوقعة).
|
||||
- **سباق حقيقي**: رحلة تُنهى ورصيده لا يكفي العمولة → نسمح بأرضية سالبة محدودة (`credit_floor`، مثلاً −1 عمولة) ثم نحجبه فوراً. البديل (رفض الخصم) يعني عمولة ضائعة.
|
||||
- **الخصم لا يفشل أبداً** — بخلاف محفظة الأرباح (I1) حيث الخصم مشروط بالرصيد. هنا الشرط الوحيد هو الأرضية، ويُفحص **بعد** الخصم لا قبله.
|
||||
- `credit_floor` (أقصى دين) لكل مستأجر/عملة. الفحص عند `setOnline` و`accept` وبعد كل خصم — **لا** أثناء رحلة جارية.
|
||||
- **لماذا لا نحجب عند الصفر:** حجبٌ عند الصفر يعني سائقاً يُترك في منتصف يومه، وراكباً بلا سائق، وتجنيداً يموت. الدين المحدود أرخص من ذلك بكثير. لكنه يظلّ **محدوداً** — أرضية بلا سقف تعني عمولة غير قابلة للتحصيل إلى الأبد.
|
||||
|
||||
### 5.3 باقات الشحن لكل عملة
|
||||
كما في سيرو — لكل عملة باقاتها وأسعارها:
|
||||
> ⚠️ **الليرة السورية الجديدة حذفت صفرين** — كل الأرقام السورية تُكتب بالعملة الجديدة.
|
||||
|
||||
لكل عملة باقاتها (أرقام مبدئية تُضبط من لوحة الأدمن):
|
||||
```
|
||||
JOD: [5, 10, 25, 50]
|
||||
SYP: [50k, 100k, 250k]
|
||||
JOD: [3, 5, 10, 25]
|
||||
SYP: [500, 1000, 2500] ← بالعملة الجديدة
|
||||
EGP: [100, 250, 500]
|
||||
```
|
||||
- الشحن عبر **[07-integrations](07-integrations.md)** وطرق الدفع لكل دولة (كليك · شام كاش · إي كاش · بيموب · فوري …).
|
||||
- باقة قد تحمل **حافزاً**: «اشحن 50 واحصل على 55» — أداة تسويق مباشرة بيد المستأجر.
|
||||
- الحافز يُسجَّل قيداً منفصلاً في الدفتر (`bonus`) لا يُخلط بالمدفوع فعلاً — وإلا فسدت المحاسبة.
|
||||
|
||||
### 5.4 حاجز دخول السائق الجديد
|
||||
النموذج يطلب من السائق أن يدفع **قبل** أن يكسب — وهذا يقتل التجنيد.
|
||||
المعالجات (قرار مالك، لا افتراض تقني):
|
||||
- **رصيد ترحيبي** (مثلاً 5 دنانير مجاناً) — الأشيع.
|
||||
- **فترة سماح** (أول 7 أيام أو أول 20 رحلة بلا خصم).
|
||||
- **أرضية سالبة أوسع للسائق الجديد** ثم تُشدَّد.
|
||||
### 5.4 مكافأة التسجيل (محسومة — قرار المالك)
|
||||
السائق الجديد يبدأ **برصيد هدية** يُقيَّد في دفتره فور اعتماده، فيعمل من أول دقيقة بلا دفع:
|
||||
|
||||
| العملة | المكافأة |
|
||||
|--------|----------|
|
||||
| SYP (الجديدة) | **300** |
|
||||
| EGP | **300** |
|
||||
| JOD | **3–4** (يُحسم رقم واحد عند التنفيذ) |
|
||||
|
||||
- تُقيَّد كنوع `signup_bonus` **منفصلاً عن المدفوع فعلاً** — وإلا اختلطت الهدايا بالإيرادات في المحاسبة.
|
||||
- تُمنح **مرة واحدة لكل سائق** — الحارس: قيد واحد بهذا النوع لكل `driver_id` (فريد على مستوى القاعدة، لا فحص تطبيقي).
|
||||
|
||||
### 5.5 وسيلة الدفع لا تغيّر شيئاً
|
||||
| الدفع | من يقبض الأجرة | من أين العمولة |
|
||||
@@ -106,11 +115,13 @@ EGP: [100, 250, 500]
|
||||
| J1 | جدول `driver_credit` + دفتر قيوده — خصم ذرّي داخل معاملة (نمط I1) |
|
||||
| J2 | خصم العمولة من الرصيد التشغيلي عند `completed` (لا من الأجرة) |
|
||||
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger` (السائق يقبض كاملاً) |
|
||||
| J4 | الحجب عند نفاد الرصيد: `setOnline` + `accept` + بعد كل خصم |
|
||||
| J5 | باقات الشحن لكل عملة + الحافز كقيد منفصل |
|
||||
| J6 | رصيد ترحيبي / فترة سماح للسائق الجديد |
|
||||
| J7 | أرضية سالبة محدودة (`credit_floor`) لسباق «أنهى الرحلة ورصيده لا يكفي» |
|
||||
| J4 | الرصيد السالب مسموح: الخصم لا يفشل أبداً؛ الحجب فقط عند تجاوز `credit_floor` — عند `setOnline`/`accept`، **لا** أثناء رحلة جارية |
|
||||
| J5 | باقات الشحن لكل عملة (SYP بالعملة الجديدة) + الحافز كقيد منفصل |
|
||||
| J6 | مكافأة التسجيل: 300 SYP · 300 EGP · 3–4 JOD — مرة واحدة لكل سائق (قيد فريد على القاعدة) |
|
||||
| J7 | `credit_floor` لكل مستأجر/عملة |
|
||||
| J8 | شاشة/نقاط: رصيدي · تاريخ الخصومات · الشحن |
|
||||
|
||||
> **لا مستخدمين في Tripz بعد** (2026-07-17) — فلا حاجة لهجرة بيانات ولا توافق خلفي. مستخدمو سيرو يُهاجَرون لاحقاً في عملية منفصلة وبسيطة.
|
||||
|
||||
---
|
||||
← ذو صلة: [05-pricing-billing](05-pricing-billing.md) · [17-backend-backlog](17-backend-backlog.md) · [19-entitlements-licensing](19-entitlements-licensing.md)
|
||||
|
||||
Reference in New Issue
Block a user