feat: حارس السوبر-أدمن + سياسة الدين لكل مستأجر

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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 15:26:41 +03:00
co-authored by Claude Opus 4.8
parent aae01fc4ee
commit 91e1692457
16 changed files with 388 additions and 60 deletions
+2
View File
@@ -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, // عالمي — تخزين ملفات الوثائق
@@ -0,0 +1,49 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
const SECRET = 'super-secret-value';
function ctx(headers: Record<string, any> = {}): 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,
);
});
});
@@ -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<string>('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);
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PlatformGuard } from './platform.guard';
@Global()
@Module({
providers: [PlatformGuard],
exports: [PlatformGuard],
})
export class PlatformModule {}
@@ -36,9 +36,18 @@ export class Tenant {
@Column({ type: 'jsonb', default: {} })
branding: Record<string, any>;
// استحقاقات مشتراة (ماذا يملك) — يحكمها السوبر-أدمن. راجع docs/19.
@Column({ type: 'jsonb', default: {} })
features: Record<string, any>;
/**
* سياسات تشغيل المستأجر (كيف يدير عمله) — يحكمها **المستأجر نفسه**، لا
* السوبر-أدمن. منفصلة عن `features` عمداً: تلك «ماذا اشتريت»، وهذه «كيف
* تشتغل». راجع docs/18 §5.2 (سياسة الدين).
*/
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ default: 'active' })
status: string;
@@ -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<void> {
await q.query(
`ALTER TABLE tripz_tenants ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'`,
);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE tripz_tenants DROP COLUMN IF EXISTS settings`);
}
}
@@ -0,0 +1,51 @@
/**
* سياسة الرصيد التشغيلي لكل مستأجر (docs/18 §5.2 — قرار المالك 2026-07-17).
*
* المستأجر يضع سياسته: كم ديناً يسمح به لسائقيه، وكم مكافأة التسجيل.
* القيم هنا **افتراضات فقط** — تُتجاوز من `tenants.settings.credit`.
*/
/**
* الدين المسموح به افتراضاً، **كقيمة موجبة** («كم ديناً أسمح؟») لا كأرضية
* سالبة. أوضح لواجهة المشغّل، و**0 = لا دين إطلاقاً** بلا لبس في الإشارة.
*/
export const DEFAULT_DEBT_ALLOWANCE: Record<string, number> = {
JOD: 2,
SYP: 200, // بالعملة السورية الجديدة
EGP: 200,
};
/** مكافأة التسجيل الافتراضية لكل عملة (docs/18 §5.4). */
export const DEFAULT_SIGNUP_BONUS: Record<string, number> = {
JOD: 3,
SYP: 300,
EGP: 300,
};
export interface CreditPolicy {
/** موجب = مقدار الدين المسموح؛ 0 = ممنوع الدين. */
debtAllowance: number;
signupBonus: number;
}
/**
* يحسب سياسة عملةٍ ما لمستأجر: الافتراض ثم تجاوز المستأجر.
* القيم غير الصالحة (نص/سالب/NaN) تسقط للافتراض — إعدادٌ مكسور يجب ألّا
* يفتح ديناً بلا حدّ ولا يحجب كل السائقين.
*/
export function resolveCreditPolicy(
settings: Record<string, any> | 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;
}
@@ -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<string, number>;
signup_bonus?: Record<string, number>;
},
) {
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);
@@ -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],
@@ -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<string, any> = {}) {
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<string, any>) =>
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);
}
});
});
@@ -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<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,
};
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<DriverCredit>,
@InjectRepository(CreditTxn) private readonly txns: Repository<CreditTxn>,
private readonly tenants: TenantsService,
) {}
/**
* سياسة المستأجر لعملةٍ ما (docs/18 §5.2). تمرّ عبر `tenants.resolve` فتستفيد
* من كاش المستأجر القائم — لا استعلام قاعدة في كل خصم.
*/
async policy(tenantId: string, currency = 'JOD'): Promise<CreditPolicy> {
const t = await this.tenants.resolve(tenantId);
return resolveCreditPolicy(t?.settings, currency);
}
async get(tenantId: string, driverId: string, currency = 'JOD'): Promise<DriverCredit> {
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<DriverCredit> {
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<boolean> {
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) {
@@ -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<Tenant>) {
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,
@@ -54,6 +54,28 @@ export class TenantsService {
await this.entitlements.invalidate(tenant.id);
}
/**
* سياسات تشغيل المستأجر — يضبطها **المستأجر نفسه** لا السوبر-أدمن.
* الدمج عميق مفتاحاً بمفتاح: ضبط سياسة الدين يجب ألّا يمحو باقي السياسات.
*/
async updateSettings(
tenantId: string,
patch: Record<string, Record<string, any>>,
): Promise<Tenant> {
const t = await this.repo.findOne({ where: { id: tenantId } });
if (!t) throw new NotFoundException('Tenant not found');
const merged: Record<string, any> = { ...(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);
@@ -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<string>('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');