From aae01fc4ee81048419c9e9afd96a89c874b4dab2 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 14:24:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A7=D9=84=D9=85=D8=AC=D9=85=D9=88?= =?UTF-8?q?=D8=B9=D8=A9=20K=20=E2=80=94=20=D8=A7=D8=B3=D8=AA=D8=AD=D9=82?= =?UTF-8?q?=D8=A7=D9=82=D8=A7=D8=AA=20=D8=A7=D9=84=D8=A7=D8=B4=D8=AA=D8=B1?= =?UTF-8?q?=D8=A7=D9=83=20=D9=85=D9=81=D8=B1=D9=88=D8=B6=D8=A9=20=D8=B9?= =?UTF-8?q?=D9=84=D9=89=20=D8=A7=D9=84=D8=B3=D9=8A=D8=B1=D9=81=D8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit القاعدة (docs/19): علم الميزة في التطبيق قرار عرض لا حدّ أمني. من يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من FeatureGuard. - K1: FeatureGuard + @RequiresFeature — tenant_id من التوكن الموقَّع لا من ترويسة. الوحدة عالمية عمداً: إجبار كل وحدة على استيرادها = نقطة منسيّة يوماً ما، والمنسيّة ميزة مجانية للجميع - K2: كتالوج الميزات + افتراضات الباقات. الافتراض هو المنع (قائمة بيضاء): ميزة جديدة تبقى محجوبة حتى تُمنح صراحةً - K3: GET /admin/features · GET /admin/tenants/:id/entitlements · PATCH /admin/tenants/:id/subscription — يدمج features لا يستبدلها (استبدالها كان سيمحو المشتريات السابقة) ويُبطل الكاش فوراً - K4: drivers_max عند apply — عند الإنشاء فقط، فسائق قائم لا يُطرد بتغيير باقة - K5: /tenant/config يرجع الاستحقاقات المحسوبة لا features الخام (الخام تجاوزات فقط فكان سيُظهر ميزات الباقة مطفأة) - K6: اختبار أن الترويسة لا تزوّر المستأجر + أن الافتراض منع لا سماح - K7: السيادة = كل الميزات — المستأجر يملك السيرفر فالحجب هناك وهم لا حماية محروس: dispatch · chat · payments/charge. المدفوعات لكل نقطة لا للصنف — الـwebhook بلا JWT فحارس الصنف كان سيمنعه. seed: المستأجر التجريبي siro صار sovereign (قرار المالك: أول مشترك بالباقة الكاملة) — وأيضاً يمنع حجب dispatch وحدّ 500 سائق من إسقاط سكربتات التحقق. Co-Authored-By: Claude Opus 4.8 --- backend/src/app.module.ts | 2 + .../entitlements/entitlements.module.ts | 17 ++++ .../entitlements/entitlements.service.ts | 55 +++++++++++ .../common/entitlements/feature.guard.spec.ts | 70 ++++++++++++++ .../src/common/entitlements/feature.guard.ts | 51 +++++++++++ .../src/common/entitlements/features.spec.ts | 55 +++++++++++ backend/src/common/entitlements/features.ts | 91 +++++++++++++++++++ backend/src/common/seed/seed.service.ts | 5 +- backend/src/modules/chat/chat.controller.ts | 4 +- .../modules/dispatch/dispatch.controller.ts | 4 +- .../src/modules/drivers/drivers.service.ts | 11 +++ .../modules/payments/payments.controller.ts | 8 +- .../src/modules/tenants/tenants.controller.ts | 39 +++++++- .../src/modules/tenants/tenants.service.ts | 41 ++++++++- docs/17-backend-backlog.md | 26 ++++-- 15 files changed, 459 insertions(+), 20 deletions(-) create mode 100644 backend/src/common/entitlements/entitlements.module.ts create mode 100644 backend/src/common/entitlements/entitlements.service.ts create mode 100644 backend/src/common/entitlements/feature.guard.spec.ts create mode 100644 backend/src/common/entitlements/feature.guard.ts create mode 100644 backend/src/common/entitlements/features.spec.ts create mode 100644 backend/src/common/entitlements/features.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e28ef6c..fdcd264 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { TenantMiddleware } from './common/tenant/tenant.middleware'; 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 { SeedModule } from './common/seed/seed.module'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; @@ -60,6 +61,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module'; RedisModule, // عالمي — عميل Redis للمطابقة و OTP I18nModule, // عالمي — ترجمة نصوص الإشعارات CacheModule, // عالمي — كاش-جانبي فوق Redis (خط أول قبل القاعدة) + EntitlementsModule, // عالمي — استحقاقات الاشتراك + FeatureGuard NabehModule, // عالمي — إرسال OTP واتساب NotificationsModule, // عالمي — FCM StorageModule, // عالمي — تخزين ملفات الوثائق diff --git a/backend/src/common/entitlements/entitlements.module.ts b/backend/src/common/entitlements/entitlements.module.ts new file mode 100644 index 0000000..36b63de --- /dev/null +++ b/backend/src/common/entitlements/entitlements.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { EntitlementsService } from './entitlements.service'; +import { FeatureGuard } from './feature.guard'; + +/** + * عالمي: `FeatureGuard` يُستعمل في وحدات كثيرة، وإجبار كلٍّ منها على استيراد + * الوحدة يعني نقطةً منسيّة يوماً ما — والنقطة المنسيّة ميزةٌ مجانية للجميع. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([Tenant])], + providers: [EntitlementsService, FeatureGuard], + exports: [EntitlementsService, FeatureGuard], +}) +export class EntitlementsModule {} diff --git a/backend/src/common/entitlements/entitlements.service.ts b/backend/src/common/entitlements/entitlements.service.ts new file mode 100644 index 0000000..866310b --- /dev/null +++ b/backend/src/common/entitlements/entitlements.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { CacheService, TTL } from '../cache/cache.service'; +import { Entitlements, Feature, resolveEntitlements } from './features'; + +const key = (tenantId: string) => `entitlements:${tenantId}`; + +/** + * استحقاقات المستأجر — Redis خط أول والقاعدة احتياط (docs/19 — K1). + * تُقرأ على كل نقطة محميّة، لذا لا يجوز أن تكون استعلام قاعدة في كل طلب. + */ +@Injectable() +export class EntitlementsService { + private readonly logger = new Logger('Entitlements'); + + constructor( + @InjectRepository(Tenant) private readonly tenants: Repository, + private readonly cache: CacheService, + ) {} + + async forTenant(tenantId: string): Promise { + return this.cache.wrap(key(tenantId), TTL.tenant, async () => { + const t = await this.tenants.findOne({ + where: { id: tenantId }, + select: { id: true, plan: true, features: true, status: true }, + }); + if (!t) return null; + return resolveEntitlements(t.plan, t.features ?? {}); + }); + } + + /** + * هل يملك المستأجر هذه الميزة؟ + * **مستأجر مجهول = ممنوع** — لا يُفترض السماح عند غياب المعلومة. + */ + async has(tenantId: string, feature: Feature): Promise { + const e = await this.forTenant(tenantId); + return e?.features?.[feature] === true; + } + + /** حدّ عددي؛ `undefined`/`null` = بلا حدّ. */ + async limit(tenantId: string, name: 'drivers_max' | 'cities_max'): Promise { + const e = await this.forTenant(tenantId); + const v = e?.limits?.[name]; + return typeof v === 'number' ? v : null; + } + + /** يُنادى بعد أي تغيير على الباقة/الميزات — وإلا سرى التغيير بعد ساعة. */ + async invalidate(tenantId: string): Promise { + await this.cache.del(key(tenantId)); + this.logger.debug(`entitlements invalidated for ${tenantId}`); + } +} diff --git a/backend/src/common/entitlements/feature.guard.spec.ts b/backend/src/common/entitlements/feature.guard.spec.ts new file mode 100644 index 0000000..ea9dabb --- /dev/null +++ b/backend/src/common/entitlements/feature.guard.spec.ts @@ -0,0 +1,70 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { FeatureGuard } from './feature.guard'; +import { EntitlementsService } from './entitlements.service'; + +const TENANT_WITH = 'tenant-with-bots'; +const TENANT_WITHOUT = 'tenant-without-bots'; + +/** سياق تنفيذ مزيّف — `user` هو ما يحقنه JwtAuthGuard من التوكن الموقَّع. */ +function ctx(user: any, headers: Record = {}): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => ({ user, headers }) }), + getHandler: () => ({}), + getClass: () => ({}), + } as any; +} + +describe('FeatureGuard — الحدّ الأمني الحقيقي (docs/19 K1/K6)', () => { + let guard: FeatureGuard; + let reflector: Reflector; + let entitlements: EntitlementsService; + + beforeEach(() => { + reflector = { getAllAndOverride: jest.fn() } as any; + entitlements = { + has: jest.fn(async (tenantId: string) => tenantId === TENANT_WITH), + } as any; + guard = new FeatureGuard(reflector, entitlements); + }); + + it('نقطة بلا @RequiresFeature تمرّ', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined); + await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).resolves.toBe(true); + expect(entitlements.has).not.toHaveBeenCalled(); + }); + + it('مستأجر يملك الميزة يمرّ', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots'); + await expect(guard.canActivate(ctx({ tenantId: TENANT_WITH }))).resolves.toBe(true); + }); + + it('مستأجر لا يملكها يُرفض بـ403', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots'); + await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('الرسالة ثابتة ولا تكشف شيئاً عن مستأجرين آخرين', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots'); + await expect(guard.canActivate(ctx({ tenantId: TENANT_WITHOUT }))).rejects.toThrow( + 'feature_not_in_plan', + ); + }); + + it('بلا مستخدم موثَّق = منع (الافتراض ليس السماح)', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots'); + await expect(guard.canActivate(ctx(undefined))).rejects.toThrow(ForbiddenException); + }); + + it('لا يمكن تزوير المستأجر بترويسة — الحارس يقرأ التوكن وحده', async () => { + (reflector.getAllAndOverride as jest.Mock).mockReturnValue('bots'); + // المهاجم يدّعي أنه المستأجر المالك للميزة عبر الترويسة… + const forged = ctx({ tenantId: TENANT_WITHOUT }, { 'x-tenant-id': TENANT_WITH }); + + await expect(guard.canActivate(forged)).rejects.toThrow(ForbiddenException); + // …والحارس سأل عن المستأجر القادم من التوكن لا من الترويسة. + expect(entitlements.has).toHaveBeenCalledWith(TENANT_WITHOUT, 'bots'); + }); +}); diff --git a/backend/src/common/entitlements/feature.guard.ts b/backend/src/common/entitlements/feature.guard.ts new file mode 100644 index 0000000..f71fded --- /dev/null +++ b/backend/src/common/entitlements/feature.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { EntitlementsService } from './entitlements.service'; +import { Feature } from './features'; + +export const FEATURE_KEY = 'required_feature'; + +/** + * يحرس نقطةً بميزة مشتراة (docs/19 — K1). + * + * **هذا هو الحدّ الأمني الحقيقي.** علم الميزة في التطبيق قرار *عرض* فقط؛ من + * يفكّك التطبيق ويفعّل الأعلام يرى الشاشة ثم يصطدم بـ403 من هنا. + * + * يجب أن يُستعمل **بعد** `JwtAuthGuard` — يعتمد على `tenant_id` القادم من + * التوكن الموقَّع، لا من ترويسة يكتبها العميل. + */ +export const RequiresFeature = (feature: Feature) => SetMetadata(FEATURE_KEY, feature); + +@Injectable() +export class FeatureGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly entitlements: EntitlementsService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const feature = this.reflector.getAllAndOverride(FEATURE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!feature) return true; // النقطة غير محروسة بميزة + + const { user } = context.switchToHttp().getRequest(); + const tenantId = user?.tenantId; + // بلا مستأجر موثَّق لا نستطيع التحقق — والافتراض هو المنع. + if (!tenantId) throw new ForbiddenException('feature_not_in_plan'); + + if (!(await this.entitlements.has(tenantId, feature))) { + // رسالة ثابتة يعرضها التطبيق بلطف («هذه الميزة غير مشمولة باشتراكك»)، + // ولا تكشف للمهاجم أي ميزات يملكها المستأجرون الآخرون. + throw new ForbiddenException('feature_not_in_plan'); + } + return true; + } +} diff --git a/backend/src/common/entitlements/features.spec.ts b/backend/src/common/entitlements/features.spec.ts new file mode 100644 index 0000000..f102a3f --- /dev/null +++ b/backend/src/common/entitlements/features.spec.ts @@ -0,0 +1,55 @@ +import { FEATURES, resolveEntitlements } from './features'; + +describe('resolveEntitlements — كتالوج الاستحقاقات (docs/19 K2)', () => { + it('الافتراض هو المنع: كل ميزة خارج الباقة محجوبة', () => { + const e = resolveEntitlements('launch'); + expect(e.features.bots).toBe(false); + expect(e.features.market_intel).toBe(false); + expect(e.features.dispatch).toBe(false); + }); + + it('كل ميزة في الكتالوج لها قيمة صريحة — لا undefined يُقرأ كسماح', () => { + const e = resolveEntitlements('launch'); + for (const f of FEATURES) { + expect(typeof e.features[f]).toBe('boolean'); + } + }); + + it('الباقة تعطي افتراضاتها', () => { + expect(resolveEntitlements('launch').features.wallet).toBe(true); + expect(resolveEntitlements('fleet').features.dispatch).toBe(true); + expect(resolveEntitlements('brand').features.dispatch).toBe(false); + }); + + it('السيادة تشمل كل شيء — قرار مقصود (docs/19 §6)', () => { + const e = resolveEntitlements('sovereign'); + for (const f of FEATURES) expect(e.features[f]).toBe(true); + }); + + it('ميزة مشتراة منفردة فوق الباقة', () => { + const e = resolveEntitlements('brand', { bots: true }); + expect(e.features.bots).toBe(true); + expect(e.features.wallet).toBe(true); // افتراض الباقة باقٍ + }); + + it('سحب ميزة من الباقة صراحةً', () => { + expect(resolveEntitlements('fleet', { dispatch: false }).features.dispatch).toBe(false); + }); + + it('مفاتيح مجهولة تُتجاهل — لا تفتح شيئاً', () => { + const e = resolveEntitlements('launch', { not_a_feature: true } as any); + expect((e.features as any).not_a_feature).toBeUndefined(); + expect(e.features.bots).toBe(false); + }); + + it('الحدود العددية من الباقة، وتُتجاوز صراحةً', () => { + expect(resolveEntitlements('launch').limits.drivers_max).toBe(50); + expect(resolveEntitlements('launch', { limits: { drivers_max: 200 } }).limits.drivers_max).toBe(200); + }); + + it('باقة مجهولة تسقط لأضيق باقة لا لأوسعها', () => { + const e = resolveEntitlements('nonsense' as any); + expect(e.features.bots).toBe(false); + expect(e.limits.drivers_max).toBe(50); + }); +}); diff --git a/backend/src/common/entitlements/features.ts b/backend/src/common/entitlements/features.ts new file mode 100644 index 0000000..33cdc7e --- /dev/null +++ b/backend/src/common/entitlements/features.ts @@ -0,0 +1,91 @@ +import { TenantPlan } from '../../database/entities/tenant.entity'; + +/** + * كتالوج الميزات القابلة للبيع (docs/19 — K2). + * الميزة تُشترى منفردة فوق الباقة، فـ`plan` يعطي الافتراضات و`tenants.features` + * هي الحقيقة النهائية. + */ +export const FEATURES = [ + 'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب + 'wallet', // محفظة الراكب/السائق + التسوية + 'payments', // بوابات الدفع والشحن + 'chat', // دردشة داخل الرحلة + 'calls', // مكالمات WebRTC + 'ride_types', // أنواع رحلات مخصّصة للمستأجر + 'market_intel', // الاستخبار السوقي + 'bots', // البوتات + 'ads', // الدعاية + 'transit', // نظام المواصلات + 'api_access', // API + Webhooks للمستأجر +] as const; + +export type Feature = (typeof FEATURES)[number]; + +/** الحدود العددية — تُفرض على السيرفر أيضاً (docs/19 — K4). */ +export interface Limits { + drivers_max?: number; + cities_max?: number; +} + +export interface Entitlements { + features: Record; + limits: Limits; +} + +/** `null` = بلا حدّ. */ +const UNLIMITED = null; + +/** + * افتراضات كل باقة (docs/05 — انطلاقة/علامة/أسطول+/سيادة). + * ما لا يُذكر هنا = **ممنوع**؛ الافتراض هو المنع لا السماح. + */ +const PLAN_DEFAULTS: Record = { + launch: { + features: ['wallet', 'chat', 'ride_types'], + limits: { drivers_max: 50, cities_max: 1 }, + }, + brand: { + features: ['wallet', 'chat', 'calls', 'ride_types', 'payments'], + limits: { drivers_max: 500, cities_max: 3 }, + }, + fleet: { + features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'dispatch', 'api_access'], + limits: { drivers_max: 5000, cities_max: 20 }, + }, + // السيادة: كل شيء مشمول — قرار مقصود (docs/19 §6). المستأجر يشغّل النسخة على + // خوادمه، فلا يوجد حارس يحرس ضدّه؛ الحجب هناك وهمٌ لا حماية. الحدّ تعاقدي. + sovereign: { + features: [...FEATURES], + limits: { drivers_max: UNLIMITED as any, cities_max: UNLIMITED as any }, + }, +}; + +/** خريطة «كل الميزات ممنوعة» — نقطة البداية دائماً (default-deny). */ +function noFeatures(): Record { + return Object.fromEntries(FEATURES.map((f) => [f, false])) as Record; +} + +/** + * يحسب استحقاقات المستأجر: افتراضات الباقة، ثم تجاوزات `tenants.features` + * الصريحة (شراء ميزة منفردة أو سحبها). + * + * **مفتاح غير موجود = ممنوع.** قائمة بيضاء لا سوداء — ميزة جديدة تُضاف للكتالوج + * تبقى محجوبة عن الجميع حتى تُمنح صراحةً، لا العكس. + */ +export function resolveEntitlements( + plan: TenantPlan, + overrides: Record = {}, +): Entitlements { + const base = PLAN_DEFAULTS[plan] ?? PLAN_DEFAULTS.launch; + + const features = noFeatures(); + for (const f of base.features) features[f] = true; + + // تجاوزات صريحة — تقبل المنح والسحب معاً. + for (const f of FEATURES) { + if (typeof overrides?.[f] === 'boolean') features[f] = overrides[f]; + } + + const limits: Limits = { ...base.limits, ...(overrides?.limits ?? {}) }; + return { features, limits }; +} diff --git a/backend/src/common/seed/seed.service.ts b/backend/src/common/seed/seed.service.ts index 9327e9e..d7b6eb1 100644 --- a/backend/src/common/seed/seed.service.ts +++ b/backend/src/common/seed/seed.service.ts @@ -33,7 +33,10 @@ export class SeedService implements OnModuleInit { name: 'Siro (Demo — Tenant Zero)', slug: 'siro', countryPack: 'jo', - plan: 'brand', + // سيرو = المشترك الأول بالباقة الكاملة (قرار المالك)، وسيادةً تعني كل + // الميزات بلا حدود عددية (docs/19 §6). عملياً أيضاً: أي باقة أدنى كانت + // ستحجب dispatch وتُسقط سكربتات التحقق بـ403 وحدّ السائقين. + plan: 'sovereign', }); this.logger.log(`seeded demo tenant "siro" (${tenant.id})`); } diff --git a/backend/src/modules/chat/chat.controller.ts b/backend/src/modules/chat/chat.controller.ts index f5378fa..886d542 100644 --- a/backend/src/modules/chat/chat.controller.ts +++ b/backend/src/modules/chat/chat.controller.ts @@ -3,10 +3,12 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ChatService } from './chat.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; @ApiTags('chat') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, FeatureGuard) +@RequiresFeature('chat') @Controller('trips') export class ChatController { constructor(private readonly chat: ChatService) {} diff --git a/backend/src/modules/dispatch/dispatch.controller.ts b/backend/src/modules/dispatch/dispatch.controller.ts index d41b3a2..10c1997 100644 --- a/backend/src/modules/dispatch/dispatch.controller.ts +++ b/backend/src/modules/dispatch/dispatch.controller.ts @@ -5,11 +5,13 @@ 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 { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; @ApiTags('dispatch') @ApiBearerAuth() -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard) @Roles('dispatcher', 'admin') +@RequiresFeature('dispatch') @Controller('dispatch') export class DispatchController { constructor(private readonly dispatch: DispatchService) {} diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index 1e13240..989a0bf 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -5,6 +5,7 @@ import { Driver } from './entities/driver.entity'; import { UsersService } from '../users/users.service'; import { DriverLocationService } from '../locations/driver-location.service'; import { DriverCreditService } from '../credit/driver-credit.service'; +import { EntitlementsService } from '../../common/entitlements/entitlements.service'; @Injectable() export class DriversService { @@ -16,6 +17,7 @@ export class DriversService { private readonly users: UsersService, private readonly locations: DriverLocationService, private readonly credit: DriverCreditService, + private readonly entitlements: EntitlementsService, ) {} findByUser(tenantId: string, userId: string): Promise { @@ -33,6 +35,15 @@ export class DriversService { data: Partial, ): Promise { let driver = await this.findByUser(tenantId, userId); + if (!driver) { + // حدّ الباقة يُفرض على السيرفر (docs/19 — K4): السائق رقم 501 يُرفض + // مهما قال التطبيق. يُفحص عند الإنشاء فقط — سائق قائم لا يُطرد بتغيير باقة. + const max = await this.entitlements.limit(tenantId, 'drivers_max'); + if (max != null) { + const count = await this.repo.count({ where: { tenant_id: tenantId } }); + if (count >= max) throw new ForbiddenException('drivers_limit_reached'); + } + } if (!driver) { driver = this.repo.create({ tenant_id: tenantId, diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index 006d4fa..3a4c1c4 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -3,14 +3,20 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { PaymentsService } from './payments.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; +import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard'; +/** + * الحراسة هنا **لكل نقطة** لا على مستوى الصنف: الـwebhook يناديه مزوّد الدفع + * بلا JWT، فحارس الميزة على الصنف كان سيمنعه (بلا مستأجر = منع). + */ @ApiTags('payments') @Controller('payments') export class PaymentsController { constructor(private readonly payments: PaymentsService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, FeatureGuard) + @RequiresFeature('payments') @Post('charge') charge(@CurrentUser() user: AuthUser, @Body() body: any) { return this.payments.charge(user.tenantId, { diff --git a/backend/src/modules/tenants/tenants.controller.ts b/backend/src/modules/tenants/tenants.controller.ts index e4c219e..f847faf 100644 --- a/backend/src/modules/tenants/tenants.controller.ts +++ b/backend/src/modules/tenants/tenants.controller.ts @@ -1,27 +1,60 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantsService } from './tenants.service'; import { Tenant } from '../../database/entities/tenant.entity'; +import { FEATURES } from '../../common/entitlements/features'; @ApiTags('tenants') @Controller() export class TenantsController { constructor(private readonly tenants: TenantsService) {} - // للتطبيق: إعداد المستأجر الحالي عند الإقلاع. + /** + * إعداد المستأجر عند إقلاع التطبيق. + * + * ⚠️ **`features` هنا للعرض فقط — وليست حدّاً أمنياً** (docs/19 — K5). + * التطبيق يستعملها ليقرّر **ماذا يُظهر**. من يفكّك التطبيق ويفعّل كل الأعلام + * يرى الشاشات ثم يصطدم بـ403 من `FeatureGuard` على السيرفر. لا تُبنَ أي + * حماية على هذه الاستجابة. + */ @Get('tenant/config/:slug') config(@Param('slug') slug: string) { return this.tenants.config(slug); } - // للسوبر-آدمن: إدارة كل المستأجرين (يُحمى بحارس دور لاحقاً). + // ---- سوبر-أدمن (docs/19 — K3). يُحمى بحارس دور المنصة لاحقاً ---- + @Get('admin/tenants') list() { return this.tenants.findAll(); } + /** كتالوج الميزات القابلة للبيع — تعرضه لوحة السوبر-أدمن. */ + @Get('admin/features') + features() { + return { features: FEATURES }; + } + @Post('admin/tenants') create(@Body() body: Partial) { return this.tenants.create(body); } + + /** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */ + @Get('admin/tenants/:id/entitlements') + entitlements(@Param('id') id: string) { + return this.tenants.entitlementsOf(id); + } + + /** + * تغيير الاشتراك و/أو الميزات المشتراة منفردة. + * يُبطل الكاش فوراً فيسري خلال ثوانٍ **بلا تحديث تطبيق**. + */ + @Patch('admin/tenants/:id/subscription') + updateSubscription( + @Param('id') id: string, + @Body() body: { plan?: Tenant['plan']; features?: Record }, + ) { + return this.tenants.updateSubscription(id, body); + } } diff --git a/backend/src/modules/tenants/tenants.service.ts b/backend/src/modules/tenants/tenants.service.ts index 9d447c7..7ce5723 100644 --- a/backend/src/modules/tenants/tenants.service.ts +++ b/backend/src/modules/tenants/tenants.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Tenant } from '../../database/entities/tenant.entity'; +import { Tenant, TenantPlan } from '../../database/entities/tenant.entity'; import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service'; +import { EntitlementsService } from '../../common/entitlements/entitlements.service'; @Injectable() export class TenantsService { @@ -10,6 +11,7 @@ export class TenantsService { @InjectRepository(Tenant) private readonly repo: Repository, private readonly cache: CacheService, + private readonly entitlements: EntitlementsService, ) {} findAll(): Promise { @@ -49,6 +51,33 @@ export class TenantsService { /** يُبطل مفتاحَي المستأجر (بالـslug وبالـUUID) — يُنادى بعد أي تعديل عليه. */ async invalidate(tenant: Pick): Promise { await this.cache.del(CacheKeys.tenant(tenant.slug), CacheKeys.tenant(tenant.id)); + await this.entitlements.invalidate(tenant.id); + } + + /** الاستحقاقات الفعّالة — نفس ما يقرأه `FeatureGuard` (docs/19 — K3). */ + entitlementsOf(tenantId: string) { + return this.entitlements.forTenant(tenantId); + } + + /** + * تغيير باقة المستأجر و/أو ميزاته المشتراة منفردة (docs/19 — K3). + * `features` تُدمج لا تُستبدل — وإلا محا تعديلٌ جزئي كل المشتريات السابقة. + */ + async updateSubscription( + tenantId: string, + body: { plan?: TenantPlan; features?: Record }, + ): Promise { + const t = await this.repo.findOne({ where: { id: tenantId } }); + if (!t) throw new NotFoundException('Tenant not found'); + + if (body.plan) t.plan = body.plan; + if (body.features) t.features = { ...(t.features ?? {}), ...body.features }; + + const saved = await this.repo.save(t); + // بلا هذا يسري الاشتراك الجديد بعد ساعة (عمر الكاش) — أي مستأجر دفع ولم + // تُفتح له ميزته، أو أُلغي اشتراكه وبقيت مفتوحة. + await this.invalidate(saved); + return saved; } /** @@ -59,13 +88,19 @@ export class TenantsService { // عبر resolve ليستفيد من الكاش — كل تطبيق يناديها عند الإقلاع. const t = await this.resolve(slug); if (!t) return null; + + // الاستحقاقات المحسوبة (افتراضات الباقة + المشتريات) لا `features` الخام: + // الخام يحمل التجاوزات فقط، فلو أُرجع لظهرت ميزات الباقة كأنها مطفأة. + const ent = await this.entitlements.forTenant(t.id); return { slug: t.slug, name: t.name, countryPack: t.countryPack, plan: t.plan, branding: t.branding, - features: t.features, + // ⚠️ للعرض فقط — الحدّ الأمني هو FeatureGuard على السيرفر (docs/19 K5). + features: ent?.features ?? {}, + limits: ent?.limits ?? {}, }; } } diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index 3c162f5..8942ad4 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -214,19 +214,25 @@ --- -## المجموعة K — الاستحقاقات ومنع تفعيل الميزات بلا اشتراك +## المجموعة K — الاستحقاقات ومنع تفعيل الميزات بلا اشتراك — ✅ منفَّذة > **المستند الكامل: [19-entitlements-licensing](19-entitlements-licensing.md).** > القاعدة: **علم الميزة في التطبيق قرار عرض لا حدّ أمني.** الحدّ الحقيقي حارس على السيرفر. -| # | البند | -|---|-------| -| K1 | `FeatureGuard` + `@RequiresFeature()` — الاستحقاقات من Redis والقاعدة احتياط | -| K2 | كتالوج الميزات + افتراضات لكل باقة | -| K3 | نقاط سوبر-أدمن: إنشاء مستأجر باشتراك · تعديل الاستحقاقات · إبطال الكاش | -| K4 | فرض الحدود العددية (`drivers_max`, `cities_max`) على السيرفر | -| K5 | `GET /tenant/config` — `features` للعرض فقط، موثّقة صراحةً أنها ليست حدّاً أمنياً | -| K6 | اختبار عزل في CI: مستأجر بلا ميزة يأخذ 403 على كل نقاطها | -| K7 | قرار وضع السيادة: كل الميزات مشمولة + بند تعاقدي (لا وهم حماية تقنية) | +| # | البند | الحالة | +|---|-------|--------| +| K1 | `FeatureGuard` + `@RequiresFeature()` — الاستحقاقات من Redis والقاعدة احتياط. الوحدة **عالمية** عمداً: إجبار كل وحدة على استيرادها يعني نقطة منسيّة يوماً ما، والمنسيّة ميزةٌ مجانية للجميع | ✅ | +| K2 | `common/entitlements/features.ts` — الكتالوج + افتراضات كل باقة. **الافتراض هو المنع**: قائمة بيضاء، وميزة جديدة تبقى محجوبة حتى تُمنح | ✅ | +| K3 | `GET /admin/features` · `GET /admin/tenants/:id/entitlements` · `PATCH /admin/tenants/:id/subscription` (يدمج `features` لا يستبدلها، ويُبطل الكاش فوراً) | ✅ | +| K4 | `drivers_max` يُفرض عند `drivers.apply` — عند الإنشاء فقط، فسائق قائم لا يُطرد بتغيير باقة | ✅ (`cities_max` معرَّف بلا نقطة إنفاذ بعد) | +| K5 | `GET /tenant/config` يرجع **الاستحقاقات المحسوبة** لا `features` الخام (الخام تجاوزات فقط، فكانت ستُظهر ميزات الباقة مطفأة) — موثّقة كعرض لا حماية | ✅ | +| K6 | `feature.guard.spec` + `features.spec` — منها اختبار أن الترويسة لا تزوّر المستأجر، وأن الحارس يقرأ التوكن وحده | ✅ | +| K7 | السيادة = كل الميزات بلا حدود، قراراً مقصوداً (لا وهم حماية تقنية) | ✅ | + +**نقاط محروسة الآن**: `dispatch` · `chat` · `payments/charge`. المدفوعات محروسة **لكل نقطة** لا على مستوى الصنف — الـwebhook يناديه المزوّد بلا JWT فكان حارس الصنف سيمنعه. + +**تغيير مقصود على الـseed**: المستأجر التجريبي `siro` صار `sovereign` بدل `brand`. سببان: يطابق قرار المالك («سيرو أول مشترك، له الباقة الكاملة»)، ويمنع حجب `dispatch` وحدّ `drivers_max: 500` من إسقاط سكربتات التحقق (كل تشغيلة تحميل تُنشئ عشرات السائقين وهم يتراكمون). + +**ما تبقّى للمجموعة K**: حارس دور المنصة على نقاط `admin/*` (مفتوحة حالياً — بند قائم قبل K أصلاً)، ونقطة إنفاذ لـ`cities_max`. ---