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,
|
||||
|
||||
Reference in New Issue
Block a user