الأهم أولاً: `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>
12 KiB
24 — محفظة المستأجر وإيرادات المنصة (فلسفة المال)
قرارات المالك 2026-07-18. هذا المستند يحكم كل ما يتعلّق بحركة المال، ويسبق أي كود في المجموعة P. ذو صلة: 18 (الرصيد والعمولة) · 22 §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. الحل المُثبَت في سيرو ويُنقل:
- تطبيق أندرويد صغير على جهاز مخصّص يلتقط رسائل التأكيد الواردة من المزوّد.
- يرفعها خاماً إلى webhook عندنا (
raw_sms_log) — تُحفظ كما وصلت، لا تُحلَّل على الجهاز. - يُستخرج المبلغ والمرجع والمرسل (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.