feat: المجموعة K — استحقاقات الاشتراك مفروضة على السيرفر

القاعدة (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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 14:24:50 +03:00
co-authored by Claude Opus 4.8
parent d9b0faab2d
commit aae01fc4ee
15 changed files with 459 additions and 20 deletions
+3 -1
View File
@@ -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) {}
@@ -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) {}
@@ -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<Driver | null> {
@@ -33,6 +35,15 @@ export class DriversService {
data: Partial<Driver>,
): Promise<Driver> {
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,
@@ -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, {
@@ -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<Tenant>) {
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<string, any> },
) {
return this.tenants.updateSubscription(id, body);
}
}
+38 -3
View File
@@ -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<Tenant>,
private readonly cache: CacheService,
private readonly entitlements: EntitlementsService,
) {}
findAll(): Promise<Tenant[]> {
@@ -49,6 +51,33 @@ export class TenantsService {
/** يُبطل مفتاحَي المستأجر (بالـslug وبالـUUID) — يُنادى بعد أي تعديل عليه. */
async invalidate(tenant: Pick<Tenant, 'id' | 'slug'>): Promise<void> {
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<string, any> },
): Promise<Tenant> {
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 ?? {},
};
}
}