الأهم أولاً: `PaymentsService.webhook()` كان يقبل أي جسم `{ payment_id }`
بلا أي تحقّق توقيع — من يعرف معرّف دفعة معلَّقة كان يستطيع تحويلها «ناجحة»
ويشحن رصيداً من عدم (محفظة راكب أو رصيد سائق تشغيلي). الآن كل تغيير حالة
محروس بـ`adapter.verifyWebhook(headers, payload, tenant)`، ولا شيء يُقرأ من
الحمولة قبل ذلك كقرار ثقة — قراءة المرجع لتحديد المستأجر ليست قراراً.
البنية (docs/07 · docs/24 — P1):
- `PaymentAdapter`: charge() يعيد instant (كاش) · redirect (بوابة API حقيقية)
· invoice (بلا API، تسوية عبر P2).
- PayMob (مصر): تسلسل auth→order→payment_key→iframe حقيقي عبر fetch،
وتحقّق HMAC-SHA512 على تسلسل حقول ثابت (بروتوكول PayMob الرسمي بالضبط)
بمقارنة ثابتة الزمن. مفاتيح كل مستأجر مستقلة — حساب تاجر خاص به.
- كليق/شام كاش/MTN/سيرياتيل/زين كاش: محوّل مشترك واحد لأن سلوكها متطابق
فعلياً في سيرو (`create_*_invoice.php` تُنشئ فاتورة فقط، لا نداء بوابة
حيّاً) — مرجع + حساب استلام معروض، والتسوية عبر رسالة SMS لا webhook.
MTN/سيرياتيل الحقيقيَّين (توكن+OTP) موثَّقان كبند مفتوح: لا نبني تكاملاً
لا نملك اعتماداً حيّاً للتحقّق منه.
- `PATCH /payments/settings` لأدمن المستأجر: مفاتيح PayMob · حسابات
الاستلام · سرّ webhook الرسائل — الاستجابة لا تُعيد الأسرار.
تنظيف: إزالة الإشارات المتبقّية لحاوية `martin` من docs/07 (أُزيلت فعلياً
سابقاً)، وتحديث هيكل الكود الموثَّق ليطابق ما هو مبنيّ فعلاً.
25 اختباراً جديداً (226 إجمالاً) — منها توقيع PayMob محسوب فعلياً ومُتحقَّق،
وتلاعبٌ بالحمولة بعد التوقيع يُرفض، وسبع حالات تثبت إغلاق ثغرة الـwebhook.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
110 lines
12 KiB
Markdown
110 lines
12 KiB
Markdown
# 24 — محفظة المستأجر وإيرادات المنصة (فلسفة المال)
|
||
|
||
> قرارات المالك 2026-07-18. هذا المستند يحكم **كل** ما يتعلّق بحركة المال، ويسبق أي كود في المجموعة P.
|
||
> ذو صلة: [18](18-driver-credit-commission.md) (الرصيد والعمولة) · [22](22-full-product-roadmap.md) §P · [[siro-reference]].
|
||
|
||
---
|
||
|
||
## 1. القاعدة الحاكمة: مالٌ لنا، ومالٌ نحتفظ به لغيرنا
|
||
|
||
الخطأ الذي يقع فيه أغلب التطبيقات — **وسيرو منها** — هو خلط النوعين في وعاء واحد، فيبدو الرصيد ضخماً بينما أغلبه دَيْن على المنصة لا ملكاً لها.
|
||
|
||
| النوع | مثاله | مِلك مَن؟ | في الميزانية |
|
||
|---|---|---|---|
|
||
| **إيراد** | شحن السائق لرصيده التشغيلي · عمولة كل عملية دفع | **المستأجر** (مالك التطبيق) | دخل مُحقَّق |
|
||
| **أمانة (pending)** | شحن الراكب لمحفظته · أجرة رحلة لم تُسوَّ بعد | **الراكب/السائق** | **التزام**، لا دخل |
|
||
|
||
> **المحصّلة العملية**: نقود الأمانة تمرّ عبر حسابنا لكنها ليست لنا — سنسلّمها للسائق أو نردّها للراكب. عدّها إيراداً يعني تقريراً ماليّاً كاذباً، وقراراً بالإنفاق من مالٍ ليس لنا.
|
||
|
||
## 2. محفظتا المستأجر
|
||
|
||
لكل مستأجر (مثال: `siro`) محفظتان منفصلتان — لا حقل واحد بحالتين:
|
||
|
||
| المحفظة | يدخلها | يخرج منها |
|
||
|---|---|---|
|
||
| **`tenant_wallet`** (محفظة المستأجر — «سيرو والت») | شحن الرصيد التشغيلي للسائقين · عمولة عمليات الدفع · عمولة الرحلات | سحب المالك لأرباحه |
|
||
| **`tenant_pending`** (أمانات) | شحن الراكب · أجرة محصَّلة لم تُسلَّم | تسوية للسائق · ردّ للراكب |
|
||
|
||
**لماذا محفظتان لا حقل `status`**: التقارير والسحب يقرآن `tenant_wallet` وحده. لو كان الفصل بحقلٍ في صفوف واحدة، فاستعلامٌ واحد ينسى شرط الحالة يجعل المالك يسحب من أموال الركّاب — وهذا خطأ يقع مرة واحدة ويكلّف ثقة السوق.
|
||
|
||
**دفتر مضاف فقط (append-only)** لكل محفظة: كل حركة صفٌّ جديد بمرجعها وسببها، لا تحديثٌ لرصيد. الرصيد مجموعٌ مشتق. (سيرو خزّن المال في `varchar(10)` وجمع نصوصاً — لا يُنقل هذا إطلاقاً.)
|
||
|
||
## 3. مسارات المال الثلاثة
|
||
|
||
### 3.1 شحن السائق رصيده التشغيلي ← **إيراد مباشر**
|
||
هذا هو نموذج العمل الأساسي (docs/18). السائق يدفع مقدَّماً ليعمل؛ المبلغ الحقيقي يصل → **`tenant_wallet`** فور نجاح الدفع. لا مرحلة أمانة: المال صار للمستأجر مقابل خدمة تشغيلية.
|
||
|
||
### 3.2 شحن الراكب محفظته ← **أمانة**
|
||
المال يدخل حسابنا لكنه **يبقى ملك الراكب** → **`tenant_pending`** + رصيد الراكب. حين يركب، يُخصم من رصيده وتُسوَّى الأجرة للسائق (ناقص العمولة). الجزء الوحيد الذي يتحوّل إلى إيراد هو **العمولة**.
|
||
|
||
### 3.3 عمولة عملية الدفع ← **إيراد**
|
||
رسم ثابت على **كل** عملية تحويل/دفع، محدَّد بالدولة (`countryPack`):
|
||
|
||
| الدولة | الرسم لكل عملية |
|
||
|---|---|
|
||
| سوريا | **35 ل.س** (بالعملة الجديدة) |
|
||
| مصر | **5 ج.م** |
|
||
| الأردن | **20 قرشاً** (0.20 د.أ) |
|
||
|
||
يُقتطع عند العملية ويُقيَّد في `tenant_wallet`. يُخزَّن **في إعداد المستأجر لا في الكود** — سيرو كتب الرقم مرتين في مكانين فتناقضا وتسرّب المال (docs/21).
|
||
|
||
## 4. جدول لكل وسيلة دفع + تقارير
|
||
|
||
نمط سيرو الصحيح: جدول مستقل لكل وسيلة (`cliq_invoices` · `invoices_shamcash` · `mtn_invoices` · `ecash_transactions` …) لأن لكل مزوّد حقولَه ومرجعَه وسلوكَ تأكيده. فوقها **محوّل موحّد** يعطي الباقي واجهة واحدة.
|
||
|
||
التقارير المطلوبة للوحة المستأجر: الدخل حسب الوسيلة · حسب اليوم · شحن السائقين مقابل الأمانات · العمولات المحصَّلة · المعلّق غير المسوّى.
|
||
|
||
## 5. الأسواق بلا API (كليك · شام كاش) — تسوية بالرسائل
|
||
|
||
كليك وشام كاش **لا توفّران API**. الحل المُثبَت في سيرو ويُنقل:
|
||
|
||
1. تطبيق أندرويد صغير على جهاز مخصّص يلتقط رسائل التأكيد الواردة من المزوّد.
|
||
2. يرفعها **خاماً** إلى webhook عندنا (`raw_sms_log`) — تُحفظ كما وصلت، لا تُحلَّل على الجهاز.
|
||
3. يُستخرج المبلغ والمرجع والمرسل (**Gemini** كما في `process_with_gemini.php`) ثم تُطابَق مع فاتورة معلّقة وتُسوَّى المحفظة.
|
||
|
||
**شروط لا تُتنازل عنها**: الرسالة الخام تُحفظ دائماً (أثر للنزاع) · المطابقة **مرة واحدة** بمفتاح تفرّد على مرجع العملية (وإلا سوّت رسالةٌ مكرّرة الفاتورة مرتين) · وما لا يُطابَق آلياً يذهب لطابور مراجعة بشرية لا يُهمَل بصمت.
|
||
|
||
## 6. ما لا يُنقل من سيرو (مُدقَّق — docs/21)
|
||
|
||
- **IDOR في `request_payout.php`**: هوية السائق من الطلب لا من التوكن → سحبُ رصيد الغير. (Tripz سليم: من التوكن.)
|
||
- **بلا حجز رصيد عند طلب السحب** → طلبات متزامنة تمرّ كلها = صرفٌ مزدوج.
|
||
- **رسم 3500 متناقض**: الفحص يشترط `amount + 3500` والتسوية تخصم `amount − 3500` → تسريب مال.
|
||
- **`finalizePayout` خمس كتابات بلا معاملة** → فشلٌ في المنتصف يترك سحباً نصف مسوّى.
|
||
- **المال في `varchar(10)`**.
|
||
- **جدولان متداخلان** لنفس المفهوم (`payout_requests` و`driver_withdrawal_requests`).
|
||
|
||
## 6.5 حالة التنفيذ (2026-07-18)
|
||
|
||
**منجَز (P0 · P3 · التوجيه · P5):**
|
||
- `tenant_revenue_ledger` و`tenant_pending_ledger` — جدولان مضافان فقط بهجرة `1721930000000`، وفهرس فريد على `(tenant_id, ref)` يمنع التسوية المزدوجة.
|
||
- `TenantWalletService`: `creditRevenue` · `creditPending` · `releasePending` · `balance` · `summary` · `revenueByReason`. التكرار يُلتقط من القاعدة لا بالفحص المسبق وحده (نداءان متزامنان يمرّان معاً قبل أن يكتب أيّهما).
|
||
- `transactionFeeFor` — مصدر واحد للرقم، و`cappedFee` يمنع صافياً سالباً حين يفوق الرسمُ المبلغَ.
|
||
- توجيه الدفع في `markSuccess`: `credit_topup` → إيراد + رصيد السائق · `topup` → أمانة + محفظة الراكب · الرسم → إيراد دائماً.
|
||
- **`credit_topup` للسائقين فقط** — الدور من التوكن لا من الجسم. بدونه يرسل راكبٌ `credit_topup` فيُسجَّل مالُه إيراداً محقَّقاً ويُشحن حساب سائق لا يملكه.
|
||
- `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن).
|
||
- `/admin/overview` يفصل `trip_commission` عن `revenue` الكلي.
|
||
|
||
**منجَز أيضاً (P2 — التسوية بالرسائل):**
|
||
- `tripz_pay_raw_sms`: الرسالة تُحفظ **خاماً وقبل أي تحليل**، وفهرس فريد على بصمة المحتوى يمنع معالجة إعادة الإرسال مرتين.
|
||
- `POST /payments/sms/webhook/:tenantSlug` — بلا JWT (الرافع جهاز)، محميّ بسرّ لكل مستأجر (`settings.payments.sms_webhook_secret`) بمقارنة **ثابتة الزمن**؛ ومستأجر بلا سرّ = النقطة **مغلقة** لا مفتوحة.
|
||
- `GeminiService.extractTransferSms` بحرارة صفر وتعليمات صريحة بإرجاع `null` عند عدم اليقين — النموذج الذي يخمّن مبلغاً يسوّي فاتورة بمال لم يصل.
|
||
- المطابقة: المرجع أولاً، ثم المبلغ ضمن 24 ساعة و**بشرط فاتورة وحيدة**؛ فاتورتان بنفس المبلغ = التباس يُراجَع بشرياً.
|
||
- التسوية تمرّ بـ`settleFromSms` → `markSuccess` نفسه، فلا مسار مالي ثانٍ يتفرّع ويتناقض.
|
||
- `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي.
|
||
- 13 اختباراً تغطّي التزوير والتكرار والتخمين والالتباس.
|
||
|
||
**منجَز أيضاً (P1 — البوابات الفعلية):**
|
||
- **الثغرة الأخطر في P كلها أُغلقت**: `webhook()` كان يقبل أي جسم `{ payment_id }` بلا أي تحقّق — من يعرف معرّف دفعة معلَّقة يحوّلها «ناجحة» ويشحن رصيداً من عدم. الآن كل تغيير حالة محروس بـ`adapter.verifyWebhook(headers, payload, tenant)`، ولا قراءة قبله سوى استخراج المرجع لتحديد أي مستأجر (قراءة بحتة، لا قرار ثقة).
|
||
- `PaymentAdapter` (docs/07): `charge()` يعيد `instant` (كاش) · `redirect` (بوابة API حقيقية) · `invoice` (بلا API — مرجع + حساب استلام، تسوية عبر P2).
|
||
- **PayMob** (مصر) — البوابة الوحيدة بـAPI فعلية بين مزوّدي المنطقة: تسلسل auth→order→payment_key→iframe حقيقي، وتحقّق HMAC-SHA512 على تسلسل حقول ثابت (بروتوكول PayMob الرسمي، لا اختراعاً) بمقارنة **ثابتة الزمن**. مفاتيح كل مستأجر في `settings.payments.paymob` — حساب تاجر مستقل لكل مستأجر، فمالُه يصل لحسابه هو.
|
||
- **كليك · شام كاش · MTN · سيرياتيل · زين كاش** — محوّل واحد مشترك (`InvoiceAdapter`): بلا API فعلية اليوم (مطابق لما هو مطبَّق فعلاً في `payment_server` بسيرو)، يولّد مرجعاً ويعرض حساب الاستلام المضبوط في `settings.payments.transfer_targets`، والتسوية عبر رسالة SMS (P2) لا webhook من المزوّد.
|
||
- ⚠️ MTN وسيرياتيل يملكان API إنتاجية حقيقية (توكن/OTP/مفتاح خاص) في سيرو، لكنها تتطلّب بيانات اعتماد حيّة لا نملكها لاختبارها فعلياً — **لن نبني تكاملاً لا يمكن التحقّق منه**. بند مفتوح يُستأنف عند توفّر حساب تجريبي حقيقي.
|
||
- `PATCH /payments/settings` (أدمن المستأجر): يضبط مفاتيح PayMob · حسابات الاستلام · سرّ webhook الرسائل. الاستجابة لا تُعيد الأسرار — "مضبوط/غير مضبوط" فقط.
|
||
- 25 اختباراً جديداً (منها توقيعٌ حقيقي مُتحقَّق ومُتلاعَبٌ به عمداً بعد التوقيع، وسبع حالات تثبت إغلاق ثغرة الـwebhook تحديداً).
|
||
|
||
**الباقي**: P4 (تقارير أوسع) · سحب المالك أرباحه من `tenant_wallet` · MTN/سيرياتيل الحقيقيَّين عند توفّر اعتماد.
|
||
|
||
## 7. أثره على ما هو مبنيّ الآن
|
||
|
||
`GET /admin/overview` يحسب `revenue` = مجموع عمولة الرحلات فقط. بعد هذا المستند يصير الإيراد الحقيقي = **عمولة الرحلات + شحن السائقين + عمولات عمليات الدفع**، والأمانات تُستثنى صراحةً. النقطة تحتاج تحديثاً ضمن P.
|