Files
SS/WALLET.md
T

189 lines
12 KiB
Markdown

# WALLET.md — تفصيل نظام التسجيل والمحفظة (SMM Panel)
> ملف مكمّل لـ `AGENTS.md`. يركّز حصراً على جزئين حسّاسين: **نظام تسجيل الدخول/الحساب**
> و**صحة المحفظة (Wallet Integrity)** — أي ضمان أن كل ريال/دولار يدخل أو يخرج من رصيد
> المستخدم محسوب صح، موثّق، وغير قابل للتلاعب أو التكرار.
---
## 1. نظام الحساب (Auth) بالتفصيل
### 1.1 التسجيل (Register)
```
POST /auth/register
Body: { username, email, password }
```
- تحقق من صيغة الإيميل، وأن `username` و `email` غير مستخدمين (UNIQUE constraint + تحقق على مستوى التطبيق).
- كلمة المرور: حد أدنى 8 خانات، تُشفّر فوراً بـ `bcrypt` (salt rounds ≥ 10) — **لا تُخزَّن ولا تُطبع باللوق أبداً**.
- عند الإنشاء: `balance = 0.0000` صراحةً (لا تترك القيمة الافتراضية ضمنية في الكود).
- (اختياري لكن يُفضّل): إرسال إيميل تفعيل قبل السماح بالشحن أو الشراء، لتقليل الحسابات الوهمية.
### 1.2 الدخول (Login)
```
POST /auth/login
Body: { email, password }
```
- قارن الباسورد بـ `bcrypt.compare`.
- عند النجاح: أصدر JWT يحوي `{ user_id, role }` بمدة صلاحية قصيرة (مثلاً 15 دقيقة) + **refresh token** طويل مخزّن كـ httpOnly cookie أو في جدول `refresh_tokens` (قابل للإبطال عند تسجيل الخروج/تغيير الباسورد).
- **Rate limiting إلزامي**: مثلاً 5 محاولات فاشلة / 15 دقيقة لكل IP + لكل حساب، ثم قفل مؤقت. هذا يمنع محاولات تخمين الباسورد للوصول لرصيد أحد.
- لا ترجّع أبداً رسالة تفرّق بين "الإيميل غير موجود" و"الباسورد غلط" — رسالة موحدة لمنع تعداد الحسابات (user enumeration).
### 1.3 الجلسة والصلاحيات
- Middleware `auth`: يتحقق من JWT ويحقن `req.user`.
- Middleware `requireActive`: يرفض أي عملية مالية (شحن/شراء) إذا `status = banned`.
- Middleware `admin`: يتحقق `role = admin` لكل مسار إداري.
- عند أي عملية حساسة على المحفظة، تحقق من هوية المستخدم من الـ JWT فقط — **لا تثق أبداً بـ `user_id` قادم من body/query**.
### 1.4 نسيان كلمة المرور
- توليد token عشوائي (32+ byte) + صلاحية قصيرة (15-30 دقيقة) + تخزينه مُشفّر (hash) في DB.
- عند إعادة التعيين: أبطل كل الـ refresh tokens القديمة لهذا المستخدم (يقفل أي جلسة مسروقة).
---
## 2. صحة المحفظة (Wallet Integrity) — الجزء الأهم
الهدف: **رصيد المستخدم = مجموع كل حركاته في `transactions`، دائماً، بدون استثناء.**
إذا صار فرق بين `users.balance` ومجموع `transactions`، فهذا يعني وجود باغ خطير أو تلاعب.
### 2.1 القواعد الذهبية
1. **كل تغيير على `balance` يمر عبر خدمة واحدة فقط**: `wallet.service.js` — ممنوع أي كود آخر (controller أو route) يعدّل `balance` مباشرة بـ SQL.
2. **كل عملية = DB Transaction واحدة (atomic)** تشمل: قفل الصف، التحقق، التعديل، وتسجيل الحركة في `transactions` — الأربعة سوا أو ولا وحدة.
3. **استخدم `SELECT ... FOR UPDATE`** على صف المستخدم لمنع Race Condition (طلبين بنفس اللحظة يشتروا بنفس الرصيد).
4. **DECIMAL دائماً، لا FLOAT أبداً** — الأخطاء العشرية في FLOAT تسبب فروقات مالية حقيقية مع الوقت.
5. **لا رصيد سالب أبداً**: تحقق `balance >= amount` **داخل نفس الـ Transaction بعد القفل**، وليس قبل.
6. **كل حركة توثّق `balance_after`** — يعطيك سجل تدقيق كامل تقدر تعيد بناء الرصيد منه في أي لحظة تاريخية.
7. **Idempotency**: أي عملية قابلة لإعادة الإرسال (retry من الشبكة، دبل كليك، webhook مكرر) لازم يكون إلها مفتاح فريد يمنع تكرارها.
### 2.2 مثال Pseudo-code لخصم رصيد (شراء خدمة)
```js
async function chargeForOrder(userId, amount, orderRef) {
return db.transaction(async (trx) => {
// 1. قفل صف المستخدم
const user = await trx('users').where({ id: userId }).forUpdate().first();
// 2. تحقق من الحالة والرصيد داخل القفل
if (user.status !== 'active') throw new AppError('الحساب موقوف');
if (Number(user.balance) < Number(amount)) throw new InsufficientBalanceError();
const newBalance = round4(Number(user.balance) - Number(amount));
// 3. عدّل الرصيد
await trx('users').where({ id: userId }).update({ balance: newBalance });
// 4. سجّل الحركة (idempotency عبر reference فريد)
await trx('transactions').insert({
user_id: userId,
type: 'order',
amount: -amount,
balance_after: newBalance,
reference: orderRef, // مثال: "order:1234"
note: 'خصم مقابل طلب خدمة',
});
return newBalance;
}); // commit تلقائي، أو rollback كامل عند أي خطأ
}
```
- إذا فشل إرسال الطلب لمزوّد الخدمة **بعد** نجاح الخصم: نفّذ عملية عكسية (`refund`) بنفس الآلية أعلاه، لا تعدّل الرصيد يدوياً.
- ضع `UNIQUE constraint` على `transactions.reference` (أو composite `user_id + reference + type`) — هذا يمنع تسجيل نفس الحركة مرتين حتى لو صار retry.
### 2.3 الشحن اليدوي — كيف تتأكد إن المصاري "صحيحة" فعلاً
هذا أهم نقطة طلبتها، فهاي التفاصيل:
**أ) منع تكرار نفس إثبات التحويل:**
- `deposits.tx_ref` لازم `UNIQUE` — يمنع مستخدم يقدّم نفس رقم التحويل/الهاش مرتين (أو مستخدمين مختلفين يستخدموا نفس الإثبات).
**ب) الموافقة تتم فقط من الأدمن (لا auto-approve إلا لو عندك تكامل حقيقي مع بوابة/بلوكتشين):**
```
PATCH /admin/deposits/:id { action: 'approve' | 'reject', note }
```
- عند `approve`، داخل DB Transaction واحدة:
1. قفل صف `deposits` (`FOR UPDATE`) وتأكد `status = pending` (يمنع أدمن ثاني يوافق مرتين بنفس اللحظة).
2. قفل صف `users`.
3. `balance += deposit.amount`.
4. أنشئ `transaction` بـ `type=deposit، reference = "deposit:" + deposit.id`.
5. حدّث `deposits.status = approved`, `reviewed_by = admin.id`.
6. Commit واحد للكل.
- **لا تسمح بتعديل `amount` بعد الإنشاء** — إذا المبلغ غلط، الأدمن يرفض ويطلب من المستخدم تقديم طلب جديد. هذا يحافظ على أن `deposits.amount` = المصدر الوحيد للحقيقة.
**ج) لو رح تضيف تحقق تلقائي لاحقاً (USDT مثلاً):**
- تحقق من الـ tx hash على البلوكتشين فعلياً (عدد التأكيدات، العنوان المستلم، المبلغ يطابق تماماً) قبل أي auto-approve.
- خزّن نتيجة التحقق (raw response) في عمود إضافي للتدقيق.
- لا تثق بأي مبلغ يرسله المستخدم في الفورم — تحقق منه مقابل البلوكتشين/بوابة الدفع نفسها.
**د) تعديل رصيد يدوي من الأدمن (`PATCH /admin/users/:id`):**
- يمر بنفس آلية `wallet.service.js` (قفل + تسجيل transaction بـ `note` يوضح السبب و`reference` يربط بمن نفّذ التعديل) — **ممنوع UPDATE مباشر على عمود `balance`**.
### 2.4 التسوية الدورية (Reconciliation)
مهمة cron (يومية مثلاً) تتحقق:
```sql
SELECT user_id, SUM(amount) AS calculated
FROM transactions
GROUP BY user_id
```
وتقارن الناتج مع `users.balance` لكل مستخدم. أي فرق = تنبيه فوري للأدمن (لا تصحيح تلقائي — تحقيق يدوي أولاً).
### 2.5 حالات حافة لازم تُختبر
| الحالة | السلوك المطلوب |
|---|---|
| طلبين شراء بنفس اللحظة من نفس المستخدم | الثاني يفشل أو ينتظر القفل، لا يمرّان معاً إذا الرصيد يكفي لواحد فقط |
| فشل الشبكة بعد الخصم وقبل استلام رد المزوّد | job دوري يتحقق من حالة الطلب عند المزوّد، وإن لم يُنشأ فعلياً يرد المبلغ |
| Retry تلقائي من الفرونت (دبل كليك على "شراء") | idempotency key يمنع الخصم مرتين لنفس الطلب |
| أدمن يوافق على نفس طلب الشحن مرتين (تبويبين مفتوحين) | القفل (`FOR UPDATE`) + شرط `status=pending` يمنع التكرار |
| مستخدم يقدّم رقم تحويل مستخدم سابقاً | `UNIQUE` على `tx_ref` يرفضه فوراً |
| رصيد يوشك يصير سالب بسبب سباق (race) | التحقق من الرصيد يتم بعد القفل مباشرة، ضمن نفس الـ transaction |
---
## 3. جدول Idempotency (اختياري لكن مُستحسن)
لأي عملية POST حساسة (شراء، شحن)، خلي الفرونت يرسل `Idempotency-Key` فريد لكل محاولة:
| العمود | النوع |
|---|---|
| key | VARCHAR(64) PK |
| user_id | BIGINT |
| endpoint | VARCHAR(100) |
| response_snapshot | JSON |
| created_at | TIMESTAMP |
إذا وصل نفس المفتاح مرة ثانية، رجّع نفس النتيجة المخزّنة بدل تنفيذ العملية من جديد.
---
## 4. اختبارات إلزامية لمنطق المحفظة
- شراء بمبلغ أكبر من الرصيد → يُرفض، لا يتغيّر الرصيد.
- شراءين متزامنين (parallel) بمبلغ كل واحد = نص الرصيد → واحد فقط ينجح.
- فشل إرسال الطلب للمزوّد بعد الخصم → رصيد المستخدم يرجع كما كان (refund تلقائي).
- موافقة أدمن على شحن مرتين بالتوازي → إضافة واحدة فقط للرصيد.
- تقديم `tx_ref` مكرر → رفض فوري بدون لمس الرصيد.
- بعد N عملية عشوائية (شحن/خصم/استرجاع)، `SUM(transactions.amount) == users.balance` دائماً.
---
## 5. ملخص مسارات الحساب والمحفظة
```
POST /auth/register
POST /auth/login
POST /auth/logout → يُبطل الـ refresh token
POST /auth/forgot-password
POST /auth/reset-password
GET /me → الرصيد + بيانات الحساب
GET /transactions → كشف حساب كامل (مع balance_after لكل حركة)
POST /deposits → تقديم طلب شحن (amount, method, tx_ref)
GET /deposits → طلبات الشحن الخاصة بي وحالتها
PATCH /admin/deposits/:id → موافقة/رفض (atomic كما بالقسم 2.3)
PATCH /admin/users/:id → تعديل رصيد يدوي (يمر بـ wallet.service.js حصراً)
```
---
## 6. ملاحظة للوكيل البرمجي
عند تنفيذ أي كود يلمس `balance`، اسأل نفسك دائماً: *"هل هذا داخل DB transaction مقفول بـ FOR UPDATE، وهل يسجّل صف في transactions؟"* — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.