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
@@ -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) {