feat: implement rewards module with referral/coupon systems, update tariff seeding, and add trip distance service for improved billing accuracy.
This commit is contained in:
@@ -277,6 +277,67 @@
|
||||
|
||||
---
|
||||
|
||||
## المجموعة L — الإحالات والمكافآت والكوبونات — ✅ منفَّذة
|
||||
> قرار المالك 2026-07-18. **قانونان ثابتان يحكمان المجموعة كلها:**
|
||||
> 1. **محفظة الراكب لا تُسحب أبداً** — فمكافأة الراكب **كوبون خصم** لا رصيد محفظة. إنزالها في المحفظة كان يخلق التزاماً نقدياً قابلاً للسحب مقابل حملة تسويقية.
|
||||
> 2. **كل خصم مسقوف بعملته** — 25 جنيهاً في مصر · 25 ليرة سورية · 0.25 دينار (نفس نسبة `credit-policy`). السقف **حدّ أعلى مطلق** لا افتراض.
|
||||
|
||||
| # | البند | الحالة |
|
||||
|---|-------|--------|
|
||||
| L1 | `referral_codes` (كود شخصي ثابت) + `referrals` بحالات `pending → qualified → paid` | ✅ |
|
||||
| L2 | نوع قيد `referral_bonus` في `credit_txns` + `grantReferralBonus` بحارسَي J6 (فحص تطبيقي + فهرس فريد جزئي على `ref`) | ✅ |
|
||||
| L3 | محرّك الكوبونات: `coupons` + `coupon_redemptions`. يخصم من `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً (docs/18 §52) | ✅ |
|
||||
| L4 | **شرط الاستحقاق**: بعد أول رحلة مكتملة للمُحال لا عند التسجيل. `qualifying_trips` لا ينزل تحت 1 ولو ضبطه الأدمن صفراً | ✅ |
|
||||
| L5 | `RewardsSweeper` — نمط `ScheduledTripsSweeper` (setInterval + قفل `running`). **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل | ✅ |
|
||||
| L6 | `tenants.settings.rewards` بنفس بنية `credit-policy` — سقف الخصم ومكافآت الطرفين لكل عملة | ✅ |
|
||||
| L7 | ربط `fraud.service` — كشف حلقات الإحالة والأجهزة المكرّرة | ⏳ |
|
||||
| L8 | باقات الشحن (J5) فوق نفس المحرّك | ⏳ |
|
||||
|
||||
**اتجاهات الإحالة الأربعة ليست أربعة مسارات**: شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة — سائق ← رصيد تشغيلي، راكب ← كوبون. والدور يُحسم **وقت الصرف** من وجود صفّ سائق لا من الدور المخزَّن وقت التسجيل، فمن سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً لا كوبوناً.
|
||||
|
||||
**حرّاس الاحتيال (على القاعدة لا بالفحص التطبيقي وحده)**:
|
||||
- فريد على `(tenant_id, referee_user_id)` — الشخص يُحال **مرة واحدة** في عمره. بدونه تُعاد نفس الضحية تحت عشرة داعين.
|
||||
- `CHECK (referrer <> referee)` — إحالة النفس.
|
||||
- فريد جزئي على `credit_txns(tenant_id, ref) WHERE type='referral_bonus'` وعلى `coupons(tenant_id, ref)` — الماسح والصرف الفوري قد يلتقيان على إحالة واحدة.
|
||||
- فريد على `coupon_redemptions(tenant_id, trip_id)` — كوبون واحد لكل رحلة.
|
||||
|
||||
**درس مثبَّت باختبار**: المحاولة الأولى حسبت عتبة التأهّل داخل SQL (`CASE WHEN qualifying_trips + 1 >= n`)، فجُمع العمود **نصّاً** (`'0' + 1 = '01'`) ولم تتحقق الشرطية أبداً — كل إحالة تبقى معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى مستخدم. الحساب صار في TypeScript عبر `RETURNING`، والتزامن محفوظ بشرط الحالة في كلتا العبارتين.
|
||||
|
||||
**العمولة تُحتسب قبل خصم الكوبون** عمداً: الكوبون تنازل تسويقي من المنصة، فاحتسابها بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب ومرة بعمولة أنقص من السائق.
|
||||
|
||||
---
|
||||
|
||||
## المجموعة M — أساس التسعير: تعرفة الانطلاق والمسافة الحقيقية — ✅ منفَّذة
|
||||
> قرار المالك 2026-07-19. **الحدّ الأدنى للرحلة: 1.10 دينار · 20 جنيهاً · 150 ليرة** — وهذه المرساة التي تُشتقّ منها كل الأرقام الأخرى.
|
||||
|
||||
| # | البند | الحالة |
|
||||
|---|-------|--------|
|
||||
| M1 | `default-tariffs.ts` — مصدر حقيقة واحد يستهلكه `SeedService` ومولّد الـSQL معاً | ✅ |
|
||||
| M2 | `city = 'default'` بدل اسم البلد؛ البلد من `country_pack` للمستأجر | ✅ |
|
||||
| M3 | نوافذ التعرفة **بالتوقيت المحلي** (`timezone` في التعريف) عبر `Intl` — لا UTC ولا توقيت الخادم | ✅ |
|
||||
| M4 | فتحة العدّاد (`flag`) حقيقية في كل نافذة، ورسم حجز (`booking_fee`) | ✅ |
|
||||
| M5 | `fixed_quote` منفَّذ فعلاً — لا مسافة ولا زمن ولا حدّ أدنى؛ الانتظار وحده يُضاف | ✅ |
|
||||
| M6 | الفئات مشتقّة بمعاملات: أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP | ✅ |
|
||||
| M7 | `TripDistanceService` — المسافة من نبضات GPS مع تنقية الضجيج والقفزات | ✅ |
|
||||
| M8 | إعادة التسعير عند الإنهاء على المسافة المقطوعة فعلاً، بسقف `routed × 1.25` | ✅ |
|
||||
| M9 | زرع التعرفة **لكل فئة خدمة** لا الاقتصادي وحده | ✅ |
|
||||
| M10 | سماحية المسافة لكل مستأجر + ربط الانحراف بـ`fraud.service` | ⏳ |
|
||||
|
||||
**الأعطال التي أُصلحت (كلها كانت فشلاً صامتاً)**:
|
||||
- **رحلة مجانية**: السكربت القديم كتب `city='jordan'`، والكود يبحث بـ`'default'` → لا تعرفة → `quotedFare = null` → `settleFare` تأخذ `quoted_fare ?? 0` → **أجرة صفر وعمولة صفر**، بسطر تحذير واحد في اللوج.
|
||||
- **ذروة لا تُحصَّل أبداً**: `pickWindow` قرأ `getUTCHours()`، فذروة 16:00–20:00 تقع 19:00–23:00 في عمّان.
|
||||
- **الفان أرخص من الاقتصادي** في مصر وسوريا (22 مقابل 29.5 جنيهاً لنفس الرحلة) — أي أن الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
|
||||
- **حدّ أدنى ثلث الحقيقي**: `perKm × 2` = 0.368 ديناراً بدل 1.10.
|
||||
- **`fixed_quote` لم يكن ثابتاً**: كان يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه.
|
||||
- **السكربت لم يعمل أصلاً**: `require` في ملف ESM؛ ولا وجود لـ`seed-tariffs.sql` إطلاقاً.
|
||||
- **تشغيلتان = تعرفتان فعّالتان** بنفس النسخة، و`getActive` ترتّب `version DESC` فالاختيار بينهما غير محدَّد.
|
||||
|
||||
**سياسة المسافة (M7/M8)**: يُحاسَب **الأطول** من (المقاس، المقدَّر) بسقف `routed × 1.25`. التحويلة الحقيقية يقبضها السائق، والجولة المخترَعة تُقصّ وتُعلَّم للمراجعة لا تُخصم صامتة. والطريق الأقصر يُحاسَب بالأقصر — الراكب لا يدفع تقديراً لم يُقطع. نبضات أقل من 5 = رجوع للسعر المقفول: رقم مخترَع من الضجيج أسوأ من تقدير معقول.
|
||||
|
||||
**تصحيح على المجموعة L**: سقوف الكوبونات كانت مبنية على نسبة `credit-policy` القديمة (1:100:100)، وهي لا تطابق مرساة المالك (1.10 = 20 = 150، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة). السقف 25 جنيهاً صار يقابل **1.375 ديناراً و190 ليرة** بدل 0.25 و25 — النسخة الأولى كانت تعطي سوريا سُدس العرض.
|
||||
|
||||
---
|
||||
|
||||
## مؤجَّل عمداً (قرار المالك)
|
||||
المفاوض الذكي · تدرّج السائق · خصم العمولة — **آخر شيء** (جديدة حتى على سيرو).
|
||||
**Geofence** — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس.
|
||||
|
||||
Reference in New Issue
Block a user