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:
co-authored by
Claude Opus 4.8
parent
aae01fc4ee
commit
91e1692457
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user