From d9b0faab2d944529eaeb6fc4aacc64606e1a6079 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 14:03:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A7=D9=84=D9=85=D8=AC=D9=85=D9=88?= =?UTF-8?q?=D8=B9=D8=A9=20J=20=E2=80=94=20=D8=A7=D9=84=D8=B1=D8=B5=D9=8A?= =?UTF-8?q?=D8=AF=20=D8=A7=D9=84=D8=AA=D8=B4=D8=BA=D9=8A=D9=84=D9=8A=20+?= =?UTF-8?q?=20=D8=AA=D8=B5=D8=AD=D9=8A=D8=AD=20=D9=86=D9=85=D9=88=D8=B0?= =?UTF-8?q?=D8=AC=20=D8=A7=D9=84=D8=B9=D9=85=D9=88=D9=84=D8=A9=20(B3/B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit فلسفة العمولة الصحيحة (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 --- backend/src/app.module.ts | 2 + .../migrations/1721850000000-DriverCredit.ts | 59 +++++++ .../src/modules/credit/credit.controller.ts | 42 +++++ backend/src/modules/credit/credit.module.ts | 22 +++ .../credit/driver-credit.service.spec.ts | 152 ++++++++++++++++ .../modules/credit/driver-credit.service.ts | 165 ++++++++++++++++++ .../credit/entities/credit-txn.entity.ts | 53 ++++++ .../credit/entities/driver-credit.entity.ts | 44 +++++ backend/src/modules/drivers/drivers.module.ts | 10 +- .../src/modules/drivers/drivers.service.ts | 19 +- .../modules/tariff/entities/tariff.entity.ts | 12 +- .../src/modules/tariff/tariff.engine.spec.ts | 60 +++---- backend/src/modules/tariff/tariff.engine.ts | 41 +++-- backend/src/modules/trips/trips.module.ts | 2 + backend/src/modules/trips/trips.service.ts | 117 +++++++++---- docs/17-backend-backlog.md | 25 +-- docs/18-driver-credit-commission.md | 53 +++--- 17 files changed, 736 insertions(+), 142 deletions(-) create mode 100644 backend/src/database/migrations/1721850000000-DriverCredit.ts create mode 100644 backend/src/modules/credit/credit.controller.ts create mode 100644 backend/src/modules/credit/credit.module.ts create mode 100644 backend/src/modules/credit/driver-credit.service.spec.ts create mode 100644 backend/src/modules/credit/driver-credit.service.ts create mode 100644 backend/src/modules/credit/entities/credit-txn.entity.ts create mode 100644 backend/src/modules/credit/entities/driver-credit.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 489dda2..e28ef6c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/database/migrations/1721850000000-DriverCredit.ts b/backend/src/database/migrations/1721850000000-DriverCredit.ts new file mode 100644 index 0000000..43a8599 --- /dev/null +++ b/backend/src/database/migrations/1721850000000-DriverCredit.ts @@ -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 { + 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 { + await q.query(`DROP TABLE IF EXISTS tripz_credit_txns`); + await q.query(`DROP TABLE IF EXISTS tripz_driver_credits`); + } +} diff --git a/backend/src/modules/credit/credit.controller.ts b/backend/src/modules/credit/credit.controller.ts new file mode 100644 index 0000000..b6646ab --- /dev/null +++ b/backend/src/modules/credit/credit.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/credit/credit.module.ts b/backend/src/modules/credit/credit.module.ts new file mode 100644 index 0000000..dea608a --- /dev/null +++ b/backend/src/modules/credit/credit.module.ts @@ -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 {} diff --git a/backend/src/modules/credit/driver-credit.service.spec.ts b/backend/src/modules/credit/driver-credit.service.spec.ts new file mode 100644 index 0000000..2bbe026 --- /dev/null +++ b/backend/src/modules/credit/driver-credit.service.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/credit/driver-credit.service.ts b/backend/src/modules/credit/driver-credit.service.ts new file mode 100644 index 0000000..8fc7e47 --- /dev/null +++ b/backend/src/modules/credit/driver-credit.service.ts @@ -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 = { + JOD: 3, + SYP: 300, // بالعملة السورية الجديدة (حُذف صفران) + EGP: 300, +}; + +/** أقصى دين مسموح قبل الحجب، لكل عملة (docs/18 §5.2). */ +export const CREDIT_FLOOR: Record = { + JOD: -5, + SYP: -500, + EGP: -500, +}; + +@Injectable() +export class DriverCreditService { + private readonly logger = new Logger('DriverCredit'); + + constructor( + @InjectRepository(DriverCredit) private readonly credits: Repository, + @InjectRepository(CreditTxn) private readonly txns: Repository, + ) {} + + async get(tenantId: string, driverId: string, currency = 'JOD'): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + }); + } +} diff --git a/backend/src/modules/credit/entities/credit-txn.entity.ts b/backend/src/modules/credit/entities/credit-txn.entity.ts new file mode 100644 index 0000000..5cad323 --- /dev/null +++ b/backend/src/modules/credit/entities/credit-txn.entity.ts @@ -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; +} diff --git a/backend/src/modules/credit/entities/driver-credit.entity.ts b/backend/src/modules/credit/entities/driver-credit.entity.ts new file mode 100644 index 0000000..4bfb099 --- /dev/null +++ b/backend/src/modules/credit/entities/driver-credit.entity.ts @@ -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; +} diff --git a/backend/src/modules/drivers/drivers.module.ts b/backend/src/modules/drivers/drivers.module.ts index 7b872b5..e6deae8 100644 --- a/backend/src/modules/drivers/drivers.module.ts +++ b/backend/src/modules/drivers/drivers.module.ts @@ -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], diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index 7abe992..1e13240 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -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, private readonly users: UsersService, private readonly locations: DriverLocationService, + private readonly credit: DriverCreditService, ) {} findByUser(tenantId: string, userId: string): Promise { @@ -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 { 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( diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts index 544d14b..c65c5b3 100644 --- a/backend/src/modules/tariff/entities/tariff.entity.ts +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -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 }; } diff --git a/backend/src/modules/tariff/tariff.engine.spec.ts b/backend/src/modules/tariff/tariff.engine.spec.ts index 517d13e..8f4ba26 100644 --- a/backend/src/modules/tariff/tariff.engine.spec.ts +++ b/backend/src/modules/tariff/tariff.engine.spec.ts @@ -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); }); }); diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts index 3a8aae2..2833d03 100644 --- a/backend/src/modules/tariff/tariff.engine.ts +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -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 { diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index 009784e..df7c1b0 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -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], diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index 9c55f80..c9f7e17 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -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> = { @@ -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 { @@ -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 { + 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, diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index d5264f3..3c162f5 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -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 كثيراً. diff --git a/docs/18-driver-credit-commission.md b/docs/18-driver-credit-commission.md index e82fa9c..fd219a3 100644 --- a/docs/18-driver-credit-commission.md +++ b/docs/18-driver-credit-commission.md @@ -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)