docs: نموذج الرصيد التشغيلي (18) والاستحقاقات (19) + تصحيحان على B

18 — الرصيد التشغيلي: السائق يشحن سلفاً، الراكب يدفع له الأجرة كاملة،
والعمولة تُخصم من الرصيد لا من الأجرة. مقارنة صريحة: هذا نموذج inDrive/
Yango/سيرو لا نموذج أوبر — وهو المعيار الفعلي في أسواق الكاش المستهدفة.
ثمنه ثلاثة التزامات: الحجب عند نفاد الرصيد (بدونه ينهار)، باقات لكل عملة،
ومعالجة حاجز دخول السائق الجديد.

19 — الاستحقاقات: علم الميزة في التطبيق قرار عرض لا حدّ أمني. الهندسة
العكسية تكشف شاشة تنادي نقطة ترجع 403. الحدّ الحقيقي FeatureGuard على
السيرفر + tenant_id من JWT موقَّع. الحقيقة التي تُقال صراحةً: ما يعمل كلياً
على الجهاز لا يُحمى، ووضع السيادة لا يُحمى تقنياً أصلاً (المستأجر يملك
السيرفر) — فليشمل سعره كل شيء بدل وهم حماية.

تصحيحان على المجموعة B (كلاهما بُني على افتراض خاطئ مني):
- B3: مشوار الوصول تعويضُ عدم حضور عند الإلغاء بعد 5 دقائق انتظار،
  لا بند في كل أجرة كما بنيته
