From 91e1692457ec5825f77c57050f87ff385c756f20 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 15:26:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=AD=D8=A7=D8=B1=D8=B3=20=D8=A7=D9=84?= =?UTF-8?q?=D8=B3=D9=88=D8=A8=D8=B1-=D8=A3=D8=AF=D9=85=D9=86=20+=20=D8=B3?= =?UTF-8?q?=D9=8A=D8=A7=D8=B3=D8=A9=20=D8=A7=D9=84=D8=AF=D9=8A=D9=86=20?= =?UTF-8?q?=D9=84=D9=83=D9=84=20=D9=85=D8=B3=D8=AA=D8=A3=D8=AC=D8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformGuard (x-platform-secret، مقارنة ثابتة الزمن) يحرس admin/tenants* و admin/features و platform/*. بدونه كان أدمن أي مستأجر يرقّي اشتراكه بنفسه — أي أن المجموعة K كلها كانت زينة. سرّ غير مضبوط = منع الجميع لا فتح الباب (الإعداد الناقص خطأ تشغيلي شائع، وفتح الباب عنده أسوأ من إغلاقه). admin/users/:id/role تبقى لأدمن المستأجر: نطاقها من التوكن، وليست نقطة منصة. سياسة الدين لكل مستأجر (قرار المالك): كل مشغّل يحدّد كم ديناً يسمح به، و0 خيار صريح = لا دين إطلاقاً. - tenants.settings jsonb — سياسات تشغيل يحكمها المستأجر، منفصلة عن features (استحقاقات يحكمها السوبر-أدمن): «كيف تشتغل» مقابل «ماذا اشتريت» - credit-policy.ts: debt_allowance + signup_bonus لكل عملة. الافتراضات صُحّحت لقيم المالك: 2 JOD · 200 SYP · 200 EGP (كانت 5/500/500) - تُخزَّن موجبةً («كم ديناً أسمح؟») لا كأرضية سالبة — أوضح للمشغّل و0 بلا لبس في الإشارة - PATCH /credit/policy لأدمن المستأجر؛ updateSettings يدمج عميقاً فلا يمحو سياسة أخرى، ويُبطل الكاش وإلا سرت السياسة القديمة حتى ساعة - إعداد مكسور يسقط للافتراض لا لدين بلا حدّ. والصفر لا يُبتلع (فخّ ||) هجرة: TenantSettings. Co-Authored-By: Claude Opus 4.8 --- backend/src/app.module.ts | 2 + .../common/platform/platform.guard.spec.ts | 49 +++++++++ backend/src/common/platform/platform.guard.ts | 50 +++++++++ .../src/common/platform/platform.module.ts | 9 ++ .../src/database/entities/tenant.entity.ts | 9 ++ .../1721860000000-TenantSettings.ts | 19 ++++ backend/src/modules/credit/credit-policy.ts | 51 +++++++++ .../src/modules/credit/credit.controller.ts | 38 ++++++- backend/src/modules/credit/credit.module.ts | 3 + .../credit/driver-credit.service.spec.ts | 102 +++++++++++++++--- .../modules/credit/driver-credit.service.ts | 40 +++---- .../src/modules/tenants/tenants.controller.ts | 20 +++- .../src/modules/tenants/tenants.service.ts | 22 ++++ .../modules/users/admin-users.controller.ts | 23 ++-- docs/17-backend-backlog.md | 6 +- docs/18-driver-credit-commission.md | 5 +- 16 files changed, 388 insertions(+), 60 deletions(-) create mode 100644 backend/src/common/platform/platform.guard.spec.ts create mode 100644 backend/src/common/platform/platform.guard.ts create mode 100644 backend/src/common/platform/platform.module.ts create mode 100644 backend/src/database/migrations/1721860000000-TenantSettings.ts create mode 100644 backend/src/modules/credit/credit-policy.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index fdcd264..2e569f9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { RedisModule } from './common/redis/redis.module'; import { I18nModule } from './common/i18n/i18n.module'; import { CacheModule } from './common/cache/cache.module'; import { EntitlementsModule } from './common/entitlements/entitlements.module'; +import { PlatformModule } from './common/platform/platform.module'; import { SeedModule } from './common/seed/seed.module'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; @@ -62,6 +63,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module'; I18nModule, // عالمي — ترجمة نصوص الإشعارات CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة) EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard + PlatformModule, // عالمي — حارس السوبر-أدمن (x-platform-secret) NabehModule, // عالمي — إرسال OTP واتساب NotificationsModule, // عالمي — FCM StorageModule, // عالمي — تخزين ملفات الوثائق diff --git a/backend/src/common/platform/platform.guard.spec.ts b/backend/src/common/platform/platform.guard.spec.ts new file mode 100644 index 0000000..973a0d6 --- /dev/null +++ b/backend/src/common/platform/platform.guard.spec.ts @@ -0,0 +1,49 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { PlatformGuard } from './platform.guard'; + +const SECRET = 'super-secret-value'; + +function ctx(headers: Record = {}): ExecutionContext { + return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as any; +} + +const guardWith = (secret?: string) => + new PlatformGuard({ get: () => secret } as any); + +describe('PlatformGuard — حارس السوبر-أدمن', () => { + it('السرّ الصحيح يمرّ', () => { + expect(guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': SECRET }))).toBe(true); + }); + + it('سرّ خاطئ يُرفض', () => { + expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'nope' }))).toThrow( + UnauthorizedException, + ); + }); + + it('بلا ترويسة يُرفض', () => { + expect(() => guardWith(SECRET).canActivate(ctx())).toThrow(UnauthorizedException); + }); + + it('سرّ غير مضبوط = منع الجميع، لا فتح الباب', () => { + // الفخّ الخطير: إعداد ناقص يفتح نقاط المنصة للعالم. + expect(() => guardWith(undefined).canActivate(ctx({ 'x-platform-secret': 'anything' }))).toThrow( + UnauthorizedException, + ); + expect(() => guardWith('').canActivate(ctx({ 'x-platform-secret': '' }))).toThrow( + UnauthorizedException, + ); + }); + + it('لا ينهار على ترويسة غير نصّية', () => { + expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': ['a', 'b'] }))).toThrow( + UnauthorizedException, + ); + }); + + it('طول مختلف يُرفض بلا انهيار (timingSafeEqual يشترط تطابق الطول)', () => { + expect(() => guardWith(SECRET).canActivate(ctx({ 'x-platform-secret': 'short' }))).toThrow( + UnauthorizedException, + ); + }); +}); diff --git a/backend/src/common/platform/platform.guard.ts b/backend/src/common/platform/platform.guard.ts new file mode 100644 index 0000000..4460f2e --- /dev/null +++ b/backend/src/common/platform/platform.guard.ts @@ -0,0 +1,50 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { timingSafeEqual } from 'crypto'; + +/** + * حارس السوبر-أدمن (مالك المنصة) — يحرس `admin/*` و`platform/*`. + * + * السوبر-أدمن يدير **كل** المستأجرين، فهو خارج نموذج أدوار المستأجر تماماً + * (rider/driver/dispatcher/admin): أدمن المستأجر «سيرو» يجب ألّا يرقّي اشتراكه + * بنفسه. لذلك المصادقة بسرّ المنصة لا بـJWT مستأجر. + * + * مؤقّت حتى يوجد نموذج مستخدم منصة حقيقي؛ يوثَّق في docs/19. + */ +@Injectable() +export class PlatformGuard implements CanActivate { + private readonly logger = new Logger('PlatformGuard'); + + constructor(private readonly config: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const provided = req.headers?.['x-platform-secret']; + const expected = this.config.get('platform.secret'); + + // سرّ غير مضبوط = **منع الجميع**، لا سماح للجميع. تركه فارغاً في الإنتاج + // خطأ تشغيلي شائع، وفتحُ الباب عنده أسوأ من إغلاقه. + if (!expected) { + this.logger.error('PLATFORM_SECRET is not set — refusing all platform access'); + throw new UnauthorizedException('platform access disabled'); + } + if (typeof provided !== 'string' || !PlatformGuard.safeEqual(provided, expected)) { + throw new UnauthorizedException('invalid platform secret'); + } + return true; + } + + /** مقارنة ثابتة الزمن — المقارنة العادية تسرّب السرّ حرفاً حرفاً بالتوقيت. */ + private static safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a); + const bb = Buffer.from(b); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); + } +} diff --git a/backend/src/common/platform/platform.module.ts b/backend/src/common/platform/platform.module.ts new file mode 100644 index 0000000..d45a547 --- /dev/null +++ b/backend/src/common/platform/platform.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PlatformGuard } from './platform.guard'; + +@Global() +@Module({ + providers: [PlatformGuard], + exports: [PlatformGuard], +}) +export class PlatformModule {} diff --git a/backend/src/database/entities/tenant.entity.ts b/backend/src/database/entities/tenant.entity.ts index 45fcdbc..311abc0 100644 --- a/backend/src/database/entities/tenant.entity.ts +++ b/backend/src/database/entities/tenant.entity.ts @@ -36,9 +36,18 @@ export class Tenant { @Column({ type: 'jsonb', default: {} }) branding: Record; + // استحقاقات مشتراة (ماذا يملك) — يحكمها السوبر-أدمن. راجع docs/19. @Column({ type: 'jsonb', default: {} }) features: Record; + /** + * سياسات تشغيل المستأجر (كيف يدير عمله) — يحكمها **المستأجر نفسه**، لا + * السوبر-أدمن. منفصلة عن `features` عمداً: تلك «ماذا اشتريت»، وهذه «كيف + * تشتغل». راجع docs/18 §5.2 (سياسة الدين). + */ + @Column({ type: 'jsonb', default: {} }) + settings: Record; + @Column({ default: 'active' }) status: string; diff --git a/backend/src/database/migrations/1721860000000-TenantSettings.ts b/backend/src/database/migrations/1721860000000-TenantSettings.ts new file mode 100644 index 0000000..10c4a94 --- /dev/null +++ b/backend/src/database/migrations/1721860000000-TenantSettings.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * سياسات تشغيل المستأجر (docs/18 §5.2): سياسة الدين ومكافأة التسجيل لكل عملة. + * + * منفصلة عن `features` عمداً: تلك استحقاقات مشتراة يحكمها السوبر-أدمن، وهذه + * سياسات يحكمها المستأجر نفسه. + */ +export class TenantSettings1721860000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query( + `ALTER TABLE tripz_tenants ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'`, + ); + } + + public async down(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_tenants DROP COLUMN IF EXISTS settings`); + } +} diff --git a/backend/src/modules/credit/credit-policy.ts b/backend/src/modules/credit/credit-policy.ts new file mode 100644 index 0000000..004b4bb --- /dev/null +++ b/backend/src/modules/credit/credit-policy.ts @@ -0,0 +1,51 @@ +/** + * سياسة الرصيد التشغيلي لكل مستأجر (docs/18 §5.2 — قرار المالك 2026-07-17). + * + * المستأجر يضع سياسته: كم ديناً يسمح به لسائقيه، وكم مكافأة التسجيل. + * القيم هنا **افتراضات فقط** — تُتجاوز من `tenants.settings.credit`. + */ + +/** + * الدين المسموح به افتراضاً، **كقيمة موجبة** («كم ديناً أسمح؟») لا كأرضية + * سالبة. أوضح لواجهة المشغّل، و**0 = لا دين إطلاقاً** بلا لبس في الإشارة. + */ +export const DEFAULT_DEBT_ALLOWANCE: Record = { + JOD: 2, + SYP: 200, // بالعملة السورية الجديدة + EGP: 200, +}; + +/** مكافأة التسجيل الافتراضية لكل عملة (docs/18 §5.4). */ +export const DEFAULT_SIGNUP_BONUS: Record = { + JOD: 3, + SYP: 300, + EGP: 300, +}; + +export interface CreditPolicy { + /** موجب = مقدار الدين المسموح؛ 0 = ممنوع الدين. */ + debtAllowance: number; + signupBonus: number; +} + +/** + * يحسب سياسة عملةٍ ما لمستأجر: الافتراض ثم تجاوز المستأجر. + * القيم غير الصالحة (نص/سالب/NaN) تسقط للافتراض — إعدادٌ مكسور يجب ألّا + * يفتح ديناً بلا حدّ ولا يحجب كل السائقين. + */ +export function resolveCreditPolicy( + settings: Record | null | undefined, + currency: string, +): CreditPolicy { + const cfg = settings?.credit ?? {}; + return { + debtAllowance: pick(cfg.debt_allowance?.[currency], DEFAULT_DEBT_ALLOWANCE[currency] ?? 0), + signupBonus: pick(cfg.signup_bonus?.[currency], DEFAULT_SIGNUP_BONUS[currency] ?? 0), + }; +} + +/** يقبل 0 صراحةً — `||` كان سيبتلعه ويعيده للافتراض (فخّ الصفر الصادق). */ +function pick(value: any, fallback: number): number { + const n = typeof value === 'number' ? value : Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} diff --git a/backend/src/modules/credit/credit.controller.ts b/backend/src/modules/credit/credit.controller.ts index b6646ab..9e42dfc 100644 --- a/backend/src/modules/credit/credit.controller.ts +++ b/backend/src/modules/credit/credit.controller.ts @@ -1,8 +1,11 @@ -import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Get, Patch, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { DriverCreditService, CREDIT_FLOOR } from './driver-credit.service'; +import { DriverCreditService } from './driver-credit.service'; import { DriversService } from '../drivers/drivers.service'; +import { TenantsService } from '../tenants/tenants.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; @ApiTags('credit') @@ -13,6 +16,7 @@ export class CreditController { constructor( private readonly credit: DriverCreditService, private readonly drivers: DriversService, + private readonly tenants: TenantsService, ) {} /** رصيدي التشغيلي — يعرضه تطبيق السائق (docs/18 — J8). */ @@ -22,17 +26,41 @@ export class CreditController { 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 { debtAllowance } = await this.credit.policy(user.tenantId, c.currency); const balance = Number(c.balance); return { balance, currency: c.currency, - floor, + debtAllowance, inDebt: balance < 0, - blocked: balance < floor, + blocked: balance < -debtAllowance, }; } + /** + * سياسة الرصيد التشغيلي — **أدمن المستأجر** يضع سياسته (docs/18 §5.2): + * كم ديناً يسمح به (0 = لا دين)، وكم مكافأة التسجيل. لكل عملة. + * ليست نقطة منصة: كل مستأجر وسياسته. + */ + @UseGuards(RolesGuard) + @Roles('admin') + @Patch('policy') + async setPolicy( + @CurrentUser() user: AuthUser, + @Body() body: { + debt_allowance?: Record; + signup_bonus?: Record; + }, + ) { + const t = await this.tenants.updateSettings(user.tenantId, { + credit: { + ...(body.debt_allowance ? { debt_allowance: body.debt_allowance } : {}), + ...(body.signup_bonus ? { signup_bonus: body.signup_bonus } : {}), + }, + }); + return t.settings?.credit ?? {}; + } + @Get('transactions') async history(@CurrentUser() user: AuthUser) { const driver = await this.drivers.findByUser(user.tenantId, user.userId); diff --git a/backend/src/modules/credit/credit.module.ts b/backend/src/modules/credit/credit.module.ts index dea608a..844a8b2 100644 --- a/backend/src/modules/credit/credit.module.ts +++ b/backend/src/modules/credit/credit.module.ts @@ -5,15 +5,18 @@ import { CreditTxn } from './entities/credit-txn.entity'; import { DriverCreditService } from './driver-credit.service'; import { CreditController } from './credit.controller'; import { DriversModule } from '../drivers/drivers.module'; +import { TenantsModule } from '../tenants/tenants.module'; /** * دورة متبادلة مقصودة: drivers يحتاج الرصيد (حجب/مكافأة)، والكنترولر هنا * يحتاج drivers (ليحوّل user_id إلى driver_id) — forwardRef يفكّها. + * TenantsModule لسياسة الدين لكل مستأجر (docs/18 §5.2). */ @Module({ imports: [ TypeOrmModule.forFeature([DriverCredit, CreditTxn]), forwardRef(() => DriversModule), + TenantsModule, ], controllers: [CreditController], providers: [DriverCreditService], diff --git a/backend/src/modules/credit/driver-credit.service.spec.ts b/backend/src/modules/credit/driver-credit.service.spec.ts index 2bbe026..16fce28 100644 --- a/backend/src/modules/credit/driver-credit.service.spec.ts +++ b/backend/src/modules/credit/driver-credit.service.spec.ts @@ -3,11 +3,17 @@ 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'; +import { DriverCreditService } from './driver-credit.service'; +import { DEFAULT_SIGNUP_BONUS, DEFAULT_DEBT_ALLOWANCE } from './credit-policy'; const TENANT = '11111111-1111-1111-1111-111111111111'; const DRIVER = '22222222-2222-2222-2222-222222222222'; +/** مستأجر مزيّف — `settings` هي ما يضبطه المشغّل لسياسته. */ +function fakeTenants(settings: Record = {}) { + return { resolve: jest.fn().mockResolvedValue({ id: TENANT, settings }) } as any; +} + describe('DriverCreditService (docs/18)', () => { let ds: DataSource; let credit: DriverCreditService; @@ -38,9 +44,21 @@ describe('DriverCreditService (docs/18)', () => { await ds.initialize(); await ds.synchronize(); - credit = new DriverCreditService(ds.getRepository(DriverCredit), ds.getRepository(CreditTxn)); + credit = new DriverCreditService( + ds.getRepository(DriverCredit), + ds.getRepository(CreditTxn), + fakeTenants(), + ); }); + /** يعيد بناء الخدمة بسياسة مستأجر مخصّصة. */ + const withPolicy = (settings: Record) => + new DriverCreditService( + ds.getRepository(DriverCredit), + ds.getRepository(CreditTxn), + fakeTenants(settings), + ); + afterEach(async () => { if (ds?.isInitialized) await ds.destroy(); }); @@ -73,39 +91,88 @@ describe('DriverCreditService (docs/18)', () => { }); }); - describe('الحجب عند تجاوز الأرضية فقط', () => { - it('دين ضمن الأرضية لا يحجب', async () => { - await credit.chargeCommission(TENANT, DRIVER, 2, 'trip-1'); // −2، والأرضية −5 + describe('الحجب عند تجاوز الدين المسموح فقط', () => { + it('دين ضمن المسموح لا يحجب', async () => { + await credit.chargeCommission(TENANT, DRIVER, 1, 'trip-1'); // −1، والمسموح 2 expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false); }); - it('تجاوز الأرضية يحجب', async () => { - await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1'); // −6 < −5 + it('تجاوز المسموح يحجب', async () => { + await credit.chargeCommission(TENANT, DRIVER, 3, 'trip-1'); // −3 < −2 expect(await credit.isBlocked(TENANT, DRIVER)).toBe(true); }); it('الشحن يفكّ الحجب', async () => { - await credit.chargeCommission(TENANT, DRIVER, 6, 'trip-1'); + await credit.chargeCommission(TENANT, DRIVER, 3, 'trip-1'); await credit.topup(TENANT, DRIVER, 10); expect(await credit.isBlocked(TENANT, DRIVER)).toBe(false); }); }); + describe('سياسة المستأجر تتجاوز الافتراض (docs/18 §5.2)', () => { + it('مشغّل يسمح بدين أوسع', async () => { + const c = withPolicy({ credit: { debt_allowance: { JOD: 20 } } }); + await c.chargeCommission(TENANT, DRIVER, 15, 'trip-1'); // −15، والمسموح 20 + expect(await c.isBlocked(TENANT, DRIVER)).toBe(false); + }); + + it('مشغّل يمنع الدين تماماً (0) — أي رصيد سالب يحجب فوراً', async () => { + const c = withPolicy({ credit: { debt_allowance: { JOD: 0 } } }); + await c.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1'); // −0.4 + expect(await c.isBlocked(TENANT, DRIVER)).toBe(true); + }); + + it('الصفر لا يسقط للافتراض — «0» سياسة صريحة لا قيمة غائبة', async () => { + const c = withPolicy({ credit: { debt_allowance: { JOD: 0 } } }); + expect((await c.policy(TENANT, 'JOD')).debtAllowance).toBe(0); + }); + + it('حتى مع منع الدين، الرحلة الجارية لا تُقطع — الخصم ينجح', async () => { + const c = withPolicy({ credit: { debt_allowance: { JOD: 0 } } }); + const res = await c.chargeCommission(TENANT, DRIVER, 0.4, 'trip-1'); + expect(Number(res.balance)).toBeCloseTo(-0.4); // خُصم ثم يُحجب لاحقاً + }); + + it('مكافأة تسجيل مخصّصة، وصفر = بلا مكافأة ولا قيد', async () => { + const c = withPolicy({ credit: { signup_bonus: { JOD: 10 } } }); + expect(Number((await c.grantSignupBonus(TENANT, DRIVER, 'JOD')).balance)).toBe(10); + + const none = withPolicy({ credit: { signup_bonus: { JOD: 0 } } }); + const other = '44444444-4444-4444-4444-444444444444'; + expect(Number((await none.grantSignupBonus(TENANT, other, 'JOD')).balance)).toBe(0); + expect(await none.history(TENANT, other)).toHaveLength(0); + }); + + it('إعداد مكسور يسقط للافتراض لا لدين بلا حدّ', async () => { + const c = withPolicy({ credit: { debt_allowance: { JOD: 'كثير' } } }); + expect((await c.policy(TENANT, 'JOD')).debtAllowance).toBe(DEFAULT_DEBT_ALLOWANCE.JOD); + }); + + it('عملة بلا سياسة تسقط للافتراض', async () => { + const c = withPolicy({ credit: { debt_allowance: { SYP: 500 } } }); + expect((await c.policy(TENANT, 'JOD')).debtAllowance).toBe(DEFAULT_DEBT_ALLOWANCE.JOD); + expect((await c.policy(TENANT, 'SYP')).debtAllowance).toBe(500); + }); + }); + describe('مكافأة التسجيل — مرة واحدة لكل سائق', () => { it('تُمنح بقيمة العملة', async () => { const c = await credit.grantSignupBonus(TENANT, DRIVER, 'JOD'); - expect(Number(c.balance)).toBe(SIGNUP_BONUS.JOD); + expect(Number(c.balance)).toBe(DEFAULT_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); // لا مضاعفة + expect(Number(c.balance)).toBe(DEFAULT_SIGNUP_BONUS.JOD); // لا مضاعفة }); - it('كل عملة ومكافأتها', () => { - expect(SIGNUP_BONUS.SYP).toBe(300); // بالعملة السورية الجديدة - expect(SIGNUP_BONUS.EGP).toBe(300); + it('الافتراضات لكل عملة (قرار المالك)', () => { + expect(DEFAULT_SIGNUP_BONUS.SYP).toBe(300); // بالعملة السورية الجديدة + expect(DEFAULT_SIGNUP_BONUS.EGP).toBe(300); + expect(DEFAULT_DEBT_ALLOWANCE.JOD).toBe(2); + expect(DEFAULT_DEBT_ALLOWANCE.SYP).toBe(200); + expect(DEFAULT_DEBT_ALLOWANCE.EGP).toBe(200); }); }); @@ -144,9 +211,10 @@ describe('DriverCreditService (docs/18)', () => { 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); + it('كل عملة مدعومة لها سياسة افتراضية', () => { + for (const cur of ['JOD', 'SYP', 'EGP']) { + expect(DEFAULT_DEBT_ALLOWANCE[cur]).toBeGreaterThan(0); + expect(DEFAULT_SIGNUP_BONUS[cur]).toBeGreaterThan(0); + } }); }); diff --git a/backend/src/modules/credit/driver-credit.service.ts b/backend/src/modules/credit/driver-credit.service.ts index 8fc7e47..365ea78 100644 --- a/backend/src/modules/credit/driver-credit.service.ts +++ b/backend/src/modules/credit/driver-credit.service.ts @@ -3,20 +3,8 @@ 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, -}; +import { CreditPolicy, resolveCreditPolicy } from './credit-policy'; +import { TenantsService } from '../tenants/tenants.service'; @Injectable() export class DriverCreditService { @@ -25,8 +13,18 @@ export class DriverCreditService { constructor( @InjectRepository(DriverCredit) private readonly credits: Repository, @InjectRepository(CreditTxn) private readonly txns: Repository, + private readonly tenants: TenantsService, ) {} + /** + * سياسة المستأجر لعملةٍ ما (docs/18 §5.2). تمرّ عبر `tenants.resolve` فتستفيد + * من كاش المستأجر القائم — لا استعلام قاعدة في كل خصم. + */ + async policy(tenantId: string, currency = 'JOD'): Promise { + const t = await this.tenants.resolve(tenantId); + return resolveCreditPolicy(t?.settings, currency); + } + async get(tenantId: string, driverId: string, currency = 'JOD'): Promise { await this.ensure(this.credits.manager, tenantId, driverId, currency); return (await this.credits.findOne({ @@ -68,7 +66,8 @@ export class DriverCreditService { driverId: string, currency = 'JOD', ): Promise { - const amount = SIGNUP_BONUS[currency] ?? 0; + const { signupBonus: amount } = await this.policy(tenantId, currency); + // المستأجر قد يضبطها صفراً — لا مكافأة، ولا قيد فارغ في الدفتر. if (amount <= 0) return this.get(tenantId, driverId, currency); const already = await this.txns.findOne({ @@ -88,11 +87,16 @@ export class DriverCreditService { } } - /** هل تجاوز السائق حدّ الدين؟ يُفحص عند الاتصال والقبول — لا أثناء رحلة. */ + /** + * هل تجاوز السائق حدّ الدين الذي يسمح به مستأجره؟ + * يُفحص عند الاتصال والقبول — لا أثناء رحلة جارية (docs/18 §5.2). + * + * `debtAllowance = 0` يعني «لا دين»: أي رصيد سالب يحجب فوراً. + */ 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; + const { debtAllowance } = await this.policy(tenantId, c.currency ?? currency); + return Number(c.balance) < -debtAllowance; } history(tenantId: string, driverId: string) { diff --git a/backend/src/modules/tenants/tenants.controller.ts b/backend/src/modules/tenants/tenants.controller.ts index f847faf..7a929ac 100644 --- a/backend/src/modules/tenants/tenants.controller.ts +++ b/backend/src/modules/tenants/tenants.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiSecurity } from '@nestjs/swagger'; import { TenantsService } from './tenants.service'; import { Tenant } from '../../database/entities/tenant.entity'; import { FEATURES } from '../../common/entitlements/features'; +import { PlatformGuard } from '../../common/platform/platform.guard'; @ApiTags('tenants') @Controller() @@ -22,25 +23,36 @@ export class TenantsController { return this.tenants.config(slug); } - // ---- سوبر-أدمن (docs/19 — K3). يُحمى بحارس دور المنصة لاحقاً ---- + // ---- سوبر-أدمن: مالك المنصة يدير كل المستأجرين ---- + // كلها خلف PlatformGuard (`x-platform-secret`). حرجٌ أن تكون خارج أدوار + // المستأجر: بلا هذا الحارس يستطيع أدمن أي مستأجر ترقية اشتراكه بنفسه — + // أي أن كل المجموعة K كانت ستصير زينة. + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Get('admin/tenants') list() { return this.tenants.findAll(); } /** كتالوج الميزات القابلة للبيع — تعرضه لوحة السوبر-أدمن. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Get('admin/features') features() { return { features: FEATURES }; } + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Post('admin/tenants') create(@Body() body: Partial) { return this.tenants.create(body); } /** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Get('admin/tenants/:id/entitlements') entitlements(@Param('id') id: string) { return this.tenants.entitlementsOf(id); @@ -50,6 +62,8 @@ export class TenantsController { * تغيير الاشتراك و/أو الميزات المشتراة منفردة. * يُبطل الكاش فوراً فيسري خلال ثوانٍ **بلا تحديث تطبيق**. */ + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Patch('admin/tenants/:id/subscription') updateSubscription( @Param('id') id: string, diff --git a/backend/src/modules/tenants/tenants.service.ts b/backend/src/modules/tenants/tenants.service.ts index 7ce5723..351e39a 100644 --- a/backend/src/modules/tenants/tenants.service.ts +++ b/backend/src/modules/tenants/tenants.service.ts @@ -54,6 +54,28 @@ export class TenantsService { await this.entitlements.invalidate(tenant.id); } + /** + * سياسات تشغيل المستأجر — يضبطها **المستأجر نفسه** لا السوبر-أدمن. + * الدمج عميق مفتاحاً بمفتاح: ضبط سياسة الدين يجب ألّا يمحو باقي السياسات. + */ + async updateSettings( + tenantId: string, + patch: Record>, + ): Promise { + const t = await this.repo.findOne({ where: { id: tenantId } }); + if (!t) throw new NotFoundException('Tenant not found'); + + const merged: Record = { ...(t.settings ?? {}) }; + for (const [section, value] of Object.entries(patch)) { + merged[section] = { ...(merged[section] ?? {}), ...value }; + } + t.settings = merged; + + const saved = await this.repo.save(t); + await this.invalidate(saved); // وإلا قرأت الخدمة السياسة القديمة حتى ساعة + return saved; + } + /** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */ entitlementsOf(tenantId: string) { return this.entitlements.forTenant(tenantId); diff --git a/backend/src/modules/users/admin-users.controller.ts b/backend/src/modules/users/admin-users.controller.ts index 6290979..344d16d 100644 --- a/backend/src/modules/users/admin-users.controller.ts +++ b/backend/src/modules/users/admin-users.controller.ts @@ -2,22 +2,20 @@ import { BadRequestException, Body, Controller, - Headers, NotFoundException, Param, Patch, Post, - UnauthorizedException, UseGuards, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { TenantsService } from '../tenants/tenants.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { PlatformGuard } from '../../common/platform/platform.guard'; const ROLES = ['rider', 'driver', 'dispatcher', 'admin']; @@ -27,10 +25,10 @@ export class AdminUsersController { constructor( private readonly users: UsersService, private readonly tenants: TenantsService, - private readonly config: ConfigService, ) {} - // الأدمن يعيّن دور مستخدم داخل مستأجره + // أدمن **المستأجر** يعيّن دور مستخدم داخل مستأجره هو — هذه ليست نقطة منصة، + // ونطاقها مضمون بـ user.tenantId القادم من التوكن. @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @@ -41,16 +39,11 @@ export class AdminUsersController { return this.users.findById(user.tenantId, id); } - // تمهيد: تعيين أول أدمن قبل وجود أدمن — يُحمى بسرّ المنصة (super-admin) + // تمهيد: تعيين أول أدمن قبل وجود أدمن — سوبر-أدمن عبر PlatformGuard. + @ApiSecurity('x-platform-secret') + @UseGuards(PlatformGuard) @Post('platform/users/role') - async bootstrap( - @Headers('x-platform-secret') secret: string, - @Body() body: { tenantSlug: string; phone: string; role: string }, - ) { - const expected = this.config.get('platform.secret'); - if (!expected || secret !== expected) { - throw new UnauthorizedException('invalid platform secret'); - } + async bootstrap(@Body() body: { tenantSlug: string; phone: string; role: string }) { if (!ROLES.includes(body.role)) throw new BadRequestException('invalid role'); const tenant = await this.tenants.resolve(body.tenantSlug); if (!tenant) throw new NotFoundException('unknown tenant'); diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index 8942ad4..bf40641 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -232,7 +232,11 @@ **تغيير مقصود على الـseed**: المستأجر التجريبي `siro` صار `sovereign` بدل `brand`. سببان: يطابق قرار المالك («سيرو أول مشترك، له الباقة الكاملة»)، ويمنع حجب `dispatch` وحدّ `drivers_max: 500` من إسقاط سكربتات التحقق (كل تشغيلة تحميل تُنشئ عشرات السائقين وهم يتراكمون). -**ما تبقّى للمجموعة K**: حارس دور المنصة على نقاط `admin/*` (مفتوحة حالياً — بند قائم قبل K أصلاً)، ونقطة إنفاذ لـ`cities_max`. +**حارس السوبر-أدمن ✅**: `PlatformGuard` (`x-platform-secret`, مقارنة ثابتة الزمن) يحرس كل `admin/tenants*` و`admin/features` و`platform/*`. **سرّ غير مضبوط = منع الجميع** لا فتح الباب. حرجٌ لأنه بدونه يرقّي أدمن أي مستأجر اشتراكه بنفسه — أي أن K كلها كانت زينة. `admin/users/:id/role` تبقى لأدمن المستأجر (نطاقها من التوكن) لأنها ليست نقطة منصة. + +**سياسة الدين لكل مستأجر ✅** (قرار المالك): `tenants.settings.credit` — `debt_allowance` و`signup_bonus` لكل عملة، يضبطهما أدمن المستأجر عبر `PATCH /credit/policy`. الافتراضات 2 JOD · 200 SYP · 200 EGP، و**0 خيار صريح = لا دين**. `settings` منفصلة عن `features` عمداً: تلك «ماذا اشتريت» (سوبر-أدمن)، وهذه «كيف تشتغل» (المستأجر). هجرة `TenantSettings`. + +**ما تبقّى**: نقطة إنفاذ لـ`cities_max` (لا مفهوم «مدينة» مُدار بعد)، ونموذج مستخدم منصة حقيقي بدل السرّ المشترك. --- diff --git a/docs/18-driver-credit-commission.md b/docs/18-driver-credit-commission.md index fd219a3..d1dc647 100644 --- a/docs/18-driver-credit-commission.md +++ b/docs/18-driver-credit-commission.md @@ -68,7 +68,10 @@ commission_amount = split(price_for_passenger) // تُحسب وتُسجَ الدين تجاوز الأرضية → يخرج من فهرس المتاحين: «اشحن رصيدك التشغيلي». ``` - **الخصم لا يفشل أبداً** — بخلاف محفظة الأرباح (I1) حيث الخصم مشروط بالرصيد. هنا الشرط الوحيد هو الأرضية، ويُفحص **بعد** الخصم لا قبله. -- `credit_floor` (أقصى دين) لكل مستأجر/عملة. الفحص عند `setOnline` و`accept` وبعد كل خصم — **لا** أثناء رحلة جارية. +- **مقدار الدين المسموح سياسةُ المستأجر، لا ثابتٌ في الكود** (قرار المالك): كل مشغّل يحدّد كم ديناً يسمح به لسائقيه، **و0 خيار صريح = لا دين إطلاقاً**. يُخزَّن في `tenants.settings.credit.debt_allowance.{CURRENCY}` ويُضبط عبر `PATCH /credit/policy` (أدمن المستأجر). الافتراضات: **2 JOD · 200 SYP · 200 EGP**. +- يُخزَّن **موجباً** («كم ديناً أسمح؟») لا كأرضية سالبة — أوضح لواجهة المشغّل، و0 بلا لبس في الإشارة. الفحص عند `setOnline` و`accept` — **لا** أثناء رحلة جارية. +- مكافأة التسجيل كذلك قابلة للضبط (`settings.credit.signup_bonus`)، و0 = بلا مكافأة. +- إعداد مكسور (نص/سالب) يسقط للافتراض — لا لدين بلا حدّ ولا لحجب الجميع. - **لماذا لا نحجب عند الصفر:** حجبٌ عند الصفر يعني سائقاً يُترك في منتصف يومه، وراكباً بلا سائق، وتجنيداً يموت. الدين المحدود أرخص من ذلك بكثير. لكنه يظلّ **محدوداً** — أرضية بلا سقف تعني عمولة غير قابلة للتحصيل إلى الأبد. ### 5.3 باقات الشحن لكل عملة