- B6: price_for_driver = price_for_passenger — العمولة لا تُقتطع من الأجرة

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 13:32:32 +03:00
co-authored by Claude Opus 4.8
parent f3fdc1bc20
commit 2edd8f6916
3 changed files with 278 additions and 3 deletions
+51 -3
View File
@@ -29,7 +29,7 @@
|---|-------|---------|--------|
| B1 | **حالة `started` وفصل الوصول عن البدء** | — | ✅ **كان منفَّذاً أصلاً**: `driver_arrived` و`in_progress` حالتان منفصلتان في آلة الحالة منذ P1. الناقص كان الطوابع والانتظار (B2/B7) لا الحالة نفسها. |
| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند `driver_arrived`، ويُحتسب عند `in_progress`. الدقائق المجانية `free_waiting_min` (5 افتراضاً) قابلة للضبط لكل تعرفة، والزائد بسعر `per_min_waiting` للنافذة الفعّالة. | ✅ `waiting_min` + `waiting_charge` |
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. **لا يُحتسب على الراكب ما لم يُفعِّله المالك** (`pickup_leg.charge`) — تفعيله يرفع كل أجرة، فهو قرار لا افتراض صامت. | ✅ `pickup_distance_km`/`_duration_min`/`_charge` |
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. | 🟡 **القياس ✅ والقاعدة ❌** — راجع التصحيح أدناه |
| B6 | **فصل السعر** | `price_for_passenger` · `price_for_driver` · `commission_amount` · `commission_rate`. العمولة تُعرَّف في التعرفة: `commission: { percent, flat, min }`، ولا تتجاوز الأجرة أبداً. | ✅ + **تسوية المحفظة صُحّحت**: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع |
| B7 | **طوابع زمنية دقيقة** | `driver_going_at` (`DriverIsGoingToPassenger`) · `arrived_at` · `started_at` (`rideTimeStart`) · `completed_at` (`rideTimeFinish`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها |
| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | ⏳ الشريحة التالية |
@@ -39,8 +39,18 @@
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
**الأجرة النهائية الآن** = السعر المقفول + رسم الانتظار الزائد + مشوار الوصول (إن فُعِّل)، ثم تُفصل: الراكب يدفع `price_for_passenger`، والسائق يقبض `price_for_driver`، والفرق عمولة.
**معلَّق على I2**: العمولة محسوبة ومسجّلة على الرحلة لكنها **لا تُقيَّد في محفظة المنصة** — لأن محفظة المنصة (مكافئ `siroWallet`) لم تُبنَ بعد.
### 🔴 تصحيحان لازمان على الشريحة الأولى (توضيح المالك 2026-07-17)
بنيتُ B3 وB6 على افتراضات خاطئة. الصواب:
**1. مشوار الوصول ليس بنداً في كل أجرة — بل تعويض عن عدم حضور الراكب.**
> «مشوار الوصول يُحسب إذا انتظر السائق خمس دقائق وأُلغيت الرحلة بعد الخمس دقائق.»
فالقاعدة: يُحتسب **فقط** عند الإلغاء من حالة `driver_arrived` بعد انقضاء الدقائق المجانية. بنيتُه كمفتاح تعرفة يُضاف لكل رحلة (`pickup_leg.charge`) — **خطأ**. المطلوب: يحلّ محلّ رسم الإلغاء الثابت في تلك الحالة، ويذهب **للسائق** تعويضاً.
**2. العمولة لا تُقتطع من أجرة السائق إطلاقاً** — راجع **[18-driver-credit-commission](18-driver-credit-commission.md)**.
`price_for_driver = price_for_passenger` (السائق يقبض كاملاً)، والعمولة تُخصم من **رصيد تشغيلي مدفوع سلفاً**. ما نُفِّذ (`price_for_driver = fare − commission`) نموذجُ أوبر، لا نموذجنا.
**الأجرة النهائية بعد التصحيح** = السعر المقفول + رسم الانتظار الزائد. ومشوار الوصول يظهر عند الإلغاء فقط.
---
@@ -179,6 +189,44 @@
---
---
## المجموعة J — الرصيد التشغيلي وعمولة السائق 🔴 يسبق I2
> **المستند الكامل: [18-driver-credit-commission](18-driver-credit-commission.md).**
> السائق يشحن رصيداً تشغيلياً سلفاً · الراكب يدفع له الأجرة كاملة · العمولة تُخصم من الرصيد لا من الأجرة.
> نموذج معتمد عالمياً في أسواق الكاش (inDrive · Yango · سيرو) — وليس نموذج أوبر.
| # | البند |
|---|-------|
| J1 | جدول `driver_credit` + دفتر قيود — خصم ذرّي داخل معاملة (نمط I1 حرفياً) |
| J2 | خصم العمولة عند `completed` من الرصيد التشغيلي (لا من الأجرة) |
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger` |
| J4 | **الحجب عند نفاد الرصيد** — بدونه ينهار النموذج (سائق برصيد صفر يعمل مجاناً للأبد) |
| J5 | باقات شحن لكل عملة + الحافز كقيد منفصل |
| J6 | رصيد ترحيبي / فترة سماح للسائق الجديد (حاجز الدخول) |
| J7 | أرضية سالبة محدودة لسباق «أنهى ورصيده لا يكفي» |
| J8 | نقاط: رصيدي · تاريخ الخصومات · الشحن |
> **أثره على I2**: محفظة المنصة لم تعد تستقبل عمولة من كل رحلة — بل تستقبل **مدفوعات الشحن**. هذا يبسّط I2 كثيراً.
---
## المجموعة 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 | قرار وضع السيادة: كل الميزات مشمولة + بند تعاقدي (لا وهم حماية تقنية) |
---
## مؤجَّل عمداً (قرار المالك)
المفاوض الذكي · تدرّج السائق · خصم العمولة — **آخر شيء** (جديدة حتى على سيرو).
**Geofence** — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس.
+116
View File
@@ -0,0 +1,116 @@
# 18 — الرصيد التشغيلي وعمولة السائق (نموذج الدفع المسبق)
> **طبقتان مختلفتان لا تُخلطان:**
> - **[05-pricing-billing](05-pricing-billing.md)** = كيف تقبض **Tripz** من المستأجر (اشتراك + نسبة GMV).
> - **هذا الملف** = كيف يقبض **المستأجر** (سيرو مثلاً) عمولته من **سائقيه**.
>
> مصدره: قرار المالك (2026-07-17) — «فلسفة التطبيق اللي بدنا إياه».
---
## 1. الفكرة في سطر واحد
السائق **يشتري رصيداً تشغيلياً مقدَّماً**. الراكب يدفع للسائق **الأجرة كاملة** (كاش أو محفظة). المنصة **لا تلمس أجرة الرحلة إطلاقاً** — بل تخصم عمولتها من الرصيد التشغيلي المدفوع سلفاً.
## 2. المثال المرجعي (كلام المالك حرفياً)
```
السائق يشحن رصيده التشغيلي → 4.000 دينار
الراكب يصل ويدفع للسائق (كاش/محفظة) → 4.000 دينار ← يأخذها السائق كاملة
عمولة التطبيق 10% من 4.000 → 0.400 دينار
تُخصم من الرصيد التشغيلي → 4.000 - 0.400 = 3.600 دينار
```
**الرصيد التشغيلي بعد الرحلة = 3.600 دينار.** أجرة الراكب لم تُمسّ.
> ملاحظة على الترقيم: الـ«4 دنانير» في المثال صدفة — رصيد الشحن وأجرة الرحلة رقمان مستقلان تماماً. لو شحن السائق 50 ديناراً وأنهى نفس الرحلة لصار رصيده 49.600.
## 3. هل هذا نموذج متعارف عليه عالمياً؟ — **نعم، لكنه ليس نموذج أوبر**
هناك نموذجان سائدان، وكلاهما شرعي:
| | **اقتطاع من الأرباح (take-rate)** | **الرصيد المدفوع مسبقاً (prepaid credit)** ← نموذجنا |
|---|---|---|
| **من يقبض المال؟** | المنصة تقبض الأجرة ثم تحوّل للسائق الصافي | السائق يقبض الأجرة كاملة مباشرة |
| **من يستعمله** | Uber · Bolt · Careem · Grab · Didi | **inDrive** · Yango/Yandex (شركاء) · Maxim · أغلب التطبيقات المحلية في الشرق الأوسط وأفريقيا وجنوب آسيا · **سيرو** |
| **يناسب** | أسواق الدفع الإلكتروني الغالب | **أسواق الكاش الغالب** |
| **مشكلة الكاش** | السائق يصير **مديناً** للمنصة → تحصيل ومطاردة وديون معدومة | **لا توجد** — العمولة مقبوضة سلفاً |
| **التدفق النقدي** | متأخر | **مقدَّم** |
| **حاجز الدخول للسائق** | صفر | يدفع قبل أن يكسب ← يحتاج معالجة |
**الحكم:** النموذج الذي تصفه هو **المعيار الفعلي في الأسواق التي تستهدفها** (سوريا · الأردن · مصر) حيث الكاش هو الغالب. أوبر نفسها في الأسواق كثيفة الكاش تضطر لتتبّع **رصيد سائق سالب** ثم مطاردة تحصيله — وهو بالضبط الوجع الذي يلغيه نموذجك من أصله. اختيارك سليم، وليس نسخاً أعمى عن سيرو.
**لكن ثمنه ثلاثة التزامات** (تفصيلها في §5): الحجب عند نفاد الرصيد، وباقات شحن لكل عملة، ومعالجة حاجز دخول السائق الجديد.
## 4. ما الذي يتغيّر في الكود (تصحيح لما نُفِّذ في B6)
الشريحة الأولى من المجموعة B (commit `f3fdc1b`) نُفِّذت على **نموذج الاقتطاع**:
```ts
price_for_driver = price_for_passenger - commission // ❌ ليس فلسفتنا
```
الصحيح حسب هذا المستند:
```ts
price_for_driver = price_for_passenger // السائق يأخذ الأجرة كاملة
commission_amount = split(price_for_passenger) // تُحسب وتُسجَّل على الرحلة
→ ثم تُخصم من driver_credit (رصيد منفصل تماماً)، لا من الأجرة
```
**`price_for_driver` و`price_for_passenger` يبقيان مفيدين** — لكن سبب اختلافهما ليس العمولة، بل: خصم على الراكب (كوبون/عرض) بينما السائق يقبض كاملاً، أو مكافأة تفاوض (`ai_negotiated_bonus` عند سيرو).
## 5. الآليات المطلوبة
### 5.1 الرصيد التشغيلي (`driver_credit`)
- محفظة **ثانية منفصلة** عن محفظة أرباح السائق. لا تُخلط: هذه للعمولة سلفاً، وتلك لأمواله.
- دفتر قيود append-only + خصم ذرّي — **نفس نمط I1 حرفياً** (`UPDATE … WHERE balance >= :amount`).
- الخصم يحدث عند `completed` (لا عند `paid`): الرحلة تمّت فالعمولة استُحقّت، بغضّ النظر عن وسيلة الدفع.
### 5.2 الحجب عند نفاد الرصيد — **الالتزام الأهم**
بلا حجب، النموذج **ينهار**: سائق برصيد صفر يعمل مجاناً إلى الأبد.
```
driver_credit < min_balance → لا يدخل فهرس geo:drivers:available
→ لا تُعرض عليه رحلات
→ إشعار: «اشحن رصيدك التشغيلي»
```
- الفحص عند: `setOnline` وقبل `accept` وبعد كل خصم.
- `min_balance` لكل مستأجر/عملة (قد يكون صفراً أو أعلى من أغلى عمولة متوقعة).
- **سباق حقيقي**: رحلة تُنهى ورصيده لا يكفي العمولة → نسمح بأرضية سالبة محدودة (`credit_floor`، مثلاً −1 عمولة) ثم نحجبه فوراً. البديل (رفض الخصم) يعني عمولة ضائعة.
### 5.3 باقات الشحن لكل عملة
كما في سيرو — لكل عملة باقاتها وأسعارها:
```
JOD: [5, 10, 25, 50]
SYP: [50k, 100k, 250k]
EGP: [100, 250, 500]
```
- الشحن عبر **[07-integrations](07-integrations.md)** وطرق الدفع لكل دولة (كليك · شام كاش · إي كاش · بيموب · فوري …).
- باقة قد تحمل **حافزاً**: «اشحن 50 واحصل على 55» — أداة تسويق مباشرة بيد المستأجر.
- الحافز يُسجَّل قيداً منفصلاً في الدفتر (`bonus`) لا يُخلط بالمدفوع فعلاً — وإلا فسدت المحاسبة.
### 5.4 حاجز دخول السائق الجديد
النموذج يطلب من السائق أن يدفع **قبل** أن يكسب — وهذا يقتل التجنيد.
المعالجات (قرار مالك، لا افتراض تقني):
- **رصيد ترحيبي** (مثلاً 5 دنانير مجاناً) — الأشيع.
- **فترة سماح** (أول 7 أيام أو أول 20 رحلة بلا خصم).
- **أرضية سالبة أوسع للسائق الجديد** ثم تُشدَّد.
### 5.5 وسيلة الدفع لا تغيّر شيئاً
| الدفع | من يقبض الأجرة | من أين العمولة |
|---|---|---|
| كاش | السائق مباشرة | الرصيد التشغيلي |
| محفظة الراكب | السائق (تُحوَّل لمحفظة أرباحه) | الرصيد التشغيلي |
**قاعدة واحدة لا استثناء لها** — وهذا أبسط بكثير من تفريع المنطق حسب وسيلة الدفع، وهو ما قاله المالك حرفياً: «إن كانت كاش أو محفظة».
> **مفارقة تستحق الانتباه:** في الدفع بالمحفظة، المنصة **تلمس المال فعلاً**، فتستطيع تقنياً اقتطاع العمولة منه. لكننا **لا نفعل** — عمداً. لأن قاعدتين مختلفتين حسب وسيلة الدفع تعني منطقاً مزدوجاً، ومحاسبةً مزدوجة، وسائقاً لا يفهم لماذا اختلف دخله. الاتساق أثمن من التحسين هنا.
## 6. لماذا تُحسب العمولة على الرحلة رغم أنها تُخصم من مكان آخر؟
لأن `commission_amount` على الرحلة هو **سبب** القيد في دفتر الرصيد التشغيلي. بدونه لا يستطيع السائق (ولا خدمة العملاء) الإجابة عن: «لماذا نقص رصيدي 400 فلس؟». كل خصم يشير إلى `trip_id`.
## 7. البنود المطلوبة (تُضاف للـ[17-backend-backlog](17-backend-backlog.md) كمجموعة J)
| # | البند |
|---|-------|
| J1 | جدول `driver_credit` + دفتر قيوده — خصم ذرّي داخل معاملة (نمط I1) |
| J2 | خصم العمولة من الرصيد التشغيلي عند `completed` (لا من الأجرة) |
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger` (السائق يقبض كاملاً) |
| J4 | الحجب عند نفاد الرصيد: `setOnline` + `accept` + بعد كل خصم |
| J5 | باقات الشحن لكل عملة + الحافز كقيد منفصل |
| J6 | رصيد ترحيبي / فترة سماح للسائق الجديد |
| J7 | أرضية سالبة محدودة (`credit_floor`) لسباق «أنهى الرحلة ورصيده لا يكفي» |
| J8 | شاشة/نقاط: رصيدي · تاريخ الخصومات · الشحن |
---
← ذو صلة: [05-pricing-billing](05-pricing-billing.md) · [17-backend-backlog](17-backend-backlog.md) · [19-entitlements-licensing](19-entitlements-licensing.md)
+111
View File
@@ -0,0 +1,111 @@
# 19 — الاستحقاقات (Entitlements) ومنع تفعيل الميزات بلا اشتراك
> سؤال المالك (2026-07-17): «إذا نزّلنا التطبيق بشكل كامل، وجاء المشترك وعمل **reverse engineering** وفعّل الإضافات — كيف نحدّ منها؟»
---
## 1. الجواب في سطر واحد
**لا تُفعَّل الميزة في التطبيق أبداً. تُفعَّل في السيرفر.**
التطبيق لا يملك ما يُسرَق: هو يعرض واجهة فقط. الميزة الحقيقية = نقاط API تحرسها المنصة.
## 2. القاعدة الحاكمة
> **علم الميزة في التطبيق (feature flag) هو قرار *عرض*، وليس حدّاً أمنياً — أبداً.**
`GET /tenant/config` يرجع `features` كي يعرف التطبيق **ماذا يُظهر**. لو عدّل أحدهم الاستجابة أو فكّك التطبيق وفعّل كل الأعلام، فالنتيجة:
```
المهاجم يفعّل ميزة «البوتات» في التطبيق
→ تظهر له الشاشة والأزرار ✅ (لا ضرر — بكسلات فقط)
→ يضغط زراً → POST /bots/campaigns
→ الحارس يقرأ tenant_id من الـJWT الموقَّع
→ يسأل: هل اشتراك هذا المستأجر يشمل bots؟ → لا
→ 403 Forbidden ❌ لا شيء حدث
```
**كسب المهاجم: شاشة فارغة.** هذا هو المطلوب بالضبط.
## 3. لماذا لا يستطيع تزوير `tenant_id`؟
- `tenant_id` يأتي من **JWT موقَّع بمفتاح السيرفر**، لا من هيدر يكتبه العميل.
- الترويسة `x-tenant-id` تُستعمل **قبل الدخول فقط** (اختيار المستأجر) — وبعد الدخول الحقيقة من التوكن.
- تزوير التوكن يحتاج `JWT_SECRET` — وهو على السيرفر لا في التطبيق.
## 4. التطبيق العملي: حارس استحقاقات
```ts
@RequiresFeature('bots') // ← الحدّ الأمني الحقيقي
@Post('bots/campaigns')
create(@CurrentUser() user: AuthUser, @Body() body: any) { … }
```
`FeatureGuard`:
1. يقرأ `tenant_id` من التوكن.
2. يجلب استحقاقات المستأجر — **من Redis خط أول** (نمط G4)، والقاعدة احتياط.
3. غير مستحقّة → `403` برسالة واضحة (`feature_not_in_plan`) ليعرضها التطبيق بلطف.
**كل نقطة تخصّ ميزة مدفوعة تحمل هذا الحارس. بلا استثناء واحد.**
نقطة واحدة منسيّة = الميزة مجانية للجميع. لذلك: **الافتراض هو المنع** — قائمة بيضاء لا سوداء.
## 5. الحد الذي لا يُتجاوز تقنياً
> **ما يعمل كلياً على الجهاز بلا نداء سيرفر — لا يمكن حمايته. نهائياً.**
لا تشويش (obfuscation) ولا تشفير ولا فحص جذر يغيّر هذه الحقيقة؛ كلها ترفع الكلفة ولا تمنع. لذلك القاعدة المعمارية:
> **كل ميزة ذات قيمة تجارية يجب أن تمرّ بالسيرفر — ولو لم تحتج ذلك تقنياً.**
مثال: «الاستخبار السوقي» لو حُسب في التطبيق من بيانات محليّة = مسروق بلا حيلة. ولو كان `POST /market-intel/report` = محميّ تماماً. **هذا قرار تصميم يُتخذ عند بناء كل ميزة، لا ترقيع بعدها.**
## 6. الثغرة الحقيقية: وضع السيادة (Sovereign)
هنا الخبر الذي يجب أن يُقال صراحةً:
**في وضع السيادة ([06-tenant-model](06-tenant-model.md)) — المستأجر يشغّل نسختنا على خوادمه. عنده الكود والقاعدة والسيرفر. لا يوجد حارس يحرس ضدّه، لأنه هو صاحب الحارس.**
يستطيع تعديل `FeatureGuard` ليرجع `true` دائماً. لا حلّ تقنيّ كامل. الخيارات الواقعية:
| الخيار | الجدوى |
|--------|--------|
| **السيادة = كل الميزات، بسعرها** | ✅ **الموصى به.** لا شيء يُسرق لأن لا شيء محجوب. يطابق أصلاً كون «سيادة» أعلى الباقات ($599 + $12k إعداد). |
| **مفتاح ترخيص موقَّع + اتصال دوري بالـControl Plane** | 🟡 يردع غير التقني، ويُنزع بتعديل الكود. مفيد للكشف والتوثيق العقدي لا للمنع. |
| **مكوّن حرج يبقى عندنا (SaaS جزئي)** | 🟡 فعّال لكنه يناقض وعد السيادة نفسه (البيانات داخل الدولة). |
| **العقد والقانون** | ✅ الحدّ الحقيقي في هذا الوضع. تدقيق + بند جزائي. |
**التوصية المعمارية:** في الوضع المشترك (انطلاقة/علامة/أسطول+) الحماية **تقنية وكاملة**. في وضع السيادة الحماية **تعاقدية**، فليشمل سعرُها كلَّ شيء ولا نتظاهر بحمايةٍ لا نملكها.
## 7. تدفّق السوبر-أدمن: اشتراك ← استحقاقات
```
سوبر-أدمن ينشئ مستأجراً
→ يختار الباقة (launch | brand | fleet | sovereign)
→ يختار country_pack (jo | sy | eg)
→ الباقة تولّد استحقاقات افتراضية
→ + إضافات مشتراة منفردة (market_intel, bots, ads, transit…)
→ تُحفظ في tenants.features (jsonb — موجود أصلاً في الكيان)
→ تُبطَل من كاش Redis فوراً (نمط G4)
```
- **الميزة تُشترى منفردة** فوق الباقة — لذا `features` ليست اشتقاقاً من `plan` بل قائمة صريحة. `plan` يعطي الافتراضات، و`features` هي الحقيقة.
- تغيير الاشتراك = تغيير `features` + إبطال الكاش → يسري خلال ثوانٍ **بلا تحديث تطبيق**.
## 8. شكل الاستحقاقات
```jsonc
// tenants.features
{
"dispatch": true,
"wallet": true,
"bots": false, // ← لم يشترِها
"market_intel": false,
"transit": false,
"ads": false,
"limits": { "drivers_max": 500, "cities_max": 3 }
}
```
- **الحدود العددية** (`limits`) تُفرض على السيرفر أيضاً — سائق رقم 501 يُرفض بـ403.
- الافتراض الصلب: **مفتاح غير موجود = ممنوع** (لا مسموح).
## 9. البنود المطلوبة (مجموعة K في الـ[17-backend-backlog](17-backend-backlog.md))
| # | البند |
|---|-------|
| K1 | `FeatureGuard` + `@RequiresFeature()` — استحقاقات من Redis، القاعدة احتياط |
| K2 | كتالوج الميزات + استحقاقات افتراضية لكل باقة |
| K3 | نقاط سوبر-أدمن: إنشاء مستأجر باشتراك، تعديل الاستحقاقات، إبطال الكاش |
| K4 | فرض الحدود العددية (`drivers_max`, `cities_max`) على السيرفر |
| K5 | `GET /tenant/config` يرجع `features` **للعرض فقط** — موثّق صراحةً أنه ليس حدّاً أمنياً |
| K6 | اختبار عزل في CI: مستأجر بلا ميزة يأخذ 403 على كل نقاطها |
| K7 | قرار وضع السيادة: كل الميزات مشمولة + بند تعاقدي (لا وهم حماية تقنية) |
---
← ذو صلة: [06-tenant-model](06-tenant-model.md) · [05-pricing-billing](05-pricing-billing.md) · [18-driver-credit-commission](18-driver-credit-commission.md)