Initial SMM panel implementation

This commit is contained in:
diyaa
2026-08-13 19:05:47 +02:00
commit 7717a674c7
64 changed files with 8626 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log*
.env
.DS_Store
coverage
.git
.agents
.codex
+11
View File
@@ -0,0 +1,11 @@
PORT=3000
NODE_ENV=development
DATABASE_URL=mysql://smm:smm_password@127.0.0.1:3306/smm
JWT_SECRET=change_me_long_random
PROVIDER_API_URL=https://provider.example/api/v2
PROVIDER_API_KEY=your_provider_key
CORS_ORIGIN=http://localhost:3000
DEFAULT_MARKUP_PERCENT=25
ENABLE_JOBS=false
WALLET_RECONCILIATION_INTERVAL_MS=86400000
ORDER_STATUS_INTERVAL_MS=300000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.env
npm-debug.log*
.DS_Store
coverage/
backups/
+303
View File
@@ -0,0 +1,303 @@
# AGENTS.md — SMM Panel Project
> ملف تعليمات للوكيل البرمجي (AI Agent). اقرأه بالكامل قبل كتابة أي كود.
> الهدف: بناء موقع SMM Panel لبيع خدمات السوشال ميديا عبر API مزوّد خارجي،
> مع محفظة رصيد (Wallet) ونظام مستخدمين ولوحة أدمن.
>
> ⚠️ راجع أيضاً `WALLET.md` — فيه تفاصيل إلزامية عن نظام تسجيل الدخول وصحة المحفظة،
> و`TASKS.md` — فيه خطة تنفيذ مرحلية بالترتيب.
---
## 1. نظرة عامة على المشروع (Project Overview)
منصة ويب تبيع خدمات سوشال ميديا (متابعين، لايكات، مشاهدات … إلخ).
المستخدم:
1. يسجّل حساب ويسجّل دخول.
2. يشحن محفظته (Wallet) برصيد.
3. يتصفّح الخدمات المتاحة.
4. ينشئ طلب (Order): يختار خدمة + يحط الرابط/الـ ID + الكمية.
5. النظام يخصم من رصيده ويرسل الطلب لمزوّد الخدمة عبر API، ثم يتابع حالته.
الموقع **وسيط**: يشتري من مزوّد بسعر منخفض ويبيع للمستخدم بسعر أعلى (هامش ربح يحدده الأدمن).
---
## 2. المصطلحات (Glossary)
- **Provider / المزوّد**: الجهة الخارجية اللي تملك الـ API الحقيقي وتنفّذ الخدمات.
- **Service / الخدمة**: عنصر قابل للبيع (مثال: "1000 متابع انستقرام")، له `service_id` وسعر وحد أدنى وأقصى.
- **Wallet / المحفظة**: رصيد المستخدم داخل الموقع.
- **Order / الطلب**: عملية شراء خدمة من قبل المستخدم.
- **Transaction / المعاملة**: أي حركة على الرصيد (شحن، خصم، استرجاع).
- **Markup / الهامش**: نسبة أو مبلغ يُضاف على سعر المزوّد ليصير سعر البيع.
---
## 3. التقنيات (Tech Stack)
> ⚠️ هذه افتراضات قابلة للتغيير — راجع "قرارات مفتوحة" في الأسفل.
- **Backend**: Node.js + Express
- **Database**: MySQL (أو MariaDB)
- **Auth**: JWT + bcrypt لتشفير كلمات المرور
- **Frontend**: (اختر واحد) React / EJS templates بسيطة للبداية
- **HTTP client** للتواصل مع المزوّد: `axios`
- **Validation**: `zod` أو `express-validator`
- **Env config**: `dotenv`
هيكل المجلدات المقترح:
```
/src
/config (اتصال قاعدة البيانات، متغيرات البيئة)
/models (استعلامات قاعدة البيانات)
/services (منطق العمل: wallet, orders, provider API)
/controllers (معالجة الطلبات)
/routes (المسارات)
/middleware (auth, admin-check, error handler)
/utils
/public (الواجهة)
```
---
## 4. مخطط قاعدة البيانات (Database Schema)
### جدول users
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK AUTO_INCREMENT | |
| username | VARCHAR(50) UNIQUE | |
| email | VARCHAR(120) UNIQUE | |
| password_hash | VARCHAR(255) | bcrypt |
| balance | DECIMAL(12,4) DEFAULT 0 | رصيد المحفظة |
| role | ENUM('user','admin') DEFAULT 'user' | |
| status | ENUM('active','banned') DEFAULT 'active' | |
| created_at | TIMESTAMP | |
### جدول services
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK | |
| provider_service_id | VARCHAR(50) | الـ ID عند المزوّد |
| name | VARCHAR(255) | |
| category | VARCHAR(100) | |
| provider_price | DECIMAL(12,4) | سعر المزوّد لكل 1000 |
| sell_price | DECIMAL(12,4) | سعر البيع لكل 1000 (بعد الهامش) |
| min | INT | أقل كمية |
| max | INT | أكبر كمية |
| is_active | BOOLEAN DEFAULT true | |
| updated_at | TIMESTAMP | |
### جدول orders
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK → users.id | |
| service_id | BIGINT FK → services.id | |
| link | VARCHAR(500) | الرابط/الـ ID المستهدف |
| quantity | INT | |
| charge | DECIMAL(12,4) | المبلغ المخصوم من المستخدم |
| provider_order_id | VARCHAR(80) | رقم الطلب عند المزوّد |
| status | ENUM('pending','processing','in_progress','completed','partial','canceled','failed') DEFAULT 'pending' | |
| created_at | TIMESTAMP | |
### جدول transactions
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK | |
| type | ENUM('deposit','order','refund') | |
| amount | DECIMAL(12,4) | موجب = إضافة، سالب = خصم |
| balance_after | DECIMAL(12,4) | الرصيد بعد العملية (للتدقيق) |
| reference | VARCHAR(120) | مرجع (رقم الطلب / رقم الإيداع) |
| note | VARCHAR(255) | |
| created_at | TIMESTAMP | |
### جدول deposits (طلبات الشحن اليدوي)
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK | |
| user_id | BIGINT FK | |
| amount | DECIMAL(12,4) | |
| method | VARCHAR(50) | usdt / manual … |
| tx_ref | VARCHAR(200) | إثبات التحويل (hash / رقم عملية) |
| status | ENUM('pending','approved','rejected') DEFAULT 'pending' | |
| reviewed_by | BIGINT NULL | id الأدمن |
| created_at | TIMESTAMP | |
---
## 5. تكامل API المزوّد (Provider Integration)
معظم مزوّدي SMM يتبعون نفس المعيار (Perfect Panel standard). كل الطلبات
عبارة عن POST إلى نقطة نهاية واحدة مع `key` و `action`.
**متغيرات البيئة المطلوبة:**
```
PROVIDER_API_URL=https://provider.example/api/v2
PROVIDER_API_KEY=xxxxxxxx
```
**العمليات:**
```
# جلب كل الخدمات
POST { key, action: "services" }
# إنشاء طلب
POST { key, action: "add", service, link, quantity }
→ يرجّع { order: <provider_order_id> }
# متابعة حالة طلب
POST { key, action: "status", order: <provider_order_id> }
→ يرجّع { charge, start_count, status, remains }
# رصيدك أنت عند المزوّد
POST { key, action: "balance" }
```
> ⚠️ تحقّق من توثيق المزوّد الفعلي: بعض الحقول قد تختلف بالتسمية.
> اجعل طبقة `provider.service.js` قابلة للتعديل من مكان واحد.
---
## 6. المنطق الحرج: المحفظة والطلبات (Critical Logic)
> التفاصيل الكاملة (race conditions، idempotency، تسوية دورية) موجودة في `WALLET.md`. هذا ملخص سريع فقط.
### قاعدة ذهبية: كل عملية على الرصيد داخل Transaction من قاعدة البيانات (atomic)
لا تسمح أبداً بخصم الرصيد وإرسال الطلب في خطوات منفصلة غير محمية.
**تدفق إنشاء الطلب (order flow):**
```
1. تحقّق أن المستخدم مسجّل دخول ونشِط.
2. تحقّق أن الخدمة موجودة و is_active.
3. تحقّق quantity ضمن [min, max].
4. احسب charge = (sell_price / 1000) * quantity.
5. ابدأ DB Transaction:
a. اقفل صف المستخدم (SELECT ... FOR UPDATE).
b. تحقّق balance >= charge، وإلا ارفض (رصيد غير كافٍ).
c. اخصم: balance -= charge.
d. أنشئ صف order بحالة pending.
e. أنشئ صف transaction (type=order, amount=-charge).
f. Commit.
6. أرسل الطلب لـ API المزوّد.
7. إذا نجح: خزّن provider_order_id وحدّث status=processing.
8. إذا فشل الإرسال: أعِد الرصيد (refund) وحدّث status=failed.
```
**تدفق الشحن اليدوي:**
```
1. المستخدم يقدّم طلب إيداع (amount + tx_ref) → deposits(status=pending).
2. الأدمن يراجع ويوافق.
3. عند الموافقة (داخل DB Transaction):
- balance += amount
- transaction (type=deposit)
- deposit.status = approved
```
**متابعة الحالات (cron/job):**
مهمة دورية تمرّ على الطلبات غير المكتملة وتستعلم عن حالتها من المزوّد وتحدّثها.
---
## 7. المسارات (API Routes)
### عامة / مصادقة
```
POST /auth/register
POST /auth/login
POST /auth/logout
POST /auth/forgot-password
```
### المستخدم (يتطلب تسجيل دخول)
```
GET /me → بيانات الحساب + الرصيد
GET /services → قائمة الخدمات النشطة (بسعر البيع)
POST /orders → إنشاء طلب
GET /orders → طلباتي
GET /orders/:id → تفاصيل طلب
GET /transactions → كشف حساب المحفظة
POST /deposits → طلب شحن يدوي
GET /deposits → طلبات الشحن الخاصة بي
```
### الأدمن (يتطلب role=admin)
```
GET /admin/users
PATCH /admin/users/:id → حظر/رفع حظر، تعديل رصيد يدوي
GET /admin/deposits → طلبات الشحن المعلّقة
PATCH /admin/deposits/:id → موافقة/رفض
GET /admin/orders → كل الطلبات
POST /admin/services/sync → سحب الخدمات من المزوّد
PATCH /admin/services/:id → تعديل السعر/الهامش/التفعيل
GET /admin/stats → إحصائيات (أرباح، مبيعات)
```
---
## 8. متطلبات الأمان (Security — إلزامي)
- تشفير كلمات المرور بـ **bcrypt** (لا تخزّنها نصّاً أبداً).
- مفتاح API المزوّد في **متغيرات البيئة فقط**، لا يُرسل للواجهة إطلاقاً.
- كل حسابات المال بـ `DECIMAL` وليس `FLOAT`.
- التحقق من صحة كل المدخلات (server-side validation).
- حماية من SQL Injection عبر prepared statements / ORM.
- Rate limiting على مسارات auth و orders.
- التحقق من الصلاحيات (middleware) على كل مسار أدمن.
- **السعر يُحسب دائماً في الخادم** — لا تثق بأي سعر قادم من الواجهة.
---
## 9. متغيرات البيئة (.env)
```
PORT=3000
DATABASE_URL=mysql://user:pass@localhost:3306/smm
JWT_SECRET=change_me_long_random
PROVIDER_API_URL=https://provider.example/api/v2
PROVIDER_API_KEY=your_provider_key
DEFAULT_MARKUP_PERCENT=25
```
---
## 10. ترتيب البناء (Build Order / Milestones)
1. **الإعداد**: مشروع Node + Express + اتصال MySQL + الهجرات (migrations).
2. **المصادقة**: register / login / JWT / middleware.
3. **المحفظة**: جداول transactions + عرض الرصيد + كشف حساب.
4. **الشحن اليدوي**: deposits + موافقة الأدمن.
5. **الخدمات**: سحب من المزوّد + عرضها بسعر البيع + إدارة الأدمن.
6. **الطلبات**: تدفق الطلب الكامل (atomic) + الإرسال للمزوّد.
7. **متابعة الحالات**: مهمة دورية لتحديث حالات الطلبات.
8. **لوحة الأدمن**: الإحصائيات والإدارة.
9. **الواجهة**: ربط كل شيء بواجهة نظيفة.
سلّم كل مرحلة قابلة للتشغيل والاختبار قبل الانتقال للتالية.
> 📋 للتنفيذ الفعلي خطوة بخطوة، اتّبع `TASKS.md`.
---
## 11. قرارات مفتوحة (Open Decisions — املأها)
هذه القرارات محسومة للنسخة الحالية، ومفصلة في `DECISIONS.md`:
- [x] **طريقة الشحن**: شحن يدوي عبر قنوات قابلة للإدارة، مع PayPal وشام كاش وUSDT كبداية.
- [x] **اللغة/التقنية**: Node.js + Express + MySQL/MariaDB.
- [x] **الواجهة**: HTML/CSS/JavaScript ثابتة داخل `public/` وتخدمها Express.
- [x] **العملة المعتمدة**: USD بأربع خانات عشرية.
- [x] **الهامش**: هامش افتراضي من `DEFAULT_MARKUP_PERCENT` مع تخزين `markup_percent` لكل خدمة ودعم تعديل الأدمن.
- [x] **رابط ومفتاح API المزوّد الفعلي**: عبر `PROVIDER_API_URL` و`PROVIDER_API_KEY` في متغيرات البيئة فقط.
- [x] **دعم متعدد اللغات**: الواجهة والرسائل عربية حالياً، وتعليقات الكود والداخليات بالإنجليزية.
---
## 12. ملاحظات للوكيل (Agent Notes)
- اطلب توثيق المزوّد الحقيقي قبل كتابة طبقة `provider.service.js`.
- لا تفترض تفاصيل الدفع؛ إن كانت غير محسومة اسأل المالك.
- اكتب اختبارات لمنطق المحفظة (أهم جزء حسّاس) — راجع `WALLET.md` قسم 4.
- علّق الكود بالإنجليزية، والرسائل للمستخدم بالعربية (أو حسب إعداد اللغة).
+16
View File
@@ -0,0 +1,16 @@
# Project Decisions
These decisions close the open items from `AGENTS.md` § 11 for the current build.
- Charging method: manual deposits first, with USDT/manual references stored in `deposits.tx_ref`.
- Backend and database: Node.js, Express, MySQL/MariaDB.
- Frontend: static HTML/CSS/JavaScript served from `public/` by Express.
- Currency: USD-denominated decimal balances with four fractional digits.
- Markup: global default from `DEFAULT_MARKUP_PERCENT`, stored per synced service as `markup_percent`, with admin override for `sell_price` or service activation.
- Provider API: Perfect Panel-compatible API through `PROVIDER_API_URL` and `PROVIDER_API_KEY`. Real provider credentials must be configured in environment variables before production.
- Languages: Arabic UI/messages for the current frontend; API internals and code comments stay English.
Partial order handling:
- `partial` orders are recorded with `remains`, `start_count`, and `provider_charge`.
- Automatic refunds are intentionally disabled for `partial` until the provider's exact partial/refund semantics are confirmed.
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+275
View File
@@ -0,0 +1,275 @@
# FRONTEND.md — شرح الواجهة الحالية + مواصفات التحسين (تصميم متجاوب + طرق شحن)
> ملف مكمّل لـ `AGENTS.md` و`WALLET.md`. يشرح الواجهة الموجودة حالياً (`public/index.html`,
> `public/app.js`, `public/styles.css`) صفحة صفحة ودالة دالة، ثم يحدد مواصفات دقيقة
> لثلاث تحسينات مطلوبة: (1) نظام ألوان متناسق مع خيار تغيير اللون الأساسي، (2) تصميم
> متجاوب فعلي على الموبايل، (3) إصلاح طريقة شحن الرصيد لتدعم قنوات حقيقية
> (PayPal / شام كاش / USDT ... إلخ) بدل حقل نصي حر.
---
## 1. شرح الصفحات (كل صفحة شو وظيفتها)
الواجهة تطبيق صفحة واحدة (SPA) بدون أي framework — كل شي بملف `app.js` واحد،
والتنقل بين "الصفحات" هو فعلياً تبديل محتوى `#view` حسب `state.view`.
### شاشة الدخول (قبل تسجيل الدخول)
فورمين جنب بعض: تسجيل دخول وحساب جديد. عند نجاح الدخول تُخزَّن التوكنات وتنفتح
الواجهة الرئيسية. لا رصيد ولا بيانات تُحمَّل قبل الدخول.
### لوحة المستخدم (Dashboard)
نظرة سريعة: عدد الطلبات الكلي، عدد الطلبات قيد التنفيذ، عدد طلبات الشحن المعلّقة،
وإجمالي المبلغ المصروف على الطلبات (محسوب من `transactions` نوع `order`). تحتها
جدول بآخر 5 طلبات مع زر "عرض الكل" ينقلك لصفحة "طلباتي".
**الهدف:** ملخص بلمحة، بدون أي إجراء — كله عرض فقط.
### الخدمات والطلب (Services)
فورم إنشاء طلب بالأعلى (اختيار خدمة + رابط + كمية) وتحته كتالوج كامل بكل الخدمات
النشطة (الاسم، التصنيف، السعر لكل 1000، الحد الأدنى والأعلى). السعر المعروض هو
`sell_price` فقط — سعر المزوّد الحقيقي غير مرئي هون إطلاقاً (وهذا صحيح ومقصود، راجع
`AGENTS.md` § 8).
**الهدف:** تصفح + شراء.
### طلباتي (Orders)
جدول بكل طلبات المستخدم نفسه فقط (الخدمة، الرابط، الكمية، المبلغ، المتبقي، الحالة).
عرض فقط، لا إجراء — تحديث الحالة يصير تلقائياً من الـ job الدوري بالخلفية
(`order-status.scheduler.js`)، فمجرد ما تعمل "تحديث" (زر أعلى الصفحة) بتشوف آخر حالة.
### شحن الرصيد (Deposits) — ⚠️ هاي الصفحة اللي فيها المشكلة المطلوب حلها
فورم بسيط: مبلغ + **حقل نصي حر لاسم الطريقة** + مرجع التحويل. تحته جدول بطلبات
الشحن السابقة وحالتها (معلّق/مقبول/مرفوض). المشكلة بالتفصيل بقسم 4 تحت.
### كشف الحساب (Transactions)
جدول للقراءة فقط بكل حركة صارت على رصيد المستخدم (شحن، خصم طلب، استرجاع، تعديل
أدمن) مع `balance_after` لكل صف — هاي هي "الحقيقة" اللي لازم تطابق الرصيد الظاهر
بالأعلى دائماً (راجع `WALLET.md` § 2.4 التسوية الدورية).
### لوحة الأدمن (Admin) — تظهر فقط لو `role=admin`
أربع بطاقات إحصائيات (عدد المستخدمين، طلبات معلّقة، إجمالي المبيعات، الربح)، فورم
مزامنة خدمات من المزوّد، ثم أربع جداول إدارة: المستخدمون (حظر/تنشيط + تعديل رصيد
يدوي عبر `prompt()`)، طلبات الشحن (قبول/رفض)، الخدمات (تفعيل/إيقاف)، وكل الطلبات
(عرض فقط).
---
## 2. شرح كل دالة بـ `app.js` (شو مهمتها)
### الحالة والتخزين
- **`state`** — كائن واحد مركزي فيه كل شي: التوكنات، الصفحة الحالية، وكل القوائم
المحمّلة من الـ API (خدمات، طلبات، شحن، حركات، وبيانات الأدمن).
- **`views`** — تعريف عناصر القائمة الجانبية (الآيدي، النص، هل هي للأدمن فقط).
- **`el`** — كل عناصر الـ DOM المستخدمة كثيراً، محفوظة مرة وحدة بدل `querySelector`
المتكرر.
### أدوات مساعدة عامة
- **`formatStatus(value)`** — يترجم قيم الحالة الإنجليزية (`pending`, `completed`...)
لنص عربي للعرض.
- **`showMessage(text, type)` / `clearMessage()`** — شريط رسالة واحد (نجاح/خطأ) يظهر
إما بشاشة الدخول أو داخل التطبيق حسب أيّهم ظاهر حالياً.
- **`friendlyMessage(text)`** — قاموس صغير يترجم رسائل خطأ إنجليزية معروفة من الـ
API لعربي؛ أي رسالة مو موجودة بالقاموس تظهر زي ما هي بالإنجليزي (نقطة ضعف حالية —
لازم القاموس يكبر مع الوقت).
- **`getFormData(form)`** — يحوّل أي `<form>` لكائن JS بسيط.
- **`api(path, options)`** — الغلاف الوحيد لكل نداء API: يحط توكن الدخول تلقائياً،
يحوّل الرد لـ JSON، ويرمي `Error` برسالة الباك-إند لو الرد مو ناجح.
- **`setSession(payload)` / `clearSession()`** — حفظ/مسح التوكنات بالـ
`localStorage` وبالـ `state`.
### البناء والعرض (Rendering)
- **`renderNav()`** — يبني أزرار القائمة الجانبية من `views`، ويخفي رابط الأدمن عن
غير الأدمن، ويعلّم الزر النشط.
- **`table(headers, rows, emptyText)`** — دالة عامة تبني أي جدول HTML، وتعرض رسالة
"لا توجد بيانات" لو القائمة فاضية.
- **`statusBadge(status)`** — يغلّف `formatStatus` بوسم ملوّن (أخضر/أصفر/أحمر) حسب
نوع الحالة.
- **`renderDashboard / renderServices / renderOrders / renderDeposits /
renderTransactions / renderAdmin`** — كل وحدة مسؤولة عن بناء HTML صفحتها فقط
(مشروحة بالتفصيل بقسم 1)، وبتربط أحداث الفورم الخاصة فيها إذا وجدت.
- **`render()`** — المايسترو: يحدّث العنوان واسم المستخدم والرصيد بالأعلى، يبني
القائمة الجانبية، وينده دالة العرض المناسبة حسب `state.view`.
### تحميل البيانات ودورة الحياة
- **`loadUserData()`** — يجيب كل بيانات المستخدم بالتوازي (`/me`, `/services`,
`/orders`, `/deposits`, `/transactions`)، وإذا كان أدمن يجيب بيانات لوحة الأدمن
كمان بالتوازي.
- **`boot()`** — نقطة الدخول عند تحميل الصفحة: لو ما في توكن يفتح شاشة الدخول، وإلا
يحمّل البيانات ويفتح التطبيق؛ لو فشل التحميل (توكن منتهي مثلاً) يمسح الجلسة
ويرجّع لشاشة الدخول مع رسالة خطأ.
### إجراءات المستخدم العادي
- **`submitOrder(event)`** — يمنع إعادة تحميل الصفحة، يرسل `POST /orders`، يعيد
تحميل البيانات ويعرض رسالة نجاح/خطأ.
- **`submitDeposit(event)`** — نفس الفكرة لـ `POST /deposits`.
### إجراءات الأدمن
- **`bindAdminActions()`** — ينده مرة وحدة بعد كل رسم للوحة الأدمن، ويربط كل
الأزرار (مزامنة، حظر/تنشيط، تعديل رصيد، قبول/رفض شحن، تفعيل/إيقاف خدمة).
- **`updateUser / reviewDeposit / updateService`** — أغلفة رفيعة تنده مسار
`PATCH` المناسب بالباك-إند، وتعيد تحميل البيانات وتعرض رسالة.
### الأحداث الرئيسية (أسفل الملف)
ربط فورم الدخول، فورم التسجيل، زر التحديث، زر الخروج، ثم نداء `boot()` مرة وحدة
عند تحميل الملف.
---
## 3. نظام الألوان والتصميم المتجاوب (المطلوب تنفيذه)
### 3.1 المشكلة الحالية
الألوان بـ `styles.css` ثابتة بمتغيرات CSS (`--accent`, `--accent-strong`, `--blue`
منفصلين عن بعض بدون علاقة منطقية بينهم)، وما في أي طريقة يغيّر فيها المستخدم اللون
الأساسي. التجاوب موجود بس بسيط (breakpoint واحد بس عند 900px)، والجداول بتفيض
أفقياً على الموبايل بدل ما تتكيّف.
### 3.2 نظام الألوان الجديد
وحّد كل متغيرات اللون الأساسي تحت اسم واحد مشتق:
```css
:root {
--primary-h: 174; /* Hue */
--primary-s: 64%; /* Saturation */
--primary-l: 30%; /* Lightness — يتغيّر بالجافاسكربت */
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--primary-hover: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 8%));
--primary-soft: hsl(var(--primary-h) var(--primary-s) 94%);
--primary-text-on: #ffffff;
}
```
كل مكان بالـ CSS كان يستخدم `--accent` / `--accent-strong` / `--blue` (الأزرار،
الرابط النشط بالقائمة الجانبية، لون الرصيد بالأعلى، حدود التركيز `:focus`) لازم
يتحول لـ `--primary` / `--primary-hover` / `--primary-soft`. الألوان الدلالية
(أخضر=نجاح، أصفر=معلّق، أحمر=خطر) **تبقى ثابتة ومنفصلة عن اللون الأساسي** — ما
تتأثر بتغيير اللون، لأنها تحمل معنى (حالة الطلب/الحظر) مو تزيين.
### 3.3 خيار تغيير اللون الأساسي (Theme Picker) — نسخة منقّحة
> **ملاحظة:** النسخة الأولى (زر دائري صغير + قائمة نقاط 34px بدون تسميات) نُفّذت
> ووظيفياً شغّالة صح، لكن شكلها بدائي وما بتبين إنها "ميزة" حقيقية — أقرب لعنصر
> تجريبي. هاي المواصفات المحدّثة تحل هالموضوع شكلياً بدون ما تغيّر آلية الحفظ/التطبيق
> اللي شغّالة صح أصلاً.
**الزر المُشغِّل (Trigger):** بدل الدائرة الصغيرة، زر فيه أيقونة واضحة (SVG بسيط
لقطارة ألوان أو لوحة رسام — inline SVG، بدون مكتبة أيقونات خارجية) + نص "المظهر"
يظهر بجانبها من عرض التابلت فما فوق (أيقونة فقط على الموبايل لضيق المساحة). الهدف:
المستخدم يفهم من أول نظرة إنه إعداد حقيقي مو زر تزييني.
**اللوحة (Panel) بعد الضغط:** بدل قائمة نقاط صغيرة، بطاقة منبثقة (Popover) واضحة:
- عنوان أعلى اللوحة: "اختر اللون الأساسي" + زر إغلاق (×).
- شبكة الألوان الجاهزة بحجم أكبر (44px بدل 34px)، وتحت كل لون **اسمه** (تركواز،
أزرق، بنفسجي، أخضر، برتقالي، وردي) — مو لون بدون تسمية.
- اللون **المختار حالياً** يبين عليه علامة واضحة (حلقة حدّ بلون داكن + ✓ صغيرة
بالنص) — حالياً ما في أي إشارة لأي لون مفعّل.
- قسم "لون مخصص" منفصل تحت الشبكة فيه: `<input type="color">` بحجم أكبر + حقل نصي
بجانبه يعرض/يقبل كود الـ HEX يدوياً (Bidirectional: تغيير أي وحدة يحدّث التانية) +
شريحة معاينة حيّة صغيرة (زر تجريبي أو badge) تتلوّن فوراً أثناء السحب على منتقي
اللون، قبل حتى ما يثبّت المستخدم اختياره.
- اللوحة تنغلق: بالضغط خارجها، بزر ×، بمفتاح Escape، أو عند اختيار لون جاهز.
انتقال فتح/إغلاق ناعم (`transition` على `opacity`/`transform`، مش ظهور مفاجئ).
آلية الحفظ والتطبيق **تبقى كما هي بدون تغيير** (شغّالة صح فعلاً):
1. عند اختيار لون (Preset أو مخصص)، حوّل الـ HEX إلى HSL.
2. حدّث المتغيرات الثلاث مباشرة على `document.documentElement`:
```js
document.documentElement.style.setProperty('--primary-h', h);
document.documentElement.style.setProperty('--primary-s', `${s}%`);
document.documentElement.style.setProperty('--primary-l', `${l}%`);
```
3. احفظ الاختيار بـ `localStorage.setItem('primaryColor', hex)`.
4. بأول سطر تنفيذ بالملف (قبل أي رسم)، اقرأ القيمة المحفوظة وطبّقها فوراً — هذا
موجود ومضبوط، لا تلمسه.
**معيار القبول:** الزر واضح إنه إعداد مو زخرفة (أيقونة + تسمية)، فتح اللوحة يبين
أسماء الألوان وتعليم واضح على اللون المفعّل حالياً، تغيير اللون المخصص يعطي معاينة
حيّة قبل التثبيت، وإعادة تحميل الصفحة (F5) بترجع لنفس اللون فوراً بدون وميض (زي ما
هو مضبوط حالياً).
### 3.4 التجاوب (Responsive) — تفصيل الكسور (Breakpoints)
اعتمد ثلاث كسور بدل واحد:
- **≥ 1024px (Desktop):** القائمة الجانبية ثابتة زي الوضع الحالي.
- **640–1024px (Tablet):** القائمة الجانبية تتحول لصف علوي أفقي قابل للتمرير، أو
تبقى جانبية بعرض أضيق مع أيقونات فقط.
- **< 640px (Mobile):** القائمة الجانبية تختفي افتراضياً وتظهر كـ Drawer منزلق من
فوق زر ☰ (Hamburger) يضاف بالـ `topbar`، مع خلفية شفافة (backdrop) تقفل القائمة
عند الضغط عليها.
نقاط إضافية إلزامية:
- **الجداول:** الجداول الكثيفة (الطلبات، كشف الحساب، طلبات الشحن) تتحول على
الموبايل (`< 640px`) لعرض "بطاقات" (كل صف = بطاقة فيها أزواج تسمية/قيمة) بدل
التمرير الأفقي — استخدم CSS فقط (`display: block` على `tr`/`td` مع
`data-label` من الـ JS) بدون أي مكتبة خارجية.
- **الفورمات (`.inline-form`):** تتكدس عمود واحد تحت 640px (مو 900px كما هو حالياً
فقط — خليها تدريجية: عمودين بالتابلت، عمود واحد بالموبايل).
- **الأزرار وحقول الإدخال:** ارتفاع لا يقل عن 44px بالموبايل لسهولة اللمس.
- **`topbar`:** صندوق الرصيد والأزرار لازم يلتفوا (`flex-wrap`) بدل ما يفيضوا خارج
الشاشة على الموبايل.
---
## 4. طرق شحن الرصيد — إعادة تصميم (المشكلة اللي لاحظها المالك)
### 4.1 المشكلة بالضبط
حقل "الطريقة" بفورم الشحن حالياً `<input name="method" required placeholder="manual / usdt">`
— نص حر بالكامل. هذا يعني:
- المستخدم لازم يخمّن شو يكتب.
- ما في أي تعليمات وين يحوّل المصاري فعلياً (رقم حساب PayPal؟ رقم محفظة USDT؟ حساب
شام كاش؟) — الفورم ساكت تماماً عن هالمعلومة.
- الأدمن ما عنده مكان يتحكم فيه بقنوات الدفع المتاحة من لوحة الأدمن — لازم كل قناة
تنضاف بالكود.
### 4.2 الحل: جدول `payment_methods` قابل للتحكم من الأدمن
بدل تثبيت القنوات بالكود، أضف جدول يتحكم فيه الأدمن:
| العمود | النوع | ملاحظات |
|---|---|---|
| id | BIGINT PK | |
| code | VARCHAR(30) UNIQUE | مثال: `paypal`, `sham_cash`, `usdt_trc20`, `bank_transfer` |
| label_ar | VARCHAR(100) | الاسم المعروض للمستخدم، مثال: "PayPal" أو "شام كاش" |
| instructions | TEXT | نص التعليمات (وين يحوّل، شو يكتب بالمرجع) |
| destination | VARCHAR(255) NULL | إيميل PayPal / رقم محفظة / رقم حساب — يُعرض جزء من التعليمات |
| is_active | BOOLEAN DEFAULT true | الأدمن يقدر يوقف قناة مؤقتاً |
| sort_order | INT DEFAULT 0 | ترتيب الظهور بالفورم |
| created_at / updated_at | TIMESTAMP | |
**مسارات جديدة:**
```
GET /payment-methods → القنوات النشطة فقط (عام، يحتاج تسجيل دخول بس)
GET /admin/payment-methods → كل القنوات (نشطة وموقوفة)
POST /admin/payment-methods → إضافة قناة جديدة
PATCH /admin/payment-methods/:id → تعديل/تفعيل/إيقاف قناة
```
**تعديل على `deposits`:** عمود `method` يصير يخزّن `payment_methods.code` بدل نص حر،
والتحقق بالـ backend (`zod`) يرفض أي `method` مو موجود بقائمة القنوات النشطة —
بدل قبول أي نص.
> ⚠️ هاي القنوات تبقى **يدوية بالكامل** (المستخدم يحوّل بنفسه ثم يدخل رقم العملية،
> والأدمن يوافق يدوياً) — تماماً متل الوضع الحالي، بس بقناة محددة وتعليمات واضحة
> بدل تخمين. ما في تكامل API حقيقي مع PayPal أو شام كاش بهالمرحلة (يحتاج توثيق
> واعتماد رسمي من المزوّد نفسه، وهذا قرار مفتوح لازم يُحسم لاحقاً إذا بدك أتمتة
> فعلية — راجع `AGENTS.md` § 12).
### 4.3 التعديل على فورم "شحن الرصيد" بالواجهة
1. حمّل `GET /payment-methods` عند فتح الصفحة.
2. بدّل حقل النص الحر بقائمة اختيار (`<select>`) بالأسماء العربية (`label_ar`).
3. عند اختيار قناة، اعرض صندوق تعليمات بارز فوق حقل "مرجع التحويل" يعرض
`instructions` و`destination` (مثال: "حوّل عبر PayPal إلى: pay@panel.com، ثم انسخ
رقم العملية بالأسفل").
4. غيّر تسمية حقل "مرجع التحويل" ديناميكياً حسب القناة (مثال: "رقم عملية PayPal" /
"رقم التحويل — شام كاش" / "TXID").
5. (تحسين اختياري لاحقاً، مو إلزامي بهالمرحلة): إمكانية رفع صورة إثبات التحويل —
يحتاج تخزين ملفات، أجّله لمرحلة لاحقة إذا بدك ياه.
**معيار القبول:** فتح فورم الشحن يعرض قنوات حقيقية (PayPal / شام كاش / USDT على
الأقل) بدل حقل فاضي، واختيار أي قناة يعرض تعليمات واضحة، وإرسال طلب بقناة موقوفة
أو غير موجودة يُرفض من الباك-إند مو بس من الواجهة.
---
## 5. ملاحظة للوكيل
هاي التحسينات الثلاث (الألوان/الثيمنج، التجاوب، طرق الشحن) موجودة كخطوات مرقّمة
بـ `TASKS.md` (المرحلة 9 والمرحلة 10). نفّذها بنفس فلسفة بقية المشروع: كل تغيير
بالباك-إند يمر بالتحقق (`zod`)، وكل تعديل على `deposits.method` ما لازم يكسر
اختبارات `WALLET.md` § 4 الموجودة أصلاً — شغّلها بعد التعديل للتأكد.
+52
View File
@@ -0,0 +1,52 @@
APP_PORT ?= 3000
DATABASE_URL ?= mysql://smm:smm_password@127.0.0.1:3306/smm
.PHONY: install db-up db-down migrate dev start test reconcile status-sync admin-create backup restore docker-build docker-up docker-down health
install:
npm install
db-up:
docker compose up -d db
db-down:
docker compose down
migrate:
DATABASE_URL="$(DATABASE_URL)" npm run migrate
dev:
PORT="$(APP_PORT)" DATABASE_URL="$(DATABASE_URL)" npm run dev
start:
PORT="$(APP_PORT)" DATABASE_URL="$(DATABASE_URL)" npm start
test:
DATABASE_URL="$(DATABASE_URL)" npm test
reconcile:
DATABASE_URL="$(DATABASE_URL)" npm run reconcile
status-sync:
DATABASE_URL="$(DATABASE_URL)" npm run sync:order-status
admin-create:
DATABASE_URL="$(DATABASE_URL)" npm run admin:create
backup:
./scripts/db-backup.sh
restore:
./scripts/db-restore.sh
docker-build:
docker compose build app
docker-up:
docker compose up --build
docker-down:
docker compose down
health:
curl -sS "http://127.0.0.1:$(APP_PORT)/health"
+130
View File
@@ -0,0 +1,130 @@
# SMM Panel
Node.js + Express SMM panel with wallet integrity, manual deposits, provider API sync, order creation, status tracking, admin APIs, and a static frontend.
## Quick Start
```bash
npm install
docker compose up -d db
cp .env.example .env
npm run migrate
npm start
```
Open:
```text
http://127.0.0.1:3000
```
## Docker App
For the full app stack:
```bash
docker compose up --build
```
The compose defaults are for local testing. Before production, set
`NODE_ENV=production` and replace `JWT_SECRET`, `PROVIDER_API_URL`,
`PROVIDER_API_KEY`, and `CORS_ORIGIN` through your deployment environment.
Apply migrations against the compose database:
```bash
DATABASE_URL=mysql://smm:smm_password@127.0.0.1:3306/smm npm run migrate
```
Migrations are intentionally run as an explicit command, not automatically when
the container starts.
## Environment
Important variables:
```text
PORT=3000
DATABASE_URL=mysql://smm:smm_password@127.0.0.1:3306/smm
JWT_SECRET=replace_with_a_long_random_secret
PROVIDER_API_URL=https://provider.example/api/v2
PROVIDER_API_KEY=replace_with_provider_key
CORS_ORIGIN=https://panel.example.com
DEFAULT_MARKUP_PERCENT=25
ENABLE_JOBS=false
```
For production, replace all placeholder values. The server refuses weak or placeholder production settings, and requires `CORS_ORIGIN` so browsers can only call the API from your real site origin.
## Create Admin
```bash
ADMIN_USERNAME=admin \
ADMIN_EMAIL=admin@example.com \
ADMIN_PASSWORD='StrongPass123' \
DATABASE_URL=mysql://smm:smm_password@127.0.0.1:3306/smm \
npm run admin:create
```
Then sign in from the web UI.
## Scripts
```bash
npm run dev # watch mode
npm start # run server
npm run migrate # apply migrations
npm run reconcile # wallet reconciliation
npm run sync:order-status # manually sync provider order statuses
npm test # lint + all tests + reconciliation
```
## Make Commands
```bash
make db-up
make migrate
make start APP_PORT=3000
make test
make backup
```
Create an admin through `make`:
```bash
ADMIN_USERNAME=admin \
ADMIN_EMAIL=admin@example.com \
ADMIN_PASSWORD='StrongPass123' \
make admin-create
```
## Backups
Create a database dump:
```bash
make backup
```
Restore requires an explicit confirmation:
```bash
BACKUP_FILE=backups/smm-YYYYMMDD-HHMMSS.sql CONFIRM_RESTORE=yes make restore
```
## Provider
The provider integration is Perfect Panel-compatible:
- `services`
- `add`
- `status`
- `balance`
Configure the real provider using `PROVIDER_API_URL` and `PROVIDER_API_KEY`. Provider keys are never exposed to the frontend.
## Notes
- All wallet balance changes go through `wallet.service.js`.
- Money is stored as SQL `DECIMAL` and calculated using fixed-scale `BigInt` helpers.
- Partial provider orders are recorded for admin review; no automatic partial refund is performed until provider semantics are confirmed.
+14
View File
@@ -0,0 +1,14 @@
# Security Checklist
- Passwords are hashed with `bcryptjs` before storage.
- Provider API credentials are read only from environment variables and are never returned by public APIs.
- Money columns use SQL `DECIMAL`; JavaScript money math uses fixed-scale `BigInt` helpers in `src/utils/money.js`.
- Request bodies are validated server-side with `zod`.
- SQL access goes through Knex query builders and transactions.
- Auth, password reset, deposit creation, and order creation have rate limiting.
- Every `/admin/*` route is protected by `auth`, `requireActive`, and `admin` middleware.
- Admin self-ban and last-active-admin lockout are rejected.
- Order prices are calculated on the server from `services.sell_price`; client-submitted prices are ignored.
- Production environment validation rejects missing or placeholder provider/JWT settings and requires `CORS_ORIGIN`.
- Production CORS allows only configured site origins.
- Wallet balance updates are centralized in `wallet.service.js` and covered by race-condition tests.
+206
View File
@@ -0,0 +1,206 @@
# TASKS.md — خطة تنفيذ SMM Panel للوكيل البرمجي
> نفّذ المهام بالترتيب. كل مرحلة لازم "تشتغل وتُختبر" قبل ما تنتقل للي بعدها.
> المرجع الكامل: `AGENTS.md` (السباق العام) و`WALLET.md` (تفاصيل الحساب والمحفظة — إلزامي قراءته قبل مرحلة 2 و3)
> و`FRONTEND.md` (شرح الواجهة الحالية + مواصفات دقيقة للمراحل 9 و10 — إلزامي قراءته قبلهن).
> علّم كل مهمة [x] بعد ما تخلص وتتأكد إنها تشتغل فعلياً (شغّلها، ما تفترض).
---
## المرحلة 0 — الإعداد
- [x] إنشاء مشروع Node.js + Express، هيكلة المجلدات حسب `AGENTS.md` § 3.
- [x] إعداد الاتصال بقاعدة بيانات MySQL/MariaDB (connection pool، ليس اتصال مفتوح لكل طلب).
- [x] إعداد نظام migrations (Knex / Prisma / أي أداة) — ممنوع تعديل السكيمة يدوياً بدون migration.
- [x] إنشاء ملف `.env.example` بكل المتغيرات المذكورة في `AGENTS.md` § 9.
- [x] إعداد error handler مركزي (middleware) يرجّع رسائل موحدة وما يسرّب stack trace بالإنتاج.
- [x] إعداد logging أساسي (بدون تسجيل باسوردات أو مفاتيح API إطلاقاً).
**معيار القبول:** السيرفر يشتغل، يتصل بقاعدة البيانات، ويرجّع 200 على `GET /health`.
---
## المرحلة 1 — قاعدة البيانات
- [x] إنشاء جدول `users` حسب `AGENTS.md` § 4 (بما فيها `balance DECIMAL(12,4) DEFAULT 0`).
- [x] إنشاء جدول `services`.
- [x] إنشاء جدول `orders`.
- [x] إنشاء جدول `transactions` — مع `UNIQUE` على (`user_id`, `reference`, `type`) لمنع التكرار (`WALLET.md` § 2.2).
- [x] إنشاء جدول `deposits` — مع `UNIQUE` على `tx_ref` (`WALLET.md` § 2.3-أ).
- [x] (اختياري لكن موصى) جدول `idempotency_keys` حسب `WALLET.md` § 3.
- [x] (اختياري) جدول `refresh_tokens` لإدارة الجلسات حسب `WALLET.md` § 1.2.
**معيار القبول:** كل الجداول موجودة، الـ FKs والـ UNIQUE constraints فعّالة (جرّب إدخال قيمة مكررة وتأكد إنها تُرفض).
---
## المرحلة 2 — نظام الحساب (Auth)
- [x] `POST /auth/register` — تشفير الباسورد بـ bcrypt، تحقق من تفرّد username/email.
- [x] `POST /auth/login` — إصدار JWT قصير المدة + refresh token، رسالة خطأ موحدة (`WALLET.md` § 1.2).
- [x] Rate limiting على `/auth/login` و`/auth/register` (مثلاً 5 محاولات/15 دقيقة).
- [x] Middleware `auth` (تحقق JWT) و`requireActive` (يرفض المستخدم المحظور) و`admin` (تحقق role).
- [x] `POST /auth/logout` — إبطال الـ refresh token.
- [x] `POST /auth/forgot-password` + `POST /auth/reset-password` (token عشوائي مؤقت، يبطل الجلسات القديمة عند إعادة التعيين).
- [x] `GET /me` — يرجّع بيانات الحساب + الرصيد الحالي.
**معيار القبول:**
- تسجيل حساب جديد ثم دخول فيه يرجّع JWT صالح.
- محاولة دخول بباسورد غلط 6 مرات متتالية تُحظر مؤقتاً.
- طلب لمسار محمي بدون توكن يرجّع 401.
---
## المرحلة 3 — المحفظة (Wallet) — أهم مرحلة، لا تستعجل فيها
اقرأ `WALLET.md` كامل قبل البدء هون.
- [x] بناء `wallet.service.js` — نقطة الدخول الوحيدة لأي تعديل على `balance`. لا كود ثاني يلمس عمود `balance` مباشرة.
- [x] دالة `chargeBalance(userId, amount, reference, note)` — قفل الصف (`FOR UPDATE`) + تحقق الرصيد + خصم + تسجيل transaction، الكل بـ DB transaction واحدة.
- [x] دالة `creditBalance(userId, amount, reference, note)` — نفس الآلية للإضافة (شحن/استرجاع).
- [x] `GET /transactions` — كشف حساب المستخدم مرتب زمنياً مع `balance_after` لكل حركة.
- [x] `POST /deposits` — تقديم طلب شحن (amount, method, tx_ref)، رفض فوري لو `tx_ref` مكرر.
- [x] `GET /deposits` — طلبات الشحن الخاصة بالمستخدم وحالتها.
- [x] `PATCH /admin/deposits/:id` — موافقة/رفض، بقفل الصف ومنع الموافقة المزدوجة (`WALLET.md` § 2.3-ب).
- [x] `PATCH /admin/users/:id` (تعديل رصيد يدوي) — يمر حصراً عبر `wallet.service.js`.
- [x] **اختبارات إلزامية** (`WALLET.md` § 4):
- [x] خصم أكبر من الرصيد يُرفض ولا يغيّر الرصيد.
- [x] عمليتان متزامنتان (parallel) على نفس الرصيد → وحدة بس تنجح.
- [x] موافقتان متزامنتان على نفس طلب شحن → إضافة وحدة بس.
- [x] `tx_ref` مكرر → رفض بدون لمس الرصيد.
- [x] بعد سلسلة عمليات عشوائية: `SUM(transactions.amount) == users.balance`.
- [x] job دوري (cron) للتسوية (Reconciliation) — يقارن `SUM(transactions)` مع `users.balance` ويرسل تنبيه عند أي فرق (`WALLET.md` § 2.4).
**معيار القبول:** كل الاختبارات أعلاه خضراء فعلياً (شغّلها، شوف النتيجة)، ولا يوجد أي مسار كود يعدّل `balance` خارج `wallet.service.js` (تأكد بالبحث النصي بالمشروع).
---
## المرحلة 4 — الخدمات (Services)
- [x] `provider.service.js` — طبقة موحّدة للتواصل مع API المزوّد (`services` / `add` / `status` / `balance`) حسب `AGENTS.md` § 5.
- [x] `POST /admin/services/sync` — سحب الخدمات من المزوّد وتخزينها/تحديثها محلياً.
- [x] منطق حساب `sell_price` من `provider_price` + الهامش (`DEFAULT_MARKUP_PERCENT` أو هامش لكل خدمة — حسب القرار المفتوح في `AGENTS.md` § 11).
- [x] `GET /services` — قائمة الخدمات النشطة فقط، بسعر البيع (لا تُظهر `provider_price` للمستخدم العادي أبداً).
- [x] `PATCH /admin/services/:id` — تعديل السعر/الهامش/التفعيل من لوحة الأدمن.
**معيار القبول:** مزامنة الخدمات تعمل، والمستخدم العادي ما يقدر يشوف سعر المزوّد الحقيقي بأي استجابة API.
---
## المرحلة 5 — الطلبات (Orders) — الجزء اللي يربط المحفظة بالمزوّد
- [x] `POST /orders` — تنفيذ التدفق الكامل خطوة بخطوة (`AGENTS.md` § 6):
1. تحقق الخدمة `is_active` والكمية ضمن `[min, max]`.
2. احسب `charge` **في الخادم فقط** (لا تثق بأي سعر من الواجهة).
3. `chargeBalance()` من المرحلة 3 (خصم + إنشاء order بحالة `pending` + transaction، الكل atomic).
4. أرسل الطلب لمزوّد الخدمة عبر `provider.service.js`.
5. نجاح → خزّن `provider_order_id`، حدّث `status=processing`.
6. فشل → استرجاع فوري عبر `creditBalance()` (type=`refund`)، حدّث `status=failed`.
- [x] `GET /orders` و`GET /orders/:id` — طلبات المستخدم (تأكد إنه يشوف طلباته هو فقط، لا طلبات غيره).
- [x] `GET /admin/orders` — كل الطلبات لعرض الأدمن.
**معيار القبول:** طلب ناجح يخصم المبلغ الصحيح بالضبط ويوصل provider_order_id، وطلب فاشل (مزوّد down مثلاً) يرجع الرصيد تلقائياً بدون تدخل يدوي.
---
## المرحلة 6 — متابعة الحالات (Status Tracking)
- [x] job دوري (مثلاً كل 5-10 دقائق) يمر على الطلبات بحالة `processing`/`in_progress` ويستعلم عن حالتها من المزوّد (`action: status`).
- [x] تحديث `status` و`remains` بناءً على رد المزوّد.
- [x] معالجة الحالات الجزئية (`partial`) — قرار: هل يُسترجع فرق المبلغ تلقائياً أم يحتاج مراجعة أدمن؟ (وضّح القرار بكود وتوثيق).
**معيار القبول:** طلب تجريبي يتحدث تلقائياً لحالته الحقيقية عند المزوّد خلال دورة الـ job التالية.
---
## المرحلة 7 — لوحة الأدمن
- [x] `GET /admin/users` + `PATCH /admin/users/:id` (حظر/رفع حظر، تعديل رصيد).
- [x] `GET /admin/deposits` (المعلّقة أولاً) + الموافقة/الرفض.
- [x] `GET /admin/stats` — إجمالي المبيعات، الأرباح (sell - provider cost)، عدد المستخدمين النشطين، طلبات معلّقة.
- [x] كل مسارات هذا القسم خلف middleware `admin` بدون استثناء — تحقق فعلياً بمحاولة وصول بحساب عادي وتأكد من 403.
**معيار القبول:** حساب `role=user` يحاول الوصول لأي مسار أدمن → 403، وحساب أدمن يشوف كل البيانات صح.
---
## المرحلة 8 — الواجهة (Frontend)
- [x] صفحات: تسجيل / دخول / لوحة المستخدم (الرصيد + كشف حساب) / الخدمات / إنشاء طلب / طلباتي / شحن رصيد.
- [x] لوحة أدمن منفصلة (خدمات، طلبات شحن، طلبات، مستخدمين، إحصائيات).
- [x] عرض الرصيد بشكل بارز دائماً (Header) ويتحدث فور أي عملية.
- [x] رسائل خطأ واضحة بالعربية (رصيد غير كافٍ، رابط غير صالح، إلخ).
**معيار القبول:** مستخدم جديد يقدر يسوي التدفق الكامل من الواجهة: تسجيل → طلب شحن → (موافقة أدمن) → شراء خدمة → متابعة الحالة، بدون لمس الـ API مباشرة.
---
## المرحلة 9 — نظام الألوان والتصميم المتجاوب
اقرأ `FRONTEND.md` § 3 كامل قبل البدء هون.
- [x] وحّد متغيرات اللون الأساسي بـ `styles.css` تحت `--primary-h` / `--primary-s` / `--primary-l` واشتق منها `--primary` / `--primary-hover` / `--primary-soft` (استبدل كل استخدام لـ `--accent` / `--accent-strong` / `--blue` بالمتغيرات الجديدة).
- [x] أضف زر Theme Picker بالـ `topbar`: 6 ألوان جاهزة (Presets) + خيار لون مخصص (`<input type="color">`).
- [x] عند اختيار لون: حوّل HEX إلى HSL بـ JS، حدّث المتغيرات الثلاث على `document.documentElement`، واحفظ الاختيار بـ `localStorage`.
- [x] طبّق اللون المحفوظ فوراً بأول تنفيذ للسكربت (قبل أول رسم) لمنع أي وميض بلون افتراضي.
- [x] **(تحسين مطلوب — راجع `FRONTEND.md` § 3.3 "نسخة منقّحة")** الزر الحالي دائرة صغيرة بدون تسمية وحسّه بدائي — بدّله بزر فيه أيقونة (SVG بسيط) + نص "المظهر" (يختفي على الموبايل، أيقونة بس).
- [x] لوحة الألوان: كبّر الشبكة (44px بدل 34px) وأضف اسم تحت كل لون (تركواز/أزرق/بنفسجي/أخضر/برتقالي/وردي).
- [x] بيّن اللون المفعّل حالياً بعلامة واضحة (حلقة حد + ✓) على الـ Preset المطابق.
- [x] قسم اللون المخصص: زد حجم `<input type="color">`، أضف حقل نصي HEX مرتبط ثنائياً معه، وشريحة معاينة حيّة تتحدث أثناء السحب قبل التثبيت.
- [x] اللوحة تنغلق بالضغط خارجها، بزر × صريح، أو بمفتاح Escape — مع انتقال فتح/إغلاق ناعم (`transition`).
- [x] لا تغيّر آلية الحفظ/التطبيق بـ `localStorage` ولا الـ HEX→HSL — هذا الجزء شغّال صح، خلّيه زي ما هو.
- [x] الألوان الدلالية (نجاح/تحذير/خطر لحالات الطلبات والحظر) تبقى ثابتة ومنفصلة عن اللون الأساسي — لا تتأثر بالثيمنج.
- [x] أضف كسرين إضافيين للتجاوب (640px و1024px) بدل الكسر الواحد الحالي (900px).
- [x] القائمة الجانبية: ثابتة ≥1024px، صف علوي بالتابلت، Drawer منزلق بزر ☰ تحت 640px مع خلفية قابلة للإغلاق.
- [x] الجداول الكثيفة (الطلبات، كشف الحساب، طلبات الشحن) تتحول لعرض بطاقات (Card view) تحت 640px بدل التمرير الأفقي.
- [x] `.inline-form` تتكدس تدريجياً (عمودين بالتابلت، عمود واحد بالموبايل).
- [x] ارتفاع الأزرار وحقول الإدخال لا يقل عن 44px بالموبايل.
- [x] `topbar` يلتف (`flex-wrap`) بدل ما يفيض خارج الشاشة على الموبايل.
**معيار القبول:** غيّر اللون الأساسي وأعد تحميل الصفحة — نفس اللون يرجع فوراً بدون وميض. افتح الموقع بعرض موبايل (375px) وتابلت (768px) ودسكتوب — كل صفحة (بما فيها الجداول والفورمات ولوحة الأدمن) قابلة للاستخدام بدون تمرير أفقي مزعج أو عناصر متراكبة على بعض.
---
## المرحلة 10 — طرق شحن رصيد حقيقية (PayPal / شام كاش / USDT ...)
اقرأ `FRONTEND.md` § 4 كامل قبل البدء هون. هذا تعديل على `WALLET.md` § 2.3 — يبقى نفس مبدأ "شحن يدوي بموافقة أدمن"، بس بقنوات محددة وتعليمات واضحة بدل حقل نص حر.
- [x] Migration: جدول `payment_methods` (`id`, `code` UNIQUE, `label_ar`, `instructions`, `destination`, `is_active`, `sort_order`, `created_at`, `updated_at`).
- [x] Migration/Seed: أضف قنوات ابتدائية على الأقل PayPal وشام كاش وUSDT (تعليمات placeholder، الأدمن يعبّي البيانات الحقيقية لاحقاً).
- [x] `GET /payment-methods` — القنوات النشطة فقط (يتطلب تسجيل دخول، أي مستخدم).
- [x] `GET /admin/payment-methods` — كل القنوات (نشطة وموقوفة).
- [x] `POST /admin/payment-methods` — إضافة قناة جديدة.
- [x] `PATCH /admin/payment-methods/:id` — تعديل/تفعيل/إيقاف قناة.
- [x] عدّل تحقق `POST /deposits` (`zod`): `method` لازم يطابق `code` قناة **نشطة** فعلياً، وإلا رفض فوري — لا تقبل أي نص حر بعد اليوم.
- [x] الواجهة: بدّل حقل "الطريقة" النصي بـ `<select>` معبّى من `GET /payment-methods` بالاسم العربي.
- [x] الواجهة: عند اختيار قناة، اعرض `instructions` و`destination` بصندوق بارز فوق حقل "مرجع التحويل"، وغيّر تسمية الحقل حسب القناة المختارة.
- [x] لوحة الأدمن: قسم جديد لإدارة قنوات الدفع (عرض/إضافة/تعديل/تفعيل-إيقاف) يستخدم مسارات `/admin/payment-methods`.
- [x] أعد تشغيل اختبارات `WALLET.md` § 4 (`npm run test:wallet`) للتأكد إن تعديل التحقق على `method` ما كسر منطق المحفظة أو الـ UNIQUE على `tx_ref`.
**معيار القبول:** فورم شحن الرصيد يعرض قنوات حقيقية بدل حقل فاضي، اختيار أي قناة يعرض تعليماتها، وإرسال طلب بقناة موقوفة أو غير موجودة يُرفض من الباك-إند (جرّبها مباشرة بـ curl/Postman مو بس من الواجهة). الأدمن يقدر يضيف/يوقف قناة من لوحة الأدمن وتنعكس فوراً بفورم المستخدم.
---
## المرحلة 11 — إغلاق ثغرات أمان لُقطت بفحص شامل (audit مباشر على السيرفر الحقيقي)
- [x] `PATCH /admin/users/:id`: امنع الأدمن من حظر حسابه هو نفسه (`req.params.id == req.user.id` و`status=banned` → ارفض بـ 400).
- [x] `PATCH /admin/users/:id`: امنع حظر آخر أدمن نشط متبقي بالنظام (عدّ `role=admin AND status=active` قبل التنفيذ، إذا صار العدد صفر بعد الحظر ارفض العملية).
- [x] `src/app.js`: قيّد `cors()` بدومين الموقع الحقيقي عبر متغير بيئة جديد (`CORS_ORIGIN`)، بدل السماح لأي دومين افتراضياً. خلّي القيمة الافتراضية بالتطوير مفتوحة بس بالإنتاج (`NODE_ENV=production`) إجباري تحديد الدومين، وإلا ارفض الإقلاع (زي `validateEnv`).
- [x] `src/routes/auth.routes.js`: أضف rate limiter لـ `POST /auth/forgot-password` (مثلاً 5 محاولات/15 دقيقة لكل IP، بنفس نمط `loginLimiter`).
- [x] `src/routes/deposit.routes.js`: أضف rate limiter على `POST /deposits` (مثلاً 10 طلبات/15 دقيقة لكل مستخدم) لمنع إغراق قائمة الأدمن.
- [x] (تحسين تجربة إضافي، اختياري بس مستحسن): بعد نجاح "حظر/تنشيط" من لوحة الأدمن، خلّي رسالة النجاح توضح التغيير فعلياً (مثال: "تم حظر المستخدم فلان" بدل رسالة عامة) عشان يكون واضح للأدمن إنه الإجراء نفّذ فعلاً.
**معيار القبول:** محاولة أدمن حظر نفسه أو آخر أدمن متبقي تُرفض برسالة واضحة. تشغيل السيرفر بـ `NODE_ENV=production` بدون `CORS_ORIGIN` يفشل بالإقلاع (زي باقي متغيرات الإنتاج الإلزامية). تكرار `POST /auth/forgot-password` أو `POST /deposits` أكتر من الحد المسموح يرجّع `429`. شغّل `npm test` بعدها للتأكد ما انكسر شي بالمسارات الموجودة.
---
## قبل ما تعتبر المشروع "جاهز"
- [x] راجع القرارات المفتوحة بـ `AGENTS.md` § 11 — لازم تكون كلها محسومة (مو افتراضات).
- [x] راجع كل بند بـ `AGENTS.md` § 8 (الأمان) وتأكد إنه مطبّق فعلياً، وحدة وحدة.
- [x] شغّل كل اختبارات المحفظة من المرحلة 3 مرة أخيرة بعد ربط كل الأجزاء مع بعض.
- [x] اعمل جولة يدوية كاملة (end-to-end) بحساب تجريبي: تسجيل → شحن → شراء → فشل متعمد (مزوّد وهمي يرجع خطأ) → تأكد الاسترجاع صحيح.
- [x] شغّل اختبارات المحفظة مرة أخيرة بعد المرحلة 10 (تعديل تحقق `method`) للتأكد إنه ما كسر شي.
- [x] جرّب الموقع فعلياً بثلاث أحجام شاشة (موبايل/تابلت/دسكتوب) بعد المرحلة 9 وتأكد ما في عناصر متكسرة أو نص مقطوع.
- [x] جرّب تغيير اللون الأساسي، أعد تحميل الصفحة، وتأكد إنه محفوظ.
- [x] جرّب طلب شحن بقناة PayPal وقناة شام كاش من الواجهة، وتأكد التعليمات تظهر صح لكل وحدة.
+188
View File
@@ -0,0 +1,188 @@
# 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؟"* — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.
+44
View File
@@ -0,0 +1,44 @@
services:
app:
build: .
environment:
PORT: 3000
NODE_ENV: development
DATABASE_URL: mysql://smm:smm_password@db:3306/smm
JWT_SECRET: replace_this_with_a_long_random_secret_32_chars_min
PROVIDER_API_URL: https://provider.example/api/v2
PROVIDER_API_KEY: replace_with_provider_key
DEFAULT_MARKUP_PERCENT: 25
ENABLE_JOBS: "false"
ORDER_STATUS_INTERVAL_MS: 300000
WALLET_RECONCILIATION_INTERVAL_MS: 86400000
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
db:
image: mariadb:11
environment:
MARIADB_DATABASE: smm
MARIADB_USER: smm
MARIADB_PASSWORD: smm_password
MARIADB_ROOT_PASSWORD: root_password
ports:
- "3306:3306"
volumes:
- smm_db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
volumes:
smm_db_data:
+34
View File
@@ -0,0 +1,34 @@
require('dotenv').config();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.warn('DATABASE_URL is not set. Migrations need a configured MySQL/MariaDB database.');
}
module.exports = {
development: {
client: 'mysql2',
connection: databaseUrl,
migrations: {
directory: './src/db/migrations',
extension: 'js'
},
pool: {
min: 0,
max: 10
}
},
production: {
client: 'mysql2',
connection: databaseUrl,
migrations: {
directory: './src/db/migrations',
extension: 'js'
},
pool: {
min: 2,
max: 20
}
}
};
+1831
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "smm-panel",
"version": "0.1.0",
"description": "SMM panel backend with wallet, provider services, and admin APIs.",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"admin:create": "node src/scripts/create-admin.js",
"migrate": "knex migrate:latest",
"migrate:rollback": "knex migrate:rollback",
"reconcile": "node src/jobs/reconciliation.job.js",
"sync:order-status": "node src/jobs/order-status.job.js",
"lint": "find src tests public -name '*.js' -print0 | xargs -0 -n1 node --check",
"test": "npm run lint && npm run test:wallet && npm run test:services && npm run test:orders && npm run test:status && npm run test:admin && npm run test:e2e && npm run reconcile",
"test:admin": "node --test tests/admin.test.js",
"test:e2e": "node --test tests/e2e.test.js",
"test:orders": "node --test tests/orders.test.js",
"test:services": "node --test tests/services.test.js",
"test:status": "node --test tests/order-status.test.js",
"test:wallet": "node --test tests/wallet.test.js"
},
"dependencies": {
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^8.6.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.3",
"knex": "^3.1.0",
"morgan": "^1.10.0",
"mysql2": "^3.11.5",
"pino": "^9.5.0",
"zod": "^4.4.3"
},
"engines": {
"node": ">=20"
}
}
+778
View File
@@ -0,0 +1,778 @@
const defaultPrimaryColor = '#0f766e';
const savedPrimaryColor = normalizeHex(localStorage.getItem('primaryColor') || '');
let currentPrimaryColor = savedPrimaryColor || defaultPrimaryColor;
if (savedPrimaryColor) {
applyPrimaryColor(savedPrimaryColor);
}
const state = {
accessToken: localStorage.getItem('accessToken'),
refreshToken: localStorage.getItem('refreshToken'),
user: null,
view: 'dashboard',
navOpen: false,
paymentMethods: [],
services: [],
orders: [],
deposits: [],
transactions: [],
admin: {
stats: null,
users: [],
services: [],
orders: [],
deposits: [],
paymentMethods: []
}
};
const views = [
{ id: 'dashboard', label: 'لوحة المستخدم', admin: false },
{ id: 'services', label: 'الخدمات والطلب', admin: false },
{ id: 'orders', label: 'طلباتي', admin: false },
{ id: 'deposits', label: 'شحن الرصيد', admin: false },
{ id: 'transactions', label: 'كشف الحساب', admin: false },
{ id: 'admin', label: 'لوحة الأدمن', admin: true }
];
const el = {
authScreen: document.querySelector('#authScreen'),
appShell: document.querySelector('#appShell'),
loginForm: document.querySelector('#loginForm'),
registerForm: document.querySelector('#registerForm'),
mainNav: document.querySelector('#mainNav'),
view: document.querySelector('#view'),
viewTitle: document.querySelector('#viewTitle'),
userLine: document.querySelector('#userLine'),
balanceValue: document.querySelector('#balanceValue'),
message: document.querySelector('#message'),
authMessage: document.querySelector('#authMessage'),
menuButton: document.querySelector('#menuButton'),
navBackdrop: document.querySelector('#navBackdrop'),
themePicker: document.querySelector('#themePicker'),
themeButton: document.querySelector('#themeButton'),
themeMenu: document.querySelector('#themeMenu'),
themeClose: document.querySelector('#themeClose'),
customColor: document.querySelector('#customColor'),
customHex: document.querySelector('#customHex'),
themePreview: document.querySelector('#themePreview'),
refreshButton: document.querySelector('#refreshButton'),
logoutButton: document.querySelector('#logoutButton')
};
const colorOptions = Array.from(document.querySelectorAll('[data-color]'));
function normalizeHex(value) {
const hex = value.trim();
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex.toLowerCase();
if (/^[0-9a-f]{6}$/i.test(hex)) return `#${hex.toLowerCase()}`;
return null;
}
function hexToHsl(hex) {
const normalized = hex.replace('#', '');
const r = parseInt(normalized.slice(0, 2), 16) / 255;
const g = parseInt(normalized.slice(2, 4), 16) / 255;
const b = parseInt(normalized.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
if (max === g) h = (b - r) / d + 2;
if (max === b) h = (r - g) / d + 4;
h /= 6;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
}
function applyPrimaryColor(hex) {
const hsl = hexToHsl(hex);
document.documentElement.style.setProperty('--primary-h', hsl.h);
document.documentElement.style.setProperty('--primary-s', `${hsl.s}%`);
document.documentElement.style.setProperty('--primary-l', `${hsl.l}%`);
}
function syncThemeControls(hex) {
currentPrimaryColor = hex.toLowerCase();
if (el.customColor) el.customColor.value = currentPrimaryColor;
if (el.customHex) el.customHex.value = currentPrimaryColor;
if (el.themePreview) el.themePreview.style.setProperty('--preview-color', currentPrimaryColor);
colorOptions.forEach((button) => {
button.classList.toggle('active', button.dataset.color.toLowerCase() === currentPrimaryColor);
});
}
function setPrimaryColor(hex) {
const normalized = normalizeHex(hex);
if (!normalized) return false;
applyPrimaryColor(normalized);
localStorage.setItem('primaryColor', normalized);
syncThemeControls(normalized);
return true;
}
function setThemeMenuOpen(isOpen) {
el.themePicker.classList.toggle('open', isOpen);
el.themeButton.setAttribute('aria-expanded', String(isOpen));
el.themeMenu.setAttribute('aria-hidden', String(!isOpen));
}
function formatStatus(value) {
const labels = {
active: 'نشط',
banned: 'محظور',
pending: 'معلق',
approved: 'مقبول',
rejected: 'مرفوض',
processing: 'قيد المعالجة',
in_progress: 'قيد التنفيذ',
completed: 'مكتمل',
partial: 'جزئي',
canceled: 'ملغي',
failed: 'فاشل'
};
return labels[value] || value || '-';
}
function showMessage(text, type = 'info') {
const target = el.appShell.classList.contains('hidden') ? el.authMessage : el.message;
target.textContent = friendlyMessage(text);
target.className = `message ${type === 'error' ? 'error' : ''}`;
target.classList.remove('hidden');
}
function clearMessage() {
el.message.classList.add('hidden');
el.authMessage.classList.add('hidden');
}
function friendlyMessage(text) {
const messages = {
'Insufficient balance': 'الرصيد غير كافٍ',
'Invalid email or password': 'بيانات الدخول غير صحيحة',
'Authentication required': 'سجل الدخول أولاً',
'Account is not active': 'الحساب غير نشط',
'Service is not available': 'الخدمة غير متاحة',
'Quantity is outside the service limits': 'الكمية خارج حدود الخدمة',
'Transaction reference is already used': 'مرجع التحويل مستخدم مسبقاً',
'Payment method is not available': 'طريقة الشحن غير متاحة',
'Payment method code is already used': 'كود قناة الدفع مستخدم مسبقاً',
'Provider API request failed': 'تعذر الاتصال بالمزوّد',
'Provider API returned an error': 'رفض المزوّد العملية'
};
return messages[text] || text;
}
function getFormData(form) {
return Object.fromEntries(new FormData(form).entries());
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'content-type': 'application/json',
...(state.accessToken ? { authorization: `Bearer ${state.accessToken}` } : {}),
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const message = body.error?.message || 'تعذر تنفيذ العملية';
throw new Error(message);
}
return body;
}
function setSession(payload) {
state.accessToken = payload.accessToken;
state.refreshToken = payload.refreshToken;
localStorage.setItem('accessToken', payload.accessToken);
localStorage.setItem('refreshToken', payload.refreshToken);
}
function clearSession() {
state.accessToken = null;
state.refreshToken = null;
state.user = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
function renderNav() {
el.mainNav.innerHTML = '';
for (const item of views) {
if (item.admin && state.user?.role !== 'admin') continue;
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.label;
button.className = state.view === item.id ? 'active' : '';
button.addEventListener('click', () => {
state.view = item.id;
state.navOpen = false;
render();
});
el.mainNav.append(button);
}
}
function row(headers, cells) {
return `<tr>${cells.map((cell, index) => `<td data-label="${headers[index] || ''}">${cell}</td>`).join('')}</tr>`;
}
function table(headers, rows, emptyText = 'لا توجد بيانات') {
if (!rows.length) return `<div class="surface empty">${emptyText}</div>`;
return `
<div class="table-wrap">
<table>
<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div>
`;
}
function statusBadge(status) {
return `<span class="status ${status}">${formatStatus(status)}</span>`;
}
function renderDashboard() {
const orderCount = state.orders.length;
const pendingCount = state.orders.filter((order) => ['pending', 'processing', 'in_progress'].includes(order.status)).length;
const depositCount = state.deposits.filter((deposit) => deposit.status === 'pending').length;
const spend = state.transactions
.filter((transaction) => transaction.type === 'order')
.reduce((total, transaction) => total + Math.abs(Number(transaction.amount)), 0)
.toFixed(4);
el.view.innerHTML = `
<div class="grid cols-4">
<div class="surface metric"><span class="muted">الطلبات</span><strong>${orderCount}</strong></div>
<div class="surface metric"><span class="muted">قيد التنفيذ</span><strong>${pendingCount}</strong></div>
<div class="surface metric"><span class="muted">شحن معلق</span><strong>${depositCount}</strong></div>
<div class="surface metric"><span class="muted">إجمالي الصرف</span><strong>${spend}</strong></div>
</div>
<section class="surface">
<div class="toolbar"><h2>آخر الطلبات</h2><button class="secondary small" data-view="orders">عرض الكل</button></div>
</section>
${table(['رقم', 'الخدمة', 'الكمية', 'المبلغ', 'الحالة'], state.orders.slice(0, 5).map((order) => row(['رقم', 'الخدمة', 'الكمية', 'المبلغ', 'الحالة'], [
order.id,
order.service_id,
order.quantity,
order.charge,
statusBadge(order.status)
])))}
`;
}
function renderServices() {
const options = state.services.map((service) => `<option value="${service.id}">${service.name} (${service.sell_price})</option>`).join('');
el.view.innerHTML = `
<form id="orderForm" class="surface inline-form">
<label>الخدمة<select name="service_id" required>${options}</select></label>
<label>الرابط<input name="link" type="url" required placeholder="https://"></label>
<label>الكمية<input name="quantity" type="number" min="1" required></label>
<button type="submit">تنفيذ الطلب</button>
</form>
${table(['رقم', 'الاسم', 'التصنيف', 'السعر لكل 1000', 'الحد الأدنى', 'الحد الأعلى'], state.services.map((service) => row(['رقم', 'الاسم', 'التصنيف', 'السعر لكل 1000', 'الحد الأدنى', 'الحد الأعلى'], [
service.id,
service.name,
service.category,
service.sell_price,
service.min,
service.max
])))}
`;
document.querySelector('#orderForm')?.addEventListener('submit', submitOrder);
}
function renderOrders() {
const headers = ['رقم', 'الخدمة', 'الرابط', 'الكمية', 'المبلغ', 'المتبقي', 'الحالة'];
el.view.innerHTML = table(headers, state.orders.map((order) => row(headers, [
order.id,
order.service_id,
order.link,
order.quantity,
order.charge,
order.remains ?? '-',
statusBadge(order.status)
])));
}
function renderDeposits() {
const selectedMethod = state.paymentMethods[0];
const methodOptions = state.paymentMethods.map((method) => `<option value="${method.code}">${method.label_ar}</option>`).join('');
const instructions = selectedMethod ? renderPaymentInstructions(selectedMethod) : '<div class="payment-instructions muted">لا توجد قنوات شحن متاحة حالياً.</div>';
el.view.innerHTML = `
<form id="depositForm" class="surface inline-form">
<label>المبلغ<input name="amount" inputmode="decimal" required placeholder="10.0000"></label>
<label>طريقة الشحن<select name="method" required ${state.paymentMethods.length ? '' : 'disabled'}>${methodOptions}</select></label>
<div id="paymentInstructions" class="form-wide">${instructions}</div>
<label id="txRefLabel">مرجع التحويل<input name="tx_ref" required></label>
<button type="submit" ${state.paymentMethods.length ? '' : 'disabled'}>إرسال</button>
</form>
${table(['رقم', 'المبلغ', 'الطريقة', 'المرجع', 'الحالة'], state.deposits.map((deposit) => row(['رقم', 'المبلغ', 'الطريقة', 'المرجع', 'الحالة'], [
deposit.id,
deposit.amount,
paymentMethodLabel(deposit.method),
deposit.tx_ref,
statusBadge(deposit.status)
])))}
`;
const depositForm = document.querySelector('#depositForm');
depositForm?.addEventListener('submit', submitDeposit);
depositForm?.elements.method?.addEventListener('change', updatePaymentInstruction);
updatePaymentInstruction();
}
function renderTransactions() {
const headers = ['رقم', 'النوع', 'المبلغ', 'الرصيد بعد العملية', 'المرجع', 'ملاحظة'];
el.view.innerHTML = table(headers, state.transactions.map((transaction) => row(headers, [
transaction.id,
transaction.type,
transaction.amount,
transaction.balance_after,
transaction.reference,
transaction.note || '-'
])));
}
function paymentMethodLabel(code) {
return [...state.paymentMethods, ...state.admin.paymentMethods].find((method) => method.code === code)?.label_ar || code;
}
function renderPaymentInstructions(method) {
return `
<div class="payment-instructions">
<strong>${method.label_ar}</strong>
<p>${method.instructions}</p>
${method.destination ? `<code>${method.destination}</code>` : ''}
</div>
`;
}
function txRefLabel(method) {
if (!method) return 'مرجع التحويل';
if (method.code.includes('paypal')) return 'رقم عملية PayPal';
if (method.code.includes('sham')) return 'رقم التحويل — شام كاش';
if (method.code.includes('usdt')) return 'TXID';
return `مرجع التحويل — ${method.label_ar}`;
}
function updatePaymentInstruction() {
const form = document.querySelector('#depositForm');
const selected = state.paymentMethods.find((method) => method.code === form?.elements.method?.value);
const box = document.querySelector('#paymentInstructions');
const label = document.querySelector('#txRefLabel');
if (box && selected) box.innerHTML = renderPaymentInstructions(selected);
if (label) label.firstChild.textContent = txRefLabel(selected);
}
function renderAdmin() {
const stats = state.admin.stats || {};
el.view.innerHTML = `
<div class="grid cols-4">
<div class="surface metric"><span class="muted">المستخدمون</span><strong>${stats.users_total ?? 0}</strong></div>
<div class="surface metric"><span class="muted">طلبات معلقة</span><strong>${stats.orders_pending ?? 0}</strong></div>
<div class="surface metric"><span class="muted">المبيعات</span><strong>${stats.gross_sales ?? '0.0000'}</strong></div>
<div class="surface metric"><span class="muted">الربح</span><strong>${stats.profit ?? '0.0000'}</strong></div>
</div>
<section class="surface">
<form id="syncServicesForm" class="inline-form">
<label>هامش المزامنة %<input name="markup_percent" placeholder="25"></label>
<button type="submit">مزامنة الخدمات</button>
</form>
</section>
<section class="surface"><h2>المستخدمون</h2></section>
${table(['رقم', 'اسم المستخدم', 'البريد', 'الدور', 'الحالة', 'الرصيد', 'إجراء'], state.admin.users.map((user) => row(['رقم', 'اسم المستخدم', 'البريد', 'الدور', 'الحالة', 'الرصيد', 'إجراء'], [
user.id,
user.username,
user.email,
user.role,
statusBadge(user.status),
user.balance,
`
<button class="small secondary" data-user-status="${user.id}" data-username="${user.username}" data-status="${user.status === 'active' ? 'banned' : 'active'}">${user.status === 'active' ? 'حظر' : 'تنشيط'}</button>
<button class="small" data-user-credit="${user.id}">تعديل رصيد</button>
`
])))}
<section class="surface"><h2>طلبات الشحن</h2></section>
${table(['رقم', 'مستخدم', 'المبلغ', 'الطريقة', 'المرجع', 'الحالة', 'إجراء'], state.admin.deposits.map((deposit) => row(['رقم', 'مستخدم', 'المبلغ', 'الطريقة', 'المرجع', 'الحالة', 'إجراء'], [
deposit.id,
deposit.user_id,
deposit.amount,
paymentMethodLabel(deposit.method),
deposit.tx_ref,
statusBadge(deposit.status),
deposit.status === 'pending' ? `<button class="small" data-deposit-action="${deposit.id}" data-action="approve">قبول</button> <button class="small danger" data-deposit-action="${deposit.id}" data-action="reject">رفض</button>` : '-'
])))}
<section class="surface">
<h2>قنوات الدفع</h2>
<form id="paymentMethodForm" class="inline-form admin-payment-form">
<label>الكود<input name="code" required placeholder="paypal"></label>
<label>الاسم<input name="label_ar" required placeholder="PayPal"></label>
<label>الوجهة<input name="destination" placeholder="pay@example.com"></label>
<label>الترتيب<input name="sort_order" type="number" min="0" value="0"></label>
<label class="form-wide">التعليمات<textarea name="instructions" required></textarea></label>
<button type="submit">إضافة قناة</button>
</form>
</section>
${table(['رقم', 'الكود', 'الاسم', 'الوجهة', 'الحالة', 'إجراء'], state.admin.paymentMethods.map((method) => row(['رقم', 'الكود', 'الاسم', 'الوجهة', 'الحالة', 'إجراء'], [
method.id,
method.code,
method.label_ar,
method.destination || '-',
statusBadge(method.is_active ? 'active' : 'banned'),
`<button class="small secondary" data-payment-toggle="${method.id}" data-active="${method.is_active ? 'false' : 'true'}">${method.is_active ? 'إيقاف' : 'تفعيل'}</button>`
])))}
<section class="surface"><h2>الخدمات</h2></section>
${table(['رقم', 'الاسم', 'التصنيف', 'سعر المزود', 'سعر البيع', 'الحالة', 'إجراء'], state.admin.services.map((service) => row(['رقم', 'الاسم', 'التصنيف', 'سعر المزود', 'سعر البيع', 'الحالة', 'إجراء'], [
service.id,
service.name,
service.category,
service.provider_price,
service.sell_price,
statusBadge(service.is_active ? 'active' : 'banned'),
`<button class="small secondary" data-service-toggle="${service.id}" data-active="${service.is_active ? 'false' : 'true'}">${service.is_active ? 'إيقاف' : 'تفعيل'}</button>`
])))}
<section class="surface"><h2>كل الطلبات</h2></section>
${table(['رقم', 'مستخدم', 'خدمة', 'الكمية', 'المبلغ', 'الحالة'], state.admin.orders.map((order) => row(['رقم', 'مستخدم', 'خدمة', 'الكمية', 'المبلغ', 'الحالة'], [
order.id,
order.user_id,
order.service_id,
order.quantity,
order.charge,
statusBadge(order.status)
])))}
`;
bindAdminActions();
}
function render() {
clearMessage();
const current = views.find((item) => item.id === state.view) || views[0];
el.viewTitle.textContent = current.label;
el.userLine.textContent = state.user ? `${state.user.username} · ${state.user.role}` : '';
el.balanceValue.textContent = state.user?.balance || '0.0000';
renderNav();
el.appShell.classList.toggle('nav-open', state.navOpen);
el.navBackdrop.classList.toggle('hidden', !state.navOpen);
if (state.view === 'dashboard') renderDashboard();
if (state.view === 'services') renderServices();
if (state.view === 'orders') renderOrders();
if (state.view === 'deposits') renderDeposits();
if (state.view === 'transactions') renderTransactions();
if (state.view === 'admin') renderAdmin();
document.querySelectorAll('[data-view]').forEach((button) => {
button.addEventListener('click', () => {
state.view = button.dataset.view;
render();
});
});
}
async function loadUserData() {
const [me, services, orders, deposits, transactions, paymentMethods] = await Promise.all([
api('/me'),
api('/services'),
api('/orders'),
api('/deposits'),
api('/transactions'),
api('/payment-methods')
]);
state.user = me.user;
state.services = services.services;
state.orders = orders.orders;
state.deposits = deposits.deposits;
state.transactions = transactions.transactions;
state.paymentMethods = paymentMethods.paymentMethods;
if (state.user.role === 'admin') {
const [stats, users, adminServices, adminOrders, adminDeposits, adminPaymentMethods] = await Promise.all([
api('/admin/stats'),
api('/admin/users'),
api('/admin/services'),
api('/admin/orders'),
api('/admin/deposits'),
api('/admin/payment-methods')
]);
state.admin.stats = stats.stats;
state.admin.users = users.users;
state.admin.services = adminServices.services;
state.admin.orders = adminOrders.orders;
state.admin.deposits = adminDeposits.deposits;
state.admin.paymentMethods = adminPaymentMethods.paymentMethods;
}
}
async function boot() {
if (!state.accessToken) {
el.authScreen.classList.remove('hidden');
el.appShell.classList.add('hidden');
return;
}
try {
await loadUserData();
el.authScreen.classList.add('hidden');
el.appShell.classList.remove('hidden');
render();
} catch (error) {
clearSession();
el.authScreen.classList.remove('hidden');
el.appShell.classList.add('hidden');
showMessage(error.message, 'error');
}
}
async function submitOrder(event) {
event.preventDefault();
const data = getFormData(event.currentTarget);
try {
await api('/orders', {
method: 'POST',
body: JSON.stringify({
service_id: Number(data.service_id),
link: data.link,
quantity: Number(data.quantity)
})
});
await loadUserData();
render();
showMessage('تم إنشاء الطلب بنجاح');
} catch (error) {
showMessage(error.message, 'error');
}
}
async function submitDeposit(event) {
event.preventDefault();
try {
await api('/deposits', {
method: 'POST',
body: JSON.stringify(getFormData(event.currentTarget))
});
await loadUserData();
render();
showMessage('تم إرسال طلب الشحن');
} catch (error) {
showMessage(error.message, 'error');
}
}
function bindAdminActions() {
document.querySelector('#syncServicesForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const data = getFormData(event.currentTarget);
try {
await api('/admin/services/sync', { method: 'POST', body: JSON.stringify(data.markup_percent ? data : {}) });
await loadUserData();
render();
showMessage('تمت مزامنة الخدمات');
} catch (error) {
showMessage(error.message, 'error');
}
});
document.querySelectorAll('[data-user-status]').forEach((button) => {
button.addEventListener('click', () => updateUser(button.dataset.userStatus, {
status: button.dataset.status,
username: button.dataset.username
}));
});
document.querySelectorAll('[data-user-credit]').forEach((button) => {
button.addEventListener('click', () => {
const amount = prompt('قيمة تعديل الرصيد، مثال: 10.0000 أو -5.0000');
if (amount) updateUser(button.dataset.userCredit, { amount, note: 'Manual admin adjustment' });
});
});
document.querySelectorAll('[data-deposit-action]').forEach((button) => {
button.addEventListener('click', () => reviewDeposit(button.dataset.depositAction, button.dataset.action));
});
document.querySelectorAll('[data-service-toggle]').forEach((button) => {
button.addEventListener('click', () => updateService(button.dataset.serviceToggle, button.dataset.active === 'true'));
});
document.querySelector('#paymentMethodForm')?.addEventListener('submit', createAdminPaymentMethod);
document.querySelectorAll('[data-payment-toggle]').forEach((button) => {
button.addEventListener('click', () => updatePaymentMethod(button.dataset.paymentToggle, { is_active: button.dataset.active === 'true' }));
});
}
async function createAdminPaymentMethod(event) {
event.preventDefault();
const data = getFormData(event.currentTarget);
try {
await api('/admin/payment-methods', {
method: 'POST',
body: JSON.stringify({
...data,
sort_order: Number(data.sort_order || 0)
})
});
await loadUserData();
render();
showMessage('تمت إضافة قناة الدفع');
} catch (error) {
showMessage(error.message, 'error');
}
}
async function updatePaymentMethod(methodId, body) {
try {
await api(`/admin/payment-methods/${methodId}`, { method: 'PATCH', body: JSON.stringify(body) });
await loadUserData();
render();
showMessage('تم تحديث قناة الدفع');
} catch (error) {
showMessage(error.message, 'error');
}
}
async function updateUser(userId, body) {
try {
const { username, ...payload } = body;
await api(`/admin/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) });
await loadUserData();
render();
if (payload.status === 'banned') {
showMessage(`تم حظر المستخدم ${username || userId}`);
} else if (payload.status === 'active') {
showMessage(`تم تنشيط المستخدم ${username || userId}`);
} else {
showMessage('تم تحديث المستخدم');
}
} catch (error) {
showMessage(error.message, 'error');
}
}
async function reviewDeposit(depositId, action) {
try {
await api(`/admin/deposits/${depositId}`, { method: 'PATCH', body: JSON.stringify({ action }) });
await loadUserData();
render();
showMessage('تم تحديث طلب الشحن');
} catch (error) {
showMessage(error.message, 'error');
}
}
async function updateService(serviceId, isActive) {
try {
await api(`/admin/services/${serviceId}`, { method: 'PATCH', body: JSON.stringify({ is_active: isActive }) });
await loadUserData();
render();
showMessage('تم تحديث الخدمة');
} catch (error) {
showMessage(error.message, 'error');
}
}
el.loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
try {
const payload = await api('/auth/login', { method: 'POST', body: JSON.stringify(getFormData(event.currentTarget)) });
setSession(payload);
await boot();
} catch (error) {
showMessage(error.message, 'error');
}
});
el.registerForm.addEventListener('submit', async (event) => {
event.preventDefault();
try {
await api('/auth/register', { method: 'POST', body: JSON.stringify(getFormData(event.currentTarget)) });
showMessage('تم إنشاء الحساب، سجل الدخول الآن');
} catch (error) {
showMessage(error.message, 'error');
}
});
el.refreshButton.addEventListener('click', async () => {
await loadUserData();
render();
});
el.menuButton.addEventListener('click', () => {
state.navOpen = true;
render();
});
el.navBackdrop.addEventListener('click', () => {
state.navOpen = false;
render();
});
el.themeButton.addEventListener('click', () => {
setThemeMenuOpen(!el.themePicker.classList.contains('open'));
});
el.themeClose.addEventListener('click', () => {
setThemeMenuOpen(false);
});
document.addEventListener('click', (event) => {
if (!el.themePicker.contains(event.target)) {
setThemeMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setThemeMenuOpen(false);
}
});
colorOptions.forEach((button) => {
button.addEventListener('click', () => {
setPrimaryColor(button.dataset.color);
setThemeMenuOpen(false);
});
});
el.customColor.addEventListener('input', (event) => {
setPrimaryColor(event.target.value);
});
el.customHex.addEventListener('input', (event) => {
const normalized = normalizeHex(event.target.value);
if (normalized) setPrimaryColor(normalized);
});
el.logoutButton.addEventListener('click', async () => {
try {
await api('/auth/logout', { method: 'POST', body: JSON.stringify({ refreshToken: state.refreshToken }) });
} finally {
clearSession();
location.reload();
}
});
syncThemeControls(currentPrimaryColor);
boot();
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMM Panel</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="authScreen" class="auth-shell">
<section class="auth-panel">
<div class="brand-row">
<span class="brand-mark" aria-hidden="true"></span>
<div>
<h1>SMM Panel</h1>
<p>إدارة الطلبات والرصيد والخدمات</p>
</div>
</div>
<div class="auth-grid">
<form id="loginForm" class="surface form-stack">
<h2>تسجيل الدخول</h2>
<label>البريد الإلكتروني<input name="email" type="email" autocomplete="email" required></label>
<label>كلمة المرور<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">دخول</button>
</form>
<form id="registerForm" class="surface form-stack">
<h2>حساب جديد</h2>
<label>اسم المستخدم<input name="username" autocomplete="username" required></label>
<label>البريد الإلكتروني<input name="email" type="email" autocomplete="email" required></label>
<label>كلمة المرور<input name="password" type="password" autocomplete="new-password" minlength="8" required></label>
<button type="submit">إنشاء الحساب</button>
</form>
</div>
<div id="authMessage" class="message hidden"></div>
</section>
</div>
<div id="appShell" class="app-shell hidden">
<aside class="sidebar">
<div class="brand-row compact">
<span class="brand-mark" aria-hidden="true"></span>
<strong>SMM Panel</strong>
</div>
<nav id="mainNav" class="nav-list"></nav>
</aside>
<main class="main">
<header class="topbar">
<div>
<p id="userLine" class="muted"></p>
<h1 id="viewTitle">لوحة المستخدم</h1>
</div>
<div class="top-actions">
<div class="balance-box">
<span>الرصيد</span>
<strong id="balanceValue">0.0000</strong>
</div>
<button id="menuButton" type="button" class="secondary icon-button mobile-only" aria-label="فتح القائمة">☰</button>
<div id="themePicker" class="theme-picker">
<button id="themeButton" type="button" class="theme-trigger" aria-expanded="false" aria-controls="themeMenu">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3.5a8.5 8.5 0 0 0 0 17h1.2a2.2 2.2 0 0 0 1.6-3.7 1.25 1.25 0 0 1 .9-2.1H17a4 4 0 0 0 4-4C21 6.7 17 3.5 12 3.5Z"></path>
<circle cx="7.8" cy="11" r="1.2"></circle>
<circle cx="10.8" cy="7.8" r="1.2"></circle>
<circle cx="15" cy="8.2" r="1.2"></circle>
</svg>
<span>المظهر</span>
</button>
<div id="themeMenu" class="theme-menu" aria-hidden="true">
<div class="theme-menu-head">
<strong>اختر اللون الأساسي</strong>
<button id="themeClose" type="button" class="theme-close" aria-label="إغلاق">×</button>
</div>
<div class="theme-grid">
<button type="button" class="color-option" data-color="#0f766e" style="--swatch-color:#0f766e" aria-label="تركواز"><span></span><small>تركواز</small></button>
<button type="button" class="color-option" data-color="#2563eb" style="--swatch-color:#2563eb" aria-label="أزرق"><span></span><small>أزرق</small></button>
<button type="button" class="color-option" data-color="#7c3aed" style="--swatch-color:#7c3aed" aria-label="بنفسجي"><span></span><small>بنفسجي</small></button>
<button type="button" class="color-option" data-color="#16a34a" style="--swatch-color:#16a34a" aria-label="أخضر"><span></span><small>أخضر</small></button>
<button type="button" class="color-option" data-color="#ea580c" style="--swatch-color:#ea580c" aria-label="برتقالي"><span></span><small>برتقالي</small></button>
<button type="button" class="color-option" data-color="#db2777" style="--swatch-color:#db2777" aria-label="وردي"><span></span><small>وردي</small></button>
</div>
<div class="custom-color">
<label>لون مخصص<input id="customColor" type="color" value="#0f766e"></label>
<label>الكود<input id="customHex" dir="ltr" maxlength="7" value="#0f766e" spellcheck="false"></label>
<span id="themePreview" class="theme-preview">معاينة</span>
</div>
</div>
</div>
<button id="refreshButton" type="button" class="secondary">تحديث</button>
<button id="logoutButton" type="button" class="danger">خروج</button>
</div>
</header>
<div id="message" class="message hidden"></div>
<section id="view" class="view"></section>
</main>
<button id="navBackdrop" type="button" class="nav-backdrop hidden" aria-label="إغلاق القائمة"></button>
</div>
<script src="/app.js"></script>
</body>
</html>
+746
View File
@@ -0,0 +1,746 @@
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--line: #d8dee6;
--text: #20242a;
--muted: #667085;
--primary-h: 174;
--primary-s: 64%;
--primary-l: 26%;
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--primary-hover: hsl(var(--primary-h) var(--primary-s) calc(var(--primary-l) - 8%));
--primary-soft: hsl(var(--primary-h) var(--primary-s) 94%);
--primary-text-on: #ffffff;
--warn: #b45309;
--danger: #b42318;
--ink: #111827;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Arial, "Noto Sans Arabic", sans-serif;
letter-spacing: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 0;
border-radius: 6px;
background: var(--primary);
color: var(--primary-text-on);
min-height: 38px;
padding: 0 14px;
cursor: pointer;
}
button:hover {
background: var(--primary-hover);
}
button.secondary {
background: #e7edf3;
color: var(--ink);
}
button.danger {
background: var(--danger);
}
button.small {
min-height: 30px;
padding: 0 10px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
min-height: 38px;
padding: 8px 10px;
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
textarea {
min-height: 74px;
resize: vertical;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 18px;
}
h3 {
font-size: 16px;
}
.hidden {
display: none !important;
}
.auth-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.auth-panel {
width: min(980px, 100%);
}
.auth-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.surface {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
.brand-row {
display: flex;
align-items: center;
gap: 12px;
}
.brand-row.compact {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 8px;
background:
linear-gradient(135deg, var(--primary) 0 45%, #f59e0b 45% 68%, #2563eb 68% 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
flex: 0 0 auto;
}
.form-stack {
display: grid;
gap: 12px;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
}
.sidebar {
background: #1f2933;
color: #fff;
}
.nav-list {
display: grid;
gap: 4px;
padding: 12px;
}
.nav-list button {
width: 100%;
justify-content: start;
text-align: right;
background: transparent;
color: #d7dee8;
}
.nav-list button.active,
.nav-list button:hover {
background: var(--primary);
color: #fff;
}
.main {
min-width: 0;
padding: 18px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mobile-only {
display: none;
}
.icon-button {
min-width: 44px;
padding: 0;
}
.theme-picker {
position: relative;
}
.theme-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
min-width: 44px;
padding: 0 12px;
}
.theme-trigger:hover {
background: var(--primary-soft);
}
.theme-trigger svg {
width: 20px;
height: 20px;
fill: none;
stroke: var(--primary);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 20;
display: grid;
gap: 14px;
width: min(312px, calc(100vw - 28px));
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 14px 34px rgba(17, 24, 39, 0.16);
opacity: 0;
pointer-events: none;
transform: translateY(-8px) scale(0.98);
transition: opacity 160ms ease, transform 160ms ease;
}
.theme-picker.open .theme-menu {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.theme-menu-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.theme-close {
min-width: 34px;
min-height: 34px;
padding: 0;
border-radius: 6px;
background: #eef2f6;
color: var(--ink);
font-size: 20px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.color-option {
position: relative;
display: grid;
justify-items: center;
gap: 6px;
min-height: 72px;
padding: 6px 4px;
border: 1px solid transparent;
border-radius: 8px;
background: #fff;
color: var(--text);
}
.color-option:hover {
background: #f8fafc;
}
.color-option span {
position: relative;
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--swatch-color);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.82), 0 0 0 1px var(--line);
}
.color-option small {
font-size: 12px;
color: var(--muted);
}
.color-option.active {
border-color: var(--ink);
background: var(--primary-soft);
}
.color-option.active span {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.86), 0 0 0 3px var(--ink);
}
.color-option.active span::after {
content: "✓";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-size: 20px;
font-weight: 800;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
.custom-color {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 10px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.custom-color input[type="color"] {
width: 100%;
min-height: 46px;
padding: 2px;
}
.theme-preview {
grid-column: 1 / -1;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
border-radius: 6px;
background: var(--preview-color, var(--primary));
color: #fff;
font-weight: 700;
}
.balance-box {
display: grid;
gap: 2px;
min-width: 132px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 8px 12px;
}
.balance-box span,
.muted {
color: var(--muted);
font-size: 13px;
}
.balance-box strong {
font-size: 20px;
color: var(--primary-hover);
}
.message {
border: 1px solid var(--line);
border-right: 4px solid var(--primary);
background: #fff;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 12px;
}
.message.error {
border-right-color: var(--danger);
}
.view {
display: grid;
gap: 14px;
}
.grid {
display: grid;
gap: 12px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 22px;
}
.toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.inline-form {
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr)) auto;
gap: 10px;
align-items: end;
}
.form-wide {
grid-column: 1 / -1;
}
.payment-instructions {
display: grid;
gap: 6px;
border: 1px solid hsl(var(--primary-h) var(--primary-s) 82%);
border-radius: 8px;
background: var(--primary-soft);
color: var(--text);
padding: 12px;
}
.payment-instructions code {
display: block;
width: fit-content;
max-width: 100%;
border-radius: 6px;
background: #fff;
color: var(--ink);
overflow-wrap: anywhere;
padding: 6px 8px;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px;
text-align: right;
vertical-align: middle;
white-space: nowrap;
}
th {
background: #eef2f6;
color: #344054;
font-weight: 700;
}
tr:last-child td {
border-bottom: 0;
}
.status {
display: inline-block;
min-width: 78px;
border-radius: 999px;
padding: 4px 8px;
background: #eef2f6;
color: #344054;
text-align: center;
font-size: 12px;
}
.status.active,
.status.approved,
.status.completed,
.status.processing {
background: #dcfce7;
color: #166534;
}
.status.pending,
.status.partial {
background: #fef3c7;
color: #92400e;
}
.status.banned,
.status.failed,
.status.rejected,
.status.canceled {
background: #fee2e2;
color: #991b1b;
}
.empty {
color: var(--muted);
padding: 18px;
}
@media (max-width: 1023px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
align-self: start;
border-bottom: 1px solid var(--line);
}
.brand-row.compact {
display: none;
}
.nav-list {
display: flex;
overflow-x: auto;
padding: 10px 18px;
}
.nav-list button {
width: auto;
white-space: nowrap;
}
.grid.cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inline-form {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
}
@media (max-width: 640px) {
.auth-grid,
.grid.cols-2,
.grid.cols-4 {
grid-template-columns: 1fr;
}
button,
input,
select,
textarea {
min-height: 44px;
}
button.small {
min-height: 38px;
}
.mobile-only {
display: inline-grid;
place-items: center;
}
.app-shell {
display: block;
}
.sidebar {
position: fixed;
inset: 0 0 auto 0;
z-index: 30;
transform: translateY(-105%);
transition: transform 160ms ease;
box-shadow: 0 20px 36px rgba(17, 24, 39, 0.25);
}
.app-shell.nav-open .sidebar {
transform: translateY(0);
}
.nav-list {
display: grid;
max-height: 72vh;
overflow: auto;
padding: 14px;
}
.nav-list button {
width: 100%;
min-height: 46px;
}
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 25;
border: 0;
border-radius: 0;
background: rgba(17, 24, 39, 0.42);
cursor: pointer;
}
.topbar,
.toolbar {
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
}
.top-actions {
width: 100%;
}
.theme-trigger {
padding: 0;
justify-content: center;
}
.theme-trigger span {
display: none;
}
.theme-menu {
left: auto;
right: 0;
}
.balance-box {
min-width: 0;
flex: 1 1 130px;
}
.inline-form {
grid-template-columns: 1fr;
}
.main {
padding: 14px;
}
.table-wrap {
overflow: visible;
border: 0;
background: transparent;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
min-width: 0;
}
thead {
display: none;
}
tr {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 8px 10px;
}
tr + tr {
margin-top: 10px;
}
td {
display: grid;
grid-template-columns: minmax(96px, 38%) minmax(0, 1fr);
gap: 8px;
border-bottom: 1px solid #eef2f6;
padding: 9px 0;
white-space: normal;
overflow-wrap: anywhere;
}
td::before {
content: attr(data-label);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
tr:last-child td {
border-bottom: 1px solid #eef2f6;
}
td:last-child {
border-bottom: 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
BACKUP_DIR="${BACKUP_DIR:-backups}"
CONTAINER="${DB_CONTAINER:-ss-db-1}"
DATABASE="${DB_NAME:-smm}"
USER="${DB_USER:-smm}"
PASSWORD="${DB_PASSWORD:-smm_password}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
OUTPUT="${BACKUP_DIR}/${DATABASE}-${TIMESTAMP}.sql"
mkdir -p "$BACKUP_DIR"
docker exec "$CONTAINER" mariadb-dump \
--user="$USER" \
--password="$PASSWORD" \
--single-transaction \
--routines \
--triggers \
"$DATABASE" > "$OUTPUT"
printf 'Backup written to %s\n' "$OUTPUT"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env sh
set -eu
if [ "${CONFIRM_RESTORE:-}" != "yes" ]; then
printf 'Refusing to restore without CONFIRM_RESTORE=yes\n' >&2
exit 1
fi
if [ "${BACKUP_FILE:-}" = "" ]; then
printf 'BACKUP_FILE is required\n' >&2
exit 1
fi
if [ ! -f "$BACKUP_FILE" ]; then
printf 'Backup file not found: %s\n' "$BACKUP_FILE" >&2
exit 1
fi
CONTAINER="${DB_CONTAINER:-ss-db-1}"
DATABASE="${DB_NAME:-smm}"
USER="${DB_USER:-smm}"
PASSWORD="${DB_PASSWORD:-smm_password}"
docker exec -i "$CONTAINER" mariadb \
--user="$USER" \
--password="$PASSWORD" \
"$DATABASE" < "$BACKUP_FILE"
printf 'Restored %s into %s/%s\n' "$BACKUP_FILE" "$CONTAINER" "$DATABASE"
+78
View File
@@ -0,0 +1,78 @@
const cors = require('cors');
const cookieParser = require('cookie-parser');
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const path = require('path');
const { AppError, errorHandler, notFoundHandler } = require('./middleware/error-handler');
const { adminRouter } = require('./routes/admin.routes');
const { authRouter } = require('./routes/auth.routes');
const { depositRouter } = require('./routes/deposit.routes');
const { healthRouter } = require('./routes/health.routes');
const { meRouter } = require('./routes/me.routes');
const { orderRouter } = require('./routes/order.routes');
const { paymentMethodRouter } = require('./routes/payment-method.routes');
const { serviceRouter } = require('./routes/service.routes');
const { transactionRouter } = require('./routes/transaction.routes');
const { logger, morganStream } = require('./utils/logger');
function corsOptions() {
if (process.env.NODE_ENV !== 'production') {
return {};
}
const allowedOrigins = new Set(
(process.env.CORS_ORIGIN || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
);
return {
origin(origin, callback) {
if (!origin || allowedOrigins.has(origin)) {
callback(null, true);
return;
}
callback(new AppError('Origin is not allowed', 403, 'CORS_ORIGIN_FORBIDDEN'));
}
};
}
function createApp() {
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(cors(corsOptions()));
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(morgan('combined', { stream: morganStream }));
app.use('/auth', authRouter);
app.use('/admin', adminRouter);
app.use('/deposits', depositRouter);
app.use('/health', healthRouter);
app.use('/me', meRouter);
app.use('/orders', orderRouter);
app.use('/payment-methods', paymentMethodRouter);
app.use('/services', serviceRouter);
app.use('/transactions', transactionRouter);
app.use(express.static(path.join(__dirname, '..', 'public')));
app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
app.use(notFoundHandler);
app.use(errorHandler);
app.locals.logger = logger;
app.locals.AppError = AppError;
return app;
}
module.exports = { createApp };
+67
View File
@@ -0,0 +1,67 @@
const knex = require('knex');
const { logger } = require('../utils/logger');
let db;
function createDbPool() {
if (!process.env.DATABASE_URL) {
return null;
}
return knex({
client: 'mysql2',
connection: process.env.DATABASE_URL,
pool: {
min: 0,
max: Number(process.env.DB_POOL_MAX || 10)
}
});
}
function getDb() {
if (!db) {
db = createDbPool();
}
return db;
}
async function pingDb() {
const pool = getDb();
if (!pool) {
return {
configured: false,
ok: true,
message: 'DATABASE_URL is not configured'
};
}
await pool.raw('SELECT 1 AS ok');
return {
configured: true,
ok: true
};
}
async function closeDbPool() {
if (!db) {
return;
}
try {
await db.destroy();
} catch (error) {
logger.error({ error }, 'Failed to close database pool');
} finally {
db = null;
}
}
module.exports = {
closeDbPool,
getDb,
pingDb
};
+32
View File
@@ -0,0 +1,32 @@
require('dotenv').config();
const requiredInProduction = ['DATABASE_URL', 'JWT_SECRET', 'PROVIDER_API_URL', 'PROVIDER_API_KEY', 'CORS_ORIGIN'];
const placeholderValues = new Set([
'change_me_long_random',
'your_provider_key',
'https://provider.example/api/v2'
]);
function validateEnv() {
if (process.env.NODE_ENV !== 'production') {
return;
}
const missing = requiredInProduction.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
const placeholders = requiredInProduction.filter((key) => placeholderValues.has(process.env[key]));
if (placeholders.length > 0) {
throw new Error(`Replace placeholder environment variables before production: ${placeholders.join(', ')}`);
}
if (process.env.JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters in production');
}
}
module.exports = {
validateEnv
};
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,116 @@
exports.up = async function up(knex) {
await knex.schema.createTable('users', (table) => {
table.bigIncrements('id').primary();
table.string('username', 50).notNullable().unique();
table.string('email', 120).notNullable().unique();
table.string('password_hash', 255).notNullable();
table.decimal('balance', 12, 4).notNullable().defaultTo(0);
table.enu('role', ['user', 'admin']).notNullable().defaultTo('user');
table.enu('status', ['active', 'banned']).notNullable().defaultTo('active');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
});
await knex.schema.createTable('services', (table) => {
table.bigIncrements('id').primary();
table.string('provider_service_id', 50).notNullable().unique();
table.string('name', 255).notNullable();
table.string('category', 100).notNullable();
table.decimal('provider_price', 12, 4).notNullable();
table.decimal('sell_price', 12, 4).notNullable();
table.integer('min').unsigned().notNullable();
table.integer('max').unsigned().notNullable();
table.boolean('is_active').notNullable().defaultTo(true);
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
table.index(['category', 'is_active']);
});
await knex.schema.createTable('orders', (table) => {
table.bigIncrements('id').primary();
table.bigInteger('user_id').unsigned().notNullable();
table.bigInteger('service_id').unsigned().notNullable();
table.string('link', 500).notNullable();
table.integer('quantity').unsigned().notNullable();
table.decimal('charge', 12, 4).notNullable();
table.string('provider_order_id', 80).nullable();
table.enu('status', ['pending', 'processing', 'in_progress', 'completed', 'partial', 'canceled', 'failed']).notNullable().defaultTo('pending');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
table.foreign('user_id').references('id').inTable('users').onDelete('RESTRICT').onUpdate('CASCADE');
table.foreign('service_id').references('id').inTable('services').onDelete('RESTRICT').onUpdate('CASCADE');
table.index(['user_id', 'created_at']);
table.index(['status', 'created_at']);
table.index('provider_order_id');
});
await knex.schema.createTable('transactions', (table) => {
table.bigIncrements('id').primary();
table.bigInteger('user_id').unsigned().notNullable();
table.enu('type', ['deposit', 'order', 'refund']).notNullable();
table.decimal('amount', 12, 4).notNullable();
table.decimal('balance_after', 12, 4).notNullable();
table.string('reference', 120).notNullable();
table.string('note', 255).nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.foreign('user_id').references('id').inTable('users').onDelete('RESTRICT').onUpdate('CASCADE');
table.unique(['user_id', 'reference', 'type']);
table.index(['user_id', 'created_at']);
});
await knex.schema.createTable('deposits', (table) => {
table.bigIncrements('id').primary();
table.bigInteger('user_id').unsigned().notNullable();
table.decimal('amount', 12, 4).notNullable();
table.string('method', 50).notNullable();
table.string('tx_ref', 200).notNullable().unique();
table.enu('status', ['pending', 'approved', 'rejected']).notNullable().defaultTo('pending');
table.bigInteger('reviewed_by').unsigned().nullable();
table.string('note', 255).nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
table.foreign('user_id').references('id').inTable('users').onDelete('RESTRICT').onUpdate('CASCADE');
table.foreign('reviewed_by').references('id').inTable('users').onDelete('SET NULL').onUpdate('CASCADE');
table.index(['user_id', 'created_at']);
table.index(['status', 'created_at']);
});
await knex.schema.createTable('idempotency_keys', (table) => {
table.string('key', 64).primary();
table.bigInteger('user_id').unsigned().notNullable();
table.string('endpoint', 100).notNullable();
table.json('response_snapshot').nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE').onUpdate('CASCADE');
table.index(['user_id', 'endpoint']);
table.index('created_at');
});
await knex.schema.createTable('refresh_tokens', (table) => {
table.bigIncrements('id').primary();
table.bigInteger('user_id').unsigned().notNullable();
table.string('token_hash', 255).notNullable().unique();
table.timestamp('expires_at').notNullable();
table.timestamp('revoked_at').nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE').onUpdate('CASCADE');
table.index(['user_id', 'revoked_at']);
table.index('expires_at');
});
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('refresh_tokens');
await knex.schema.dropTableIfExists('idempotency_keys');
await knex.schema.dropTableIfExists('deposits');
await knex.schema.dropTableIfExists('transactions');
await knex.schema.dropTableIfExists('orders');
await knex.schema.dropTableIfExists('services');
await knex.schema.dropTableIfExists('users');
};
@@ -0,0 +1,18 @@
exports.up = async function up(knex) {
await knex.schema.createTable('password_reset_tokens', (table) => {
table.bigIncrements('id').primary();
table.bigInteger('user_id').unsigned().notNullable();
table.string('token_hash', 255).notNullable().unique();
table.timestamp('expires_at').notNullable();
table.timestamp('used_at').nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE').onUpdate('CASCADE');
table.index(['user_id', 'used_at']);
table.index('expires_at');
});
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('password_reset_tokens');
};
@@ -0,0 +1,7 @@
exports.up = async function up(knex) {
await knex.raw("ALTER TABLE transactions MODIFY type ENUM('deposit','order','refund','adjustment') NOT NULL");
};
exports.down = async function down(knex) {
await knex.raw("ALTER TABLE transactions MODIFY type ENUM('deposit','order','refund') NOT NULL");
};
@@ -0,0 +1,11 @@
exports.up = async function up(knex) {
await knex.schema.alterTable('services', (table) => {
table.decimal('markup_percent', 8, 4).notNullable().defaultTo(0).after('provider_price');
});
};
exports.down = async function down(knex) {
await knex.schema.alterTable('services', (table) => {
table.dropColumn('markup_percent');
});
};
@@ -0,0 +1,17 @@
exports.up = async function up(knex) {
await knex.schema.alterTable('orders', (table) => {
table.integer('start_count').unsigned().nullable().after('provider_order_id');
table.integer('remains').unsigned().nullable().after('start_count');
table.decimal('provider_charge', 12, 4).nullable().after('remains');
table.timestamp('last_status_check_at').nullable().after('provider_charge');
});
};
exports.down = async function down(knex) {
await knex.schema.alterTable('orders', (table) => {
table.dropColumn('last_status_check_at');
table.dropColumn('provider_charge');
table.dropColumn('remains');
table.dropColumn('start_count');
});
};
@@ -0,0 +1,43 @@
exports.up = async function up(knex) {
await knex.schema.createTable('payment_methods', (table) => {
table.bigIncrements('id').primary();
table.string('code', 30).notNullable().unique();
table.string('label_ar', 100).notNullable();
table.text('instructions').notNullable();
table.string('destination', 255).nullable();
table.boolean('is_active').notNullable().defaultTo(true);
table.integer('sort_order').notNullable().defaultTo(0);
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
table.index(['is_active', 'sort_order']);
});
await knex('payment_methods').insert([
{
code: 'paypal',
label_ar: 'PayPal',
instructions: 'حوّل المبلغ إلى حساب PayPal الموضح، ثم أدخل رقم العملية أو البريد المستخدم في التحويل.',
destination: 'ضع بريد PayPal هنا',
sort_order: 10
},
{
code: 'sham_cash',
label_ar: 'شام كاش',
instructions: 'حوّل المبلغ إلى رقم شام كاش الموضح، ثم أدخل رقم عملية التحويل.',
destination: 'ضع رقم شام كاش هنا',
sort_order: 20
},
{
code: 'usdt_trc20',
label_ar: 'USDT TRC20',
instructions: 'حوّل USDT عبر شبكة TRC20 إلى العنوان الموضح فقط، ثم أدخل TXID.',
destination: 'ضع عنوان USDT TRC20 هنا',
sort_order: 30
}
]);
};
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('payment_methods');
};
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const { closeDbPool } = require('../config/db');
const { syncOrderStatuses } = require('../services/orders.service');
const { logger } = require('../utils/logger');
async function main() {
const result = await syncOrderStatuses(Number(process.env.ORDER_STATUS_SYNC_LIMIT || 100));
logger.info(result, 'Order status sync completed');
if (result.failed > 0) {
process.exitCode = 1;
}
}
main()
.catch((error) => {
logger.error({ error }, 'Order status sync failed');
process.exitCode = 1;
})
.finally(() => closeDbPool());
+29
View File
@@ -0,0 +1,29 @@
const { syncOrderStatuses } = require('../services/orders.service');
const { logger } = require('../utils/logger');
function startOrderStatusScheduler() {
if (process.env.ENABLE_JOBS !== 'true') {
return () => {};
}
const intervalMs = Number(process.env.ORDER_STATUS_INTERVAL_MS || 5 * 60 * 1000);
async function syncStatuses() {
try {
const result = await syncOrderStatuses(Number(process.env.ORDER_STATUS_SYNC_LIMIT || 100));
logger.info(result, 'Order status sync completed');
} catch (error) {
logger.error({ error }, 'Order status sync failed');
}
}
const interval = setInterval(syncStatuses, intervalMs);
interval.unref();
syncStatuses();
return () => clearInterval(interval);
}
module.exports = {
startOrderStatusScheduler
};
+24
View File
@@ -0,0 +1,24 @@
require('dotenv').config();
const { closeDbPool } = require('../config/db');
const { runReconciliation } = require('../services/wallet.service');
const { logger } = require('../utils/logger');
async function main() {
const mismatches = await runReconciliation();
if (mismatches.length > 0) {
logger.error({ mismatches }, 'Wallet reconciliation found mismatches');
process.exitCode = 1;
return;
}
logger.info('Wallet reconciliation passed');
}
main()
.catch((error) => {
logger.error({ error }, 'Wallet reconciliation failed');
process.exitCode = 1;
})
.finally(() => closeDbPool());
@@ -0,0 +1,35 @@
const { runReconciliation } = require('../services/wallet.service');
const { logger } = require('../utils/logger');
function startWalletReconciliationScheduler() {
if (process.env.ENABLE_JOBS !== 'true') {
return () => {};
}
const intervalMs = Number(process.env.WALLET_RECONCILIATION_INTERVAL_MS || 24 * 60 * 60 * 1000);
async function checkWallets() {
try {
const mismatches = await runReconciliation();
if (mismatches.length > 0) {
logger.error({ mismatches }, 'Wallet reconciliation found mismatches');
return;
}
logger.info('Wallet reconciliation passed');
} catch (error) {
logger.error({ error }, 'Wallet reconciliation failed');
}
}
const interval = setInterval(checkWallets, intervalMs);
interval.unref();
checkWallets();
return () => clearInterval(interval);
}
module.exports = {
startWalletReconciliationScheduler
};
+79
View File
@@ -0,0 +1,79 @@
const jwt = require('jsonwebtoken');
const { getDb } = require('../config/db');
const { AppError } = require('./error-handler');
function getJwtSecret() {
return process.env.JWT_SECRET || 'development_only_change_me';
}
function getBearerToken(req) {
const header = req.get('authorization') || '';
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
async function auth(req, _res, next) {
try {
const token = getBearerToken(req);
if (!token) {
throw new AppError('Authentication required', 401, 'UNAUTHENTICATED');
}
const payload = jwt.verify(token, getJwtSecret());
req.user = {
id: payload.user_id,
role: payload.role
};
next();
} catch (error) {
if (error instanceof AppError) {
next(error);
return;
}
next(new AppError('Invalid or expired token', 401, 'INVALID_TOKEN'));
}
}
async function requireActive(req, _res, next) {
try {
const db = getDb();
const user = await db('users').select('id', 'status').where({ id: req.user.id }).first();
if (!user) {
throw new AppError('Authentication required', 401, 'UNAUTHENTICATED');
}
if (user.status !== 'active') {
throw new AppError('Account is not active', 403, 'ACCOUNT_INACTIVE');
}
next();
} catch (error) {
next(error);
}
}
function admin(req, _res, next) {
if (!req.user || req.user.role !== 'admin') {
next(new AppError('Admin access required', 403, 'FORBIDDEN'));
return;
}
next();
}
module.exports = {
admin,
auth,
requireActive
};
+30
View File
@@ -0,0 +1,30 @@
class AppError extends Error {
constructor(message, statusCode = 500, code = 'APP_ERROR') {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
function notFoundHandler(req, _res, next) {
next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404, 'NOT_FOUND'));
}
function errorHandler(error, _req, res, _next) {
const statusCode = error.statusCode || 500;
const isProduction = process.env.NODE_ENV === 'production';
res.status(statusCode).json({
error: {
code: error.code || 'INTERNAL_SERVER_ERROR',
message: statusCode >= 500 && isProduction ? 'Internal server error' : error.message
}
});
}
module.exports = {
AppError,
errorHandler,
notFoundHandler
};
+20
View File
@@ -0,0 +1,20 @@
const { AppError } = require('./error-handler');
function validateBody(schema) {
return (req, _res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const message = result.error.issues.map((issue) => issue.message).join(', ');
next(new AppError(message, 400, 'VALIDATION_ERROR'));
return;
}
req.body = result.data;
next();
};
}
module.exports = {
validateBody
};
+233
View File
@@ -0,0 +1,233 @@
const crypto = require('crypto');
const { Router } = require('express');
const { z } = require('zod');
const { admin, auth, requireActive } = require('../middleware/auth');
const { AppError } = require('../middleware/error-handler');
const { validateBody } = require('../middleware/validate');
const { getAdminStats, listAdminUsers } = require('../services/admin.service');
const {
adjustBalance,
listAdminDeposits,
reviewDeposit
} = require('../services/wallet.service');
const {
listAdminServices,
syncServicesFromProvider,
updateService
} = require('../services/services.service');
const { listAdminOrders } = require('../services/orders.service');
const { getDb } = require('../config/db');
const {
createPaymentMethod,
listAdminPaymentMethods,
updatePaymentMethod
} = require('../services/payment-methods.service');
const adminRouter = Router();
adminRouter.use(auth, requireActive, admin);
const reviewDepositSchema = z.object({
action: z.enum(['approve', 'reject']),
note: z.string().trim().max(255).optional()
});
const updateUserSchema = z.object({
status: z.enum(['active', 'banned']).optional(),
amount: z.string().regex(/^-?\d+(?:\.\d{1,4})?$/, 'Amount must be a decimal with up to 4 places').optional(),
note: z.string().trim().min(3).max(255).optional()
}).refine((value) => value.status || value.amount, {
message: 'Provide status or amount'
});
const syncServicesSchema = z.object({
markup_percent: z.string().regex(/^\d+(?:\.\d{1,4})?$/, 'Markup percent must be a positive decimal with up to 4 places').optional()
});
const updateServiceSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
category: z.string().trim().min(1).max(100).optional(),
sell_price: z.string().regex(/^\d+(?:\.\d{1,4})?$/, 'Sell price must be a positive decimal with up to 4 places').optional(),
markup_percent: z.string().regex(/^\d+(?:\.\d{1,4})?$/, 'Markup percent must be a positive decimal with up to 4 places').optional(),
min: z.number().int().positive().optional(),
max: z.number().int().positive().optional(),
is_active: z.boolean().optional()
}).refine((value) => Object.keys(value).length > 0, {
message: 'Provide at least one service change'
});
const paymentMethodSchema = z.object({
code: z.string().trim().regex(/^[a-z0-9_]{2,30}$/, 'Payment method code must use lowercase letters, numbers, or underscores'),
label_ar: z.string().trim().min(2).max(100),
instructions: z.string().trim().min(5).max(5000),
destination: z.string().trim().max(255).optional(),
is_active: z.boolean().optional(),
sort_order: z.number().int().min(0).max(100000).optional()
});
const updatePaymentMethodSchema = paymentMethodSchema.partial().refine((value) => Object.keys(value).length > 0, {
message: 'Provide at least one payment method change'
});
adminRouter.get('/deposits', async (req, res, next) => {
try {
const deposits = await listAdminDeposits(req.query.status);
res.json({ deposits });
} catch (error) {
next(error);
}
});
adminRouter.patch('/deposits/:id', validateBody(reviewDepositSchema), async (req, res, next) => {
try {
const result = await reviewDeposit(req.params.id, req.user.id, req.body.action, req.body.note);
res.json(result);
} catch (error) {
next(error);
}
});
adminRouter.get('/orders', async (_req, res, next) => {
try {
const orders = await listAdminOrders();
res.json({ orders });
} catch (error) {
next(error);
}
});
adminRouter.get('/payment-methods', async (_req, res, next) => {
try {
const paymentMethods = await listAdminPaymentMethods();
res.json({ paymentMethods });
} catch (error) {
next(error);
}
});
adminRouter.post('/payment-methods', validateBody(paymentMethodSchema), async (req, res, next) => {
try {
const paymentMethod = await createPaymentMethod(req.body);
res.status(201).json({ paymentMethod });
} catch (error) {
next(error);
}
});
adminRouter.patch('/payment-methods/:id', validateBody(updatePaymentMethodSchema), async (req, res, next) => {
try {
const paymentMethod = await updatePaymentMethod(req.params.id, req.body);
res.json({ paymentMethod });
} catch (error) {
next(error);
}
});
adminRouter.get('/services', async (_req, res, next) => {
try {
const services = await listAdminServices();
res.json({ services });
} catch (error) {
next(error);
}
});
adminRouter.post('/services/sync', validateBody(syncServicesSchema), async (req, res, next) => {
try {
const result = await syncServicesFromProvider(req.body.markup_percent);
res.json(result);
} catch (error) {
next(error);
}
});
adminRouter.patch('/services/:id', validateBody(updateServiceSchema), async (req, res, next) => {
try {
const service = await updateService(req.params.id, req.body);
res.json({ service });
} catch (error) {
next(error);
}
});
adminRouter.get('/stats', async (_req, res, next) => {
try {
const stats = await getAdminStats();
res.json({ stats });
} catch (error) {
next(error);
}
});
adminRouter.get('/users', async (_req, res, next) => {
try {
const users = await listAdminUsers();
res.json({ users });
} catch (error) {
next(error);
}
});
adminRouter.patch('/users/:id', validateBody(updateUserSchema), async (req, res, next) => {
try {
const db = getDb();
const targetUserId = Number(req.params.id);
await db.transaction(async (trx) => {
const targetUser = await trx('users')
.select('id', 'role', 'status')
.where({ id: targetUserId })
.forUpdate()
.first();
if (!targetUser) {
throw new AppError('User not found', 404, 'USER_NOT_FOUND');
}
if (req.body.status === 'banned') {
if (targetUserId === Number(req.user.id)) {
throw new AppError('Admins cannot ban their own account', 400, 'ADMIN_SELF_BAN_FORBIDDEN');
}
if (targetUser.role === 'admin' && targetUser.status === 'active') {
const [activeAdmins] = await trx('users')
.where({ role: 'admin', status: 'active' })
.count({ count: '*' });
if (Number(activeAdmins.count) <= 1) {
throw new AppError('Cannot ban the last active admin account', 400, 'LAST_ACTIVE_ADMIN_FORBIDDEN');
}
}
}
if (req.body.status) {
await trx('users').where({ id: targetUserId }).update({ status: req.body.status });
}
});
let wallet;
if (req.body.amount) {
const reference = `admin-adjustment:${req.user.id}:${targetUserId}:${crypto.randomUUID()}`;
wallet = await adjustBalance(
targetUserId,
req.body.amount,
reference,
req.body.note || `Admin adjustment by user ${req.user.id}`
);
}
const user = await db('users')
.select('id', 'username', 'email', 'role', 'status', 'balance')
.where({ id: targetUserId })
.first();
res.json({ user, wallet });
} catch (error) {
next(error);
}
});
module.exports = {
adminRouter
};
+138
View File
@@ -0,0 +1,138 @@
const { Router } = require('express');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const {
forgotPassword,
getMe,
login,
logout,
register,
resetPassword
} = require('../services/auth.service');
const { auth, requireActive } = require('../middleware/auth');
const { validateBody } = require('../middleware/validate');
const authRouter = Router();
const registerLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts, please try again later'
}
}
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts, please try again later'
}
}
});
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts, please try again later'
}
}
});
const registerSchema = z.object({
username: z.string().trim().min(3).max(50).regex(/^[a-zA-Z0-9_]+$/, 'Username may only contain letters, numbers, and underscores'),
email: z.string().trim().email().max(120).toLowerCase(),
password: z.string().min(8).max(128)
});
const loginSchema = z.object({
email: z.string().trim().email().max(120).toLowerCase(),
password: z.string().min(1).max(128)
});
const logoutSchema = z.object({
refreshToken: z.string().min(32).max(256).optional()
});
const forgotPasswordSchema = z.object({
email: z.string().trim().email().max(120).toLowerCase()
});
const resetPasswordSchema = z.object({
token: z.string().min(32).max(256),
password: z.string().min(8).max(128)
});
authRouter.post('/register', registerLimiter, validateBody(registerSchema), async (req, res, next) => {
try {
const result = await register(req.body);
res.status(201).json(result);
} catch (error) {
next(error);
}
});
authRouter.post('/login', loginLimiter, validateBody(loginSchema), async (req, res, next) => {
try {
const result = await login(req.body);
res.json(result);
} catch (error) {
next(error);
}
});
authRouter.post('/logout', validateBody(logoutSchema), async (req, res, next) => {
try {
await logout(req.body.refreshToken || req.cookies.refreshToken);
res.json({ status: 'ok' });
} catch (error) {
next(error);
}
});
authRouter.post('/forgot-password', forgotPasswordLimiter, validateBody(forgotPasswordSchema), async (req, res, next) => {
try {
const result = await forgotPassword(req.body.email);
res.json(result);
} catch (error) {
next(error);
}
});
authRouter.post('/reset-password', validateBody(resetPasswordSchema), async (req, res, next) => {
try {
await resetPassword(req.body);
res.json({ status: 'ok' });
} catch (error) {
next(error);
}
});
authRouter.get('/me', auth, requireActive, async (req, res, next) => {
try {
const result = await getMe(req.user.id);
res.json(result);
} catch (error) {
next(error);
}
});
module.exports = {
authRouter
};
+51
View File
@@ -0,0 +1,51 @@
const { Router } = require('express');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const { auth, requireActive } = require('../middleware/auth');
const { validateBody } = require('../middleware/validate');
const { createDepositRequest, listDeposits } = require('../services/wallet.service');
const depositRouter = Router();
const depositLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
keyGenerator: (req) => `deposit:${req.user.id}`,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many deposit requests, please try again later'
}
}
});
const depositSchema = z.object({
amount: z.string().regex(/^\d+(?:\.\d{1,4})?$/, 'Amount must be a positive decimal with up to 4 places'),
method: z.string().trim().min(2).max(30),
tx_ref: z.string().trim().min(3).max(200)
});
depositRouter.post('/', auth, requireActive, depositLimiter, validateBody(depositSchema), async (req, res, next) => {
try {
const deposit = await createDepositRequest(req.user.id, req.body);
res.status(201).json({ deposit });
} catch (error) {
next(error);
}
});
depositRouter.get('/', auth, requireActive, async (req, res, next) => {
try {
const deposits = await listDeposits(req.user.id);
res.json({ deposits });
} catch (error) {
next(error);
}
});
module.exports = {
depositRouter
};
+23
View File
@@ -0,0 +1,23 @@
const { Router } = require('express');
const { pingDb } = require('../config/db');
const healthRouter = Router();
healthRouter.get('/', async (_req, res, next) => {
try {
const database = await pingDb();
res.json({
status: 'ok',
uptime: process.uptime(),
database
});
} catch (error) {
next(error);
}
});
module.exports = {
healthRouter
};
+19
View File
@@ -0,0 +1,19 @@
const { Router } = require('express');
const { auth, requireActive } = require('../middleware/auth');
const { getMe } = require('../services/auth.service');
const meRouter = Router();
meRouter.get('/', auth, requireActive, async (req, res, next) => {
try {
const result = await getMe(req.user.id);
res.json(result);
} catch (error) {
next(error);
}
});
module.exports = {
meRouter
};
+61
View File
@@ -0,0 +1,61 @@
const { Router } = require('express');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const { auth, requireActive } = require('../middleware/auth');
const { validateBody } = require('../middleware/validate');
const { createOrder, getUserOrder, listUserOrders } = require('../services/orders.service');
const orderRouter = Router();
const orderLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many order attempts, please try again later'
}
}
});
const createOrderSchema = z.object({
service_id: z.number().int().positive(),
link: z.string().trim().url().max(500),
quantity: z.number().int().positive()
});
orderRouter.use(auth, requireActive);
orderRouter.post('/', orderLimiter, validateBody(createOrderSchema), async (req, res, next) => {
try {
const order = await createOrder(req.user.id, req.body);
res.status(201).json({ order });
} catch (error) {
next(error);
}
});
orderRouter.get('/', async (req, res, next) => {
try {
const orders = await listUserOrders(req.user.id);
res.json({ orders });
} catch (error) {
next(error);
}
});
orderRouter.get('/:id', async (req, res, next) => {
try {
const order = await getUserOrder(req.user.id, req.params.id);
res.json({ order });
} catch (error) {
next(error);
}
});
module.exports = {
orderRouter
};
+19
View File
@@ -0,0 +1,19 @@
const { Router } = require('express');
const { auth, requireActive } = require('../middleware/auth');
const { listActivePaymentMethods } = require('../services/payment-methods.service');
const paymentMethodRouter = Router();
paymentMethodRouter.get('/', auth, requireActive, async (_req, res, next) => {
try {
const paymentMethods = await listActivePaymentMethods();
res.json({ paymentMethods });
} catch (error) {
next(error);
}
});
module.exports = {
paymentMethodRouter
};
+18
View File
@@ -0,0 +1,18 @@
const { Router } = require('express');
const { listActiveServices } = require('../services/services.service');
const serviceRouter = Router();
serviceRouter.get('/', async (_req, res, next) => {
try {
const services = await listActiveServices();
res.json({ services });
} catch (error) {
next(error);
}
});
module.exports = {
serviceRouter
};
+19
View File
@@ -0,0 +1,19 @@
const { Router } = require('express');
const { auth, requireActive } = require('../middleware/auth');
const { listTransactions } = require('../services/wallet.service');
const transactionRouter = Router();
transactionRouter.get('/', auth, requireActive, async (req, res, next) => {
try {
const transactions = await listTransactions(req.user.id);
res.json({ transactions });
} catch (error) {
next(error);
}
});
module.exports = {
transactionRouter
};
+63
View File
@@ -0,0 +1,63 @@
require('dotenv').config();
const bcrypt = require('bcryptjs');
const { closeDbPool, getDb } = require('../config/db');
const { logger } = require('../utils/logger');
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
async function main() {
const username = requireEnv('ADMIN_USERNAME');
const email = requireEnv('ADMIN_EMAIL').toLowerCase();
const password = requireEnv('ADMIN_PASSWORD');
if (password.length < 8) {
throw new Error('ADMIN_PASSWORD must be at least 8 characters');
}
const db = getDb();
const existing = await db('users').where({ email }).orWhere({ username }).first();
const passwordHash = await bcrypt.hash(password, 12);
if (existing) {
await db('users').where({ id: existing.id }).update({
username,
email,
password_hash: passwordHash,
role: 'admin',
status: 'active'
});
logger.info({ id: existing.id, email }, 'Admin user updated');
return;
}
const [id] = await db('users').insert({
username,
email,
password_hash: passwordHash,
balance: '0.0000',
role: 'admin',
status: 'active'
});
logger.info({ id, email }, 'Admin user created');
}
main()
.catch((error) => {
logger.error({
message: error.message,
stack: error.stack
}, 'Failed to create admin user');
process.exitCode = 1;
})
.finally(() => closeDbPool());
+48
View File
@@ -0,0 +1,48 @@
require('dotenv').config();
const { createApp } = require('./app');
const { closeDbPool } = require('./config/db');
const { validateEnv } = require('./config/env');
const { startOrderStatusScheduler } = require('./jobs/order-status.scheduler');
const { startWalletReconciliationScheduler } = require('./jobs/wallet-reconciliation.scheduler');
const { logger } = require('./utils/logger');
const port = Number(process.env.PORT || 3000);
validateEnv();
const app = createApp();
const stopOrderStatusScheduler = startOrderStatusScheduler();
const stopWalletReconciliationScheduler = startWalletReconciliationScheduler();
const server = app.listen(port, () => {
logger.info({ port }, 'SMM panel API listening');
});
async function shutdown(signal) {
logger.info({ signal }, 'Shutting down API server');
server.close(async (error) => {
if (error) {
logger.error({ error }, 'HTTP server shutdown failed');
process.exit(1);
}
await closeDbPool();
stopOrderStatusScheduler();
stopWalletReconciliationScheduler();
process.exit(0);
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('unhandledRejection', (error) => {
logger.error({ error }, 'Unhandled promise rejection');
});
process.on('uncaughtException', (error) => {
logger.fatal({ error }, 'Uncaught exception');
process.exit(1);
});
+52
View File
@@ -0,0 +1,52 @@
const { getDb } = require('../config/db');
const { formatMoney, parseMoney } = require('../utils/money');
function calculateProviderCost(providerPrice, quantity) {
return (parseMoney(String(providerPrice)) * BigInt(quantity) + 500n) / 1000n;
}
async function listAdminUsers() {
return getDb()('users')
.select('id', 'username', 'email', 'role', 'status', 'balance', 'created_at', 'updated_at')
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
async function getAdminStats() {
const db = getDb();
const [[usersTotal], [activeUsers], [pendingDeposits], [pendingOrders], orders] = await Promise.all([
db('users').count({ count: '*' }),
db('users').where({ status: 'active' }).count({ count: '*' }),
db('deposits').where({ status: 'pending' }).count({ count: '*' }),
db('orders').whereIn('status', ['pending', 'processing', 'in_progress']).count({ count: '*' }),
db('orders')
.join('services', 'services.id', 'orders.service_id')
.select('orders.charge', 'orders.quantity', 'services.provider_price', 'orders.status')
]);
let grossSales = 0n;
let providerCost = 0n;
for (const order of orders) {
if (!['processing', 'in_progress', 'completed', 'partial'].includes(order.status)) {
continue;
}
grossSales += parseMoney(order.charge);
providerCost += calculateProviderCost(order.provider_price, order.quantity);
}
return {
users_total: Number(usersTotal.count),
users_active: Number(activeUsers.count),
deposits_pending: Number(pendingDeposits.count),
orders_pending: Number(pendingOrders.count),
gross_sales: formatMoney(grossSales),
provider_cost: formatMoney(providerCost),
profit: formatMoney(grossSales - providerCost)
};
}
module.exports = {
getAdminStats,
listAdminUsers
};
+219
View File
@@ -0,0 +1,219 @@
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { getDb } = require('../config/db');
const { AppError } = require('../middleware/error-handler');
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_BYTES = 48;
const RESET_TOKEN_BYTES = 32;
const RESET_TOKEN_TTL_MINUTES = 30;
function getJwtSecret() {
return process.env.JWT_SECRET || 'development_only_change_me';
}
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * 60 * 1000);
}
function addDays(date, days) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
function sanitizeUser(user) {
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
status: user.status,
balance: user.balance
};
}
function createAccessToken(user) {
return jwt.sign(
{
user_id: user.id,
role: user.role
},
getJwtSecret(),
{
expiresIn: ACCESS_TOKEN_TTL
}
);
}
async function createRefreshToken(userId, trx = getDb()) {
const refreshToken = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('base64url');
const tokenHash = hashToken(refreshToken);
await trx('refresh_tokens').insert({
user_id: userId,
token_hash: tokenHash,
expires_at: addDays(new Date(), 30)
});
return refreshToken;
}
async function register(input) {
const db = getDb();
const passwordHash = await bcrypt.hash(input.password, 12);
try {
const [userId] = await db('users').insert({
username: input.username,
email: input.email,
password_hash: passwordHash,
balance: '0.0000'
});
const user = await db('users').where({ id: userId }).first();
return {
user: sanitizeUser(user)
};
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
throw new AppError('Username or email is already in use', 409, 'ACCOUNT_EXISTS');
}
throw error;
}
}
async function login(input) {
const db = getDb();
const genericError = new AppError('Invalid email or password', 401, 'INVALID_CREDENTIALS');
const user = await db('users').where({ email: input.email }).first();
if (!user) {
throw genericError;
}
const passwordMatches = await bcrypt.compare(input.password, user.password_hash);
if (!passwordMatches) {
throw genericError;
}
if (user.status !== 'active') {
throw new AppError('Account is not active', 403, 'ACCOUNT_INACTIVE');
}
const accessToken = createAccessToken(user);
const refreshToken = await createRefreshToken(user.id);
return {
accessToken,
refreshToken,
tokenType: 'Bearer',
expiresIn: 15 * 60,
user: sanitizeUser(user)
};
}
async function logout(refreshToken) {
if (!refreshToken) {
return;
}
const db = getDb();
await db('refresh_tokens')
.where({ token_hash: hashToken(refreshToken) })
.whereNull('revoked_at')
.update({ revoked_at: new Date() });
}
async function forgotPassword(email) {
const db = getDb();
const user = await db('users').select('id').where({ email }).first();
const response = {
status: 'ok',
message: 'If the account exists, a reset link will be sent'
};
if (!user) {
return response;
}
const token = crypto.randomBytes(RESET_TOKEN_BYTES).toString('base64url');
await db('password_reset_tokens').insert({
user_id: user.id,
token_hash: hashToken(token),
expires_at: addMinutes(new Date(), RESET_TOKEN_TTL_MINUTES)
});
if (process.env.NODE_ENV !== 'production') {
response.resetToken = token;
}
return response;
}
async function resetPassword(input) {
const db = getDb();
const tokenHash = hashToken(input.token);
await db.transaction(async (trx) => {
const resetToken = await trx('password_reset_tokens')
.where({ token_hash: tokenHash })
.whereNull('used_at')
.where('expires_at', '>', new Date())
.forUpdate()
.first();
if (!resetToken) {
throw new AppError('Invalid or expired reset token', 400, 'INVALID_RESET_TOKEN');
}
const passwordHash = await bcrypt.hash(input.password, 12);
await trx('users').where({ id: resetToken.user_id }).update({
password_hash: passwordHash
});
await trx('password_reset_tokens').where({ id: resetToken.id }).update({
used_at: new Date()
});
await trx('refresh_tokens')
.where({ user_id: resetToken.user_id })
.whereNull('revoked_at')
.update({ revoked_at: new Date() });
});
}
async function getMe(userId) {
const db = getDb();
const user = await db('users')
.select('id', 'username', 'email', 'role', 'status', 'balance')
.where({ id: userId })
.first();
if (!user) {
throw new AppError('User not found', 404, 'USER_NOT_FOUND');
}
return {
user: sanitizeUser(user)
};
}
module.exports = {
forgotPassword,
getMe,
login,
logout,
register,
resetPassword
};
+265
View File
@@ -0,0 +1,265 @@
const { getDb } = require('../config/db');
const { AppError } = require('../middleware/error-handler');
const { formatMoney, parseMoney } = require('../utils/money');
const { addProviderOrder, fetchProviderOrderStatus } = require('./provider.service');
const { chargeBalanceInTransaction, creditBalance } = require('./wallet.service');
const ORDER_FIELDS = [
'id',
'user_id',
'service_id',
'link',
'quantity',
'charge',
'provider_order_id',
'start_count',
'remains',
'provider_charge',
'last_status_check_at',
'status',
'created_at',
'updated_at'
];
const PROVIDER_STATUS_MAP = {
pending: 'pending',
processing: 'processing',
'in progress': 'in_progress',
in_progress: 'in_progress',
completed: 'completed',
complete: 'completed',
partial: 'partial',
canceled: 'canceled',
cancelled: 'canceled',
failed: 'failed'
};
function calculateOrderCharge(sellPrice, quantity) {
const price = parseMoney(String(sellPrice));
const amount = (price * BigInt(quantity) + 500n) / 1000n;
if (amount <= 0n) {
throw new AppError('Order charge is too small', 400, 'INVALID_ORDER_CHARGE');
}
return formatMoney(amount);
}
function sanitizeOrder(order) {
return {
id: order.id,
user_id: order.user_id,
service_id: order.service_id,
link: order.link,
quantity: order.quantity,
charge: order.charge,
provider_order_id: order.provider_order_id,
start_count: order.start_count,
remains: order.remains,
provider_charge: order.provider_charge,
last_status_check_at: order.last_status_check_at,
status: order.status,
created_at: order.created_at,
updated_at: order.updated_at
};
}
function normalizeProviderStatus(status) {
const normalized = String(status || '').trim().toLowerCase();
return PROVIDER_STATUS_MAP[normalized] || 'processing';
}
function normalizeOptionalInteger(value) {
if (value === undefined || value === null || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}
function normalizeProviderCharge(value) {
if (value === undefined || value === null || value === '') {
return null;
}
try {
return formatMoney(parseMoney(String(value)));
} catch (_error) {
return null;
}
}
async function createOrder(userId, input) {
const db = getDb();
const service = await db('services').where({ id: input.service_id }).first();
if (!service || !service.is_active) {
throw new AppError('Service is not available', 404, 'SERVICE_NOT_AVAILABLE');
}
if (input.quantity < service.min || input.quantity > service.max) {
throw new AppError('Quantity is outside the service limits', 400, 'INVALID_QUANTITY');
}
const charge = calculateOrderCharge(service.sell_price, input.quantity);
const order = await db.transaction(async (trx) => {
const [orderId] = await trx('orders').insert({
user_id: userId,
service_id: service.id,
link: input.link,
quantity: input.quantity,
charge,
status: 'pending'
});
await chargeBalanceInTransaction(
trx,
userId,
charge,
`order:${orderId}`,
`Order ${orderId} charge`
);
return trx('orders').where({ id: orderId }).first();
});
try {
const providerResponse = await addProviderOrder({
service: service.provider_service_id,
link: input.link,
quantity: input.quantity
});
const providerOrderId = providerResponse.order || providerResponse.id || providerResponse.order_id;
if (!providerOrderId) {
throw new AppError('Provider did not return an order id', 502, 'INVALID_PROVIDER_ORDER_RESPONSE');
}
await db('orders').where({ id: order.id }).update({
provider_order_id: String(providerOrderId),
status: 'processing'
});
return sanitizeOrder(await db('orders').where({ id: order.id }).first());
} catch (error) {
await creditBalance(
userId,
charge,
`refund:order:${order.id}`,
`Refund failed order ${order.id}`,
'refund'
);
await db('orders').where({ id: order.id }).update({
status: 'failed'
});
const failedOrder = sanitizeOrder(await db('orders').where({ id: order.id }).first());
if (error instanceof AppError) {
error.details = { order: failedOrder };
throw error;
}
const providerError = new AppError('Provider order failed and wallet was refunded', 502, 'PROVIDER_ORDER_FAILED');
providerError.details = { order: failedOrder };
throw providerError;
}
}
async function listUserOrders(userId) {
const orders = await getDb()('orders')
.select(ORDER_FIELDS)
.where({ user_id: userId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
return orders.map(sanitizeOrder);
}
async function getUserOrder(userId, orderId) {
const order = await getDb()('orders')
.select(ORDER_FIELDS)
.where({ id: orderId, user_id: userId })
.first();
if (!order) {
throw new AppError('Order not found', 404, 'ORDER_NOT_FOUND');
}
return sanitizeOrder(order);
}
async function listAdminOrders() {
const orders = await getDb()('orders')
.select(ORDER_FIELDS)
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
return orders.map(sanitizeOrder);
}
async function syncOrderStatuses(limit = 100) {
const db = getDb();
const orders = await db('orders')
.select(ORDER_FIELDS)
.whereIn('status', ['processing', 'in_progress'])
.whereNotNull('provider_order_id')
.orderBy('last_status_check_at', 'asc')
.orderBy('id', 'asc')
.limit(limit);
const results = [];
for (const order of orders) {
try {
const providerStatus = await fetchProviderOrderStatus(order.provider_order_id);
const status = normalizeProviderStatus(providerStatus.status);
const update = {
status,
start_count: normalizeOptionalInteger(providerStatus.start_count),
remains: normalizeOptionalInteger(providerStatus.remains),
provider_charge: normalizeProviderCharge(providerStatus.charge),
last_status_check_at: new Date()
};
// Partial orders are marked for admin review. We intentionally do not auto-refund
// here because provider partial semantics vary and should be reconciled manually.
await db('orders').where({ id: order.id }).update(update);
results.push({
orderId: order.id,
status,
ok: true
});
} catch (error) {
results.push({
orderId: order.id,
status: order.status,
ok: false,
error: error.code || 'STATUS_SYNC_FAILED'
});
}
}
return {
checked: orders.length,
updated: results.filter((result) => result.ok).length,
failed: results.filter((result) => !result.ok).length,
results
};
}
module.exports = {
calculateOrderCharge,
createOrder,
getUserOrder,
listAdminOrders,
listUserOrders,
syncOrderStatuses
};
+91
View File
@@ -0,0 +1,91 @@
const { getDb } = require('../config/db');
const { AppError } = require('../middleware/error-handler');
const PAYMENT_METHOD_COLUMNS = [
'id',
'code',
'label_ar',
'instructions',
'destination',
'is_active',
'sort_order',
'created_at',
'updated_at'
];
function mapDuplicatePaymentMethod(error) {
if (error.code === 'ER_DUP_ENTRY') {
throw new AppError('Payment method code is already used', 409, 'DUPLICATE_PAYMENT_METHOD');
}
throw error;
}
async function listActivePaymentMethods() {
return getDb()('payment_methods')
.select(PAYMENT_METHOD_COLUMNS)
.where({ is_active: true })
.orderBy('sort_order', 'asc')
.orderBy('id', 'asc');
}
async function listAdminPaymentMethods() {
return getDb()('payment_methods')
.select(PAYMENT_METHOD_COLUMNS)
.orderBy('sort_order', 'asc')
.orderBy('id', 'asc');
}
async function getActivePaymentMethodByCode(code) {
return getDb()('payment_methods')
.select(PAYMENT_METHOD_COLUMNS)
.where({ code, is_active: true })
.first();
}
async function createPaymentMethod(input) {
try {
const [id] = await getDb()('payment_methods').insert({
code: input.code,
label_ar: input.label_ar,
instructions: input.instructions,
destination: input.destination || null,
is_active: input.is_active ?? true,
sort_order: input.sort_order ?? 0
});
return getDb()('payment_methods').select(PAYMENT_METHOD_COLUMNS).where({ id }).first();
} catch (error) {
mapDuplicatePaymentMethod(error);
}
}
async function updatePaymentMethod(id, input) {
const changes = {};
for (const key of ['code', 'label_ar', 'instructions', 'destination', 'is_active', 'sort_order']) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
changes[key] = input[key] === '' && key === 'destination' ? null : input[key];
}
}
try {
const updated = await getDb()('payment_methods').where({ id }).update(changes);
if (!updated) {
throw new AppError('Payment method not found', 404, 'PAYMENT_METHOD_NOT_FOUND');
}
return getDb()('payment_methods').select(PAYMENT_METHOD_COLUMNS).where({ id }).first();
} catch (error) {
mapDuplicatePaymentMethod(error);
}
}
module.exports = {
createPaymentMethod,
getActivePaymentMethodByCode,
listActivePaymentMethods,
listAdminPaymentMethods,
updatePaymentMethod
};
+86
View File
@@ -0,0 +1,86 @@
const axios = require('axios');
const { AppError } = require('../middleware/error-handler');
function getProviderConfig() {
const apiUrl = process.env.PROVIDER_API_URL;
const apiKey = process.env.PROVIDER_API_KEY;
if (!apiUrl || !apiKey) {
throw new AppError('Provider API is not configured', 500, 'PROVIDER_NOT_CONFIGURED');
}
return {
apiUrl,
apiKey
};
}
async function providerRequest(action, payload = {}) {
const { apiUrl, apiKey } = getProviderConfig();
try {
const response = await axios.post(
apiUrl,
new URLSearchParams({
key: apiKey,
action,
...payload
}).toString(),
{
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
timeout: Number(process.env.PROVIDER_TIMEOUT_MS || 15000)
}
);
if (response.data && response.data.error) {
throw new AppError('Provider API returned an error', 502, 'PROVIDER_ERROR');
}
return response.data;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError('Provider API request failed', 502, 'PROVIDER_REQUEST_FAILED');
}
}
async function fetchProviderServices() {
const data = await providerRequest('services');
if (!Array.isArray(data)) {
throw new AppError('Provider services response is invalid', 502, 'INVALID_PROVIDER_RESPONSE');
}
return data;
}
async function addProviderOrder({ service, link, quantity }) {
return providerRequest('add', {
service,
link,
quantity: String(quantity)
});
}
async function fetchProviderOrderStatus(providerOrderId) {
return providerRequest('status', {
order: String(providerOrderId)
});
}
async function fetchProviderBalance() {
return providerRequest('balance');
}
module.exports = {
addProviderOrder,
fetchProviderBalance,
fetchProviderOrderStatus,
fetchProviderServices,
providerRequest
};
+130
View File
@@ -0,0 +1,130 @@
const { getDb } = require('../config/db');
const { AppError } = require('../middleware/error-handler');
const { formatMoney, parseMoney } = require('../utils/money');
const { fetchProviderServices } = require('./provider.service');
function parseMarkupPercent(value = process.env.DEFAULT_MARKUP_PERCENT || '0') {
const raw = String(value).trim();
if (!/^\d+(?:\.\d{1,4})?$/.test(raw)) {
throw new AppError('Invalid markup percent', 400, 'INVALID_MARKUP_PERCENT');
}
return raw;
}
function calculateSellPrice(providerPrice, markupPercent = process.env.DEFAULT_MARKUP_PERCENT || '0') {
const price = parseMoney(String(providerPrice));
const markup = BigInt(Math.round(Number(markupPercent) * 10000));
const sellPrice = (price * (1000000n + markup) + 500000n) / 1000000n;
return formatMoney(sellPrice);
}
function normalizeProviderService(service, markupPercent) {
const providerServiceId = String(service.service ?? service.provider_service_id ?? '').trim();
const name = String(service.name ?? '').trim();
const category = String(service.category ?? 'Uncategorized').trim() || 'Uncategorized';
const providerPrice = String(service.rate ?? service.price ?? service.provider_price ?? '').trim();
const min = Number(service.min);
const max = Number(service.max);
if (!providerServiceId || !name || !providerPrice || !Number.isInteger(min) || !Number.isInteger(max)) {
throw new AppError('Provider service item is invalid', 502, 'INVALID_PROVIDER_SERVICE');
}
return {
provider_service_id: providerServiceId,
name,
category,
provider_price: formatMoney(parseMoney(providerPrice)),
markup_percent: markupPercent,
sell_price: calculateSellPrice(providerPrice, markupPercent),
min,
max,
is_active: true
};
}
async function syncServicesFromProvider(markupPercentInput) {
const markupPercent = parseMarkupPercent(markupPercentInput);
const providerServices = await fetchProviderServices();
const services = providerServices.map((service) => normalizeProviderService(service, markupPercent));
const db = getDb();
await db.transaction(async (trx) => {
for (const service of services) {
const existing = await trx('services')
.select('id')
.where({ provider_service_id: service.provider_service_id })
.first();
if (existing) {
await trx('services').where({ id: existing.id }).update(service);
} else {
await trx('services').insert(service);
}
}
});
return {
synced: services.length
};
}
async function listActiveServices() {
return getDb()('services')
.select('id', 'provider_service_id', 'name', 'category', 'sell_price', 'min', 'max', 'is_active', 'updated_at')
.where({ is_active: true })
.orderBy('category', 'asc')
.orderBy('name', 'asc');
}
async function listAdminServices() {
return getDb()('services')
.select('id', 'provider_service_id', 'name', 'category', 'provider_price', 'markup_percent', 'sell_price', 'min', 'max', 'is_active', 'updated_at')
.orderBy('category', 'asc')
.orderBy('name', 'asc');
}
async function updateService(serviceId, input) {
const db = getDb();
const service = await db('services').where({ id: serviceId }).first();
if (!service) {
throw new AppError('Service not found', 404, 'SERVICE_NOT_FOUND');
}
const update = {};
if (input.name !== undefined) update.name = input.name;
if (input.category !== undefined) update.category = input.category;
if (input.min !== undefined) update.min = input.min;
if (input.max !== undefined) update.max = input.max;
if (input.is_active !== undefined) update.is_active = input.is_active;
if (input.sell_price !== undefined) update.sell_price = formatMoney(parseMoney(input.sell_price));
if (input.markup_percent !== undefined) {
update.markup_percent = parseMarkupPercent(input.markup_percent);
update.sell_price = calculateSellPrice(service.provider_price, update.markup_percent);
}
if (Object.keys(update).length === 0) {
throw new AppError('No service changes provided', 400, 'NO_SERVICE_CHANGES');
}
await db('services').where({ id: serviceId }).update(update);
return db('services')
.select('id', 'provider_service_id', 'name', 'category', 'provider_price', 'markup_percent', 'sell_price', 'min', 'max', 'is_active', 'updated_at')
.where({ id: serviceId })
.first();
}
module.exports = {
calculateSellPrice,
listActiveServices,
listAdminServices,
syncServicesFromProvider,
updateService
};
+270
View File
@@ -0,0 +1,270 @@
const { getDb } = require('../config/db');
const { AppError } = require('../middleware/error-handler');
const { getActivePaymentMethodByCode } = require('./payment-methods.service');
const { assertPositiveMoney, formatMoney, parseMoney } = require('../utils/money');
const ALLOWED_CREDIT_TYPES = new Set(['deposit', 'refund', 'adjustment']);
function mapDuplicateTransaction(error) {
if (error.code === 'ER_DUP_ENTRY') {
throw new AppError('Transaction reference was already used', 409, 'DUPLICATE_TRANSACTION');
}
throw error;
}
async function lockUser(trx, userId) {
const user = await trx('users').where({ id: userId }).forUpdate().first();
if (!user) {
throw new AppError('User not found', 404, 'USER_NOT_FOUND');
}
if (user.status !== 'active') {
throw new AppError('Account is not active', 403, 'ACCOUNT_INACTIVE');
}
return user;
}
async function applyBalanceChange(trx, { userId, amount, type, reference, note }) {
const user = await lockUser(trx, userId);
const currentBalance = parseMoney(user.balance);
const delta = parseMoney(amount);
const nextBalance = currentBalance + delta;
if (nextBalance < 0n) {
throw new AppError('Insufficient balance', 402, 'INSUFFICIENT_BALANCE');
}
const balanceAfter = formatMoney(nextBalance);
await trx('users').where({ id: userId }).update({ balance: balanceAfter });
await trx('transactions').insert({
user_id: userId,
type,
amount: formatMoney(delta),
balance_after: balanceAfter,
reference,
note
});
return {
balance: balanceAfter
};
}
async function chargeBalance(userId, amount, reference, note = 'Order charge') {
const charge = assertPositiveMoney(amount);
try {
return await getDb().transaction((trx) => applyBalanceChange(trx, {
userId,
amount: formatMoney(-charge),
type: 'order',
reference,
note
}));
} catch (error) {
mapDuplicateTransaction(error);
}
}
async function chargeBalanceInTransaction(trx, userId, amount, reference, note = 'Order charge') {
const charge = assertPositiveMoney(amount);
try {
return await applyBalanceChange(trx, {
userId,
amount: formatMoney(-charge),
type: 'order',
reference,
note
});
} catch (error) {
mapDuplicateTransaction(error);
}
}
async function creditBalance(userId, amount, reference, note = 'Wallet credit', type = 'deposit') {
const credit = assertPositiveMoney(amount);
if (!ALLOWED_CREDIT_TYPES.has(type)) {
throw new AppError('Invalid transaction type', 400, 'INVALID_TRANSACTION_TYPE');
}
try {
return await getDb().transaction((trx) => applyBalanceChange(trx, {
userId,
amount: formatMoney(credit),
type,
reference,
note
}));
} catch (error) {
mapDuplicateTransaction(error);
}
}
async function adjustBalance(userId, amount, reference, note = 'Admin balance adjustment') {
const parsed = parseMoney(amount);
if (parsed === 0n) {
throw new AppError('Amount must not be zero', 400, 'INVALID_AMOUNT');
}
try {
return await getDb().transaction((trx) => applyBalanceChange(trx, {
userId,
amount: formatMoney(parsed),
type: 'adjustment',
reference,
note
}));
} catch (error) {
mapDuplicateTransaction(error);
}
}
async function listTransactions(userId) {
return getDb()('transactions')
.select('id', 'type', 'amount', 'balance_after', 'reference', 'note', 'created_at')
.where({ user_id: userId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
async function createDepositRequest(userId, input) {
assertPositiveMoney(input.amount);
const paymentMethod = await getActivePaymentMethodByCode(input.method);
if (!paymentMethod) {
throw new AppError('Payment method is not available', 400, 'PAYMENT_METHOD_UNAVAILABLE');
}
try {
const [depositId] = await getDb()('deposits').insert({
user_id: userId,
amount: input.amount,
method: input.method,
tx_ref: input.tx_ref
});
return getDb()('deposits').where({ id: depositId }).first();
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
throw new AppError('Transaction reference is already used', 409, 'DUPLICATE_TX_REF');
}
throw error;
}
}
async function listDeposits(userId) {
return getDb()('deposits')
.select('id', 'amount', 'method', 'tx_ref', 'status', 'note', 'created_at', 'updated_at')
.where({ user_id: userId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
async function listAdminDeposits(status) {
const query = getDb()('deposits')
.select('id', 'user_id', 'amount', 'method', 'tx_ref', 'status', 'reviewed_by', 'note', 'created_at', 'updated_at')
.orderByRaw("FIELD(status, 'pending', 'approved', 'rejected')")
.orderBy('created_at', 'asc');
if (status) {
query.where({ status });
}
return query;
}
async function reviewDeposit(depositId, adminId, action, note) {
if (!['approve', 'reject'].includes(action)) {
throw new AppError('Invalid deposit action', 400, 'INVALID_DEPOSIT_ACTION');
}
return getDb().transaction(async (trx) => {
const deposit = await trx('deposits').where({ id: depositId }).forUpdate().first();
if (!deposit) {
throw new AppError('Deposit not found', 404, 'DEPOSIT_NOT_FOUND');
}
if (deposit.status !== 'pending') {
throw new AppError('Deposit is already reviewed', 409, 'DEPOSIT_ALREADY_REVIEWED');
}
if (action === 'reject') {
await trx('deposits').where({ id: depositId }).update({
status: 'rejected',
reviewed_by: adminId,
note
});
return {
status: 'rejected'
};
}
try {
const result = await applyBalanceChange(trx, {
userId: deposit.user_id,
amount: deposit.amount,
type: 'deposit',
reference: `deposit:${deposit.id}`,
note: note || 'Manual deposit approved'
});
await trx('deposits').where({ id: depositId }).update({
status: 'approved',
reviewed_by: adminId,
note
});
return {
status: 'approved',
balance: result.balance
};
} catch (error) {
mapDuplicateTransaction(error);
}
});
}
async function runReconciliation() {
const rows = await getDb()('users')
.leftJoin('transactions', 'transactions.user_id', 'users.id')
.select('users.id', 'users.balance')
.sum({ calculated: 'transactions.amount' })
.groupBy('users.id', 'users.balance');
return rows
.map((row) => {
const balance = formatMoney(parseMoney(row.balance));
const calculated = formatMoney(parseMoney(row.calculated || '0.0000'));
return {
userId: row.id,
balance,
calculated,
ok: balance === calculated
};
})
.filter((row) => !row.ok);
}
module.exports = {
adjustBalance,
chargeBalance,
chargeBalanceInTransaction,
creditBalance,
createDepositRequest,
listAdminDeposits,
listDeposits,
listTransactions,
reviewDeposit,
runReconciliation
};
+28
View File
@@ -0,0 +1,28 @@
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: [
'req.headers.authorization',
'password',
'password_hash',
'PROVIDER_API_KEY',
'*.password',
'*.password_hash',
'*.key'
],
remove: true
}
});
const morganStream = {
write(message) {
logger.info({ http: message.trim() }, 'HTTP request');
}
};
module.exports = {
logger,
morganStream
};
+52
View File
@@ -0,0 +1,52 @@
const SCALE = 10000n;
const MAX_ABS = 9999999999999999n;
function parseMoney(value) {
if (typeof value === 'number') {
throw new Error('Money values must be strings to avoid floating point errors');
}
const raw = String(value).trim();
const match = raw.match(/^(-?)(\d+)(?:\.(\d{1,4}))?$/);
if (!match) {
throw new Error('Invalid money value');
}
const sign = match[1] === '-' ? -1n : 1n;
const whole = BigInt(match[2]);
const fraction = BigInt((match[3] || '').padEnd(4, '0'));
const parsed = sign * (whole * SCALE + fraction);
if (parsed > MAX_ABS || parsed < -MAX_ABS) {
throw new Error('Money value is out of range');
}
return parsed;
}
function formatMoney(value) {
const amount = BigInt(value);
const sign = amount < 0n ? '-' : '';
const absolute = amount < 0n ? -amount : amount;
const whole = absolute / SCALE;
const fraction = String(absolute % SCALE).padStart(4, '0');
return `${sign}${whole}.${fraction}`;
}
function assertPositiveMoney(value) {
const parsed = parseMoney(value);
if (parsed <= 0n) {
throw new Error('Amount must be greater than zero');
}
return parsed;
}
module.exports = {
assertPositiveMoney,
formatMoney,
parseMoney
};
+379
View File
@@ -0,0 +1,379 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const bcrypt = require('bcryptjs');
const { createApp } = require('../src/app');
const { closeDbPool, getDb } = require('../src/config/db');
const { validateEnv } = require('../src/config/env');
const createdUserIds = [];
const createdServiceIds = [];
const createdOrderIds = [];
const createdPaymentMethodIds = [];
function unique(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
resolve({
baseUrl: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
return { response, body };
}
async function createAccount(baseUrl, prefix) {
const account = {
username: unique(prefix),
email: `${unique(prefix)}@example.com`,
password: 'StrongPass123'
};
const registered = await request(baseUrl, '/auth/register', {
method: 'POST',
body: JSON.stringify(account)
});
assert.equal(registered.response.status, 201);
createdUserIds.push(registered.body.user.id);
return {
...account,
id: registered.body.user.id
};
}
async function createDbAccount(prefix, overrides = {}) {
const db = getDb();
const password = 'StrongPass123';
const account = {
username: unique(prefix),
email: `${unique(prefix)}@example.com`,
password
};
const [id] = await db('users').insert({
username: account.username,
email: account.email,
password_hash: await bcrypt.hash(password, 12),
balance: '0.0000',
role: 'user',
status: 'active',
...overrides
});
createdUserIds.push(id);
return {
...account,
id
};
}
async function login(baseUrl, account) {
const loggedIn = await request(baseUrl, '/auth/login', {
method: 'POST',
body: JSON.stringify({
email: account.email,
password: account.password
})
});
assert.equal(loggedIn.response.status, 200);
return loggedIn.body.accessToken;
}
test.after(async () => {
const db = getDb();
if (createdOrderIds.length > 0) {
await db('orders').whereIn('id', createdOrderIds).del();
}
if (createdServiceIds.length > 0) {
await db('services').whereIn('id', createdServiceIds).del();
}
if (createdPaymentMethodIds.length > 0) {
await db('payment_methods').whereIn('id', createdPaymentMethodIds).del();
}
if (createdUserIds.length > 0) {
await db('password_reset_tokens').whereIn('user_id', createdUserIds).del();
await db('refresh_tokens').whereIn('user_id', createdUserIds).del();
await db('deposits').whereIn('user_id', createdUserIds).orWhereIn('reviewed_by', createdUserIds).del();
await db('transactions').whereIn('user_id', createdUserIds).del();
await db('users').whereIn('id', createdUserIds).del();
}
await closeDbPool();
});
test('admin users and stats work, while normal users receive 403', async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_secret';
const api = await listen(createApp());
try {
const db = getDb();
const admin = await createAccount(api.baseUrl, 'admin_test_admin');
const user = await createAccount(api.baseUrl, 'admin_test_user');
await db('users').where({ id: admin.id }).update({ role: 'admin' });
const [serviceId] = await db('services').insert({
provider_service_id: unique('admin_provider_service'),
name: 'Admin Test Service',
category: 'Admin',
provider_price: '4.0000',
markup_percent: '25.0000',
sell_price: '5.0000',
min: 100,
max: 10000,
is_active: true
});
createdServiceIds.push(serviceId);
const [orderId] = await db('orders').insert({
user_id: user.id,
service_id: serviceId,
link: 'https://example.com/admin-order',
quantity: 1000,
charge: '5.0000',
provider_order_id: unique('admin_provider_order'),
status: 'completed'
});
createdOrderIds.push(orderId);
const adminToken = await login(api.baseUrl, admin);
const userToken = await login(api.baseUrl, user);
const users = await request(api.baseUrl, '/admin/users', {
headers: { authorization: `Bearer ${adminToken}` }
});
assert.equal(users.response.status, 200);
assert.equal(users.body.users.some((row) => Number(row.id) === Number(user.id)), true);
const stats = await request(api.baseUrl, '/admin/stats', {
headers: { authorization: `Bearer ${adminToken}` }
});
assert.equal(stats.response.status, 200);
assert.equal(stats.body.stats.gross_sales, '5.0000');
assert.equal(stats.body.stats.provider_cost, '4.0000');
assert.equal(stats.body.stats.profit, '1.0000');
const adjusted = await request(api.baseUrl, `/admin/users/${user.id}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${adminToken}` },
body: JSON.stringify({
amount: '3.0000',
note: 'Admin test credit'
})
});
assert.equal(adjusted.response.status, 200);
assert.equal(adjusted.body.user.balance, '3.0000');
assert.equal(adjusted.body.wallet.balance, '3.0000');
const banned = await request(api.baseUrl, `/admin/users/${user.id}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ status: 'banned' })
});
assert.equal(banned.response.status, 200);
assert.equal(banned.body.user.status, 'banned');
const forbiddenChecks = [
request(api.baseUrl, '/admin/users', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, '/admin/deposits', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, '/admin/orders', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, '/admin/services', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, '/admin/payment-methods', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, '/admin/stats', { headers: { authorization: `Bearer ${userToken}` } }),
request(api.baseUrl, `/admin/users/${user.id}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({ status: 'active' })
}),
request(api.baseUrl, '/admin/services/sync', {
method: 'POST',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({})
}),
request(api.baseUrl, '/admin/deposits/1', {
method: 'PATCH',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({ action: 'reject' })
}),
request(api.baseUrl, `/admin/services/${serviceId}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({ is_active: false })
}),
request(api.baseUrl, '/admin/payment-methods', {
method: 'POST',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({
code: unique('normal_forbidden'),
label_ar: 'Forbidden',
instructions: 'Forbidden payment method'
})
})
];
const forbiddenResponses = await Promise.all(forbiddenChecks);
assert.equal(forbiddenResponses.every((result) => result.response.status === 403), true);
} finally {
await api.close();
}
});
test('admin can manage payment methods and active list hides disabled methods', async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_secret';
const api = await listen(createApp());
try {
const db = getDb();
const admin = await createAccount(api.baseUrl, 'payment_admin');
const user = await createAccount(api.baseUrl, 'payment_user');
await db('users').where({ id: admin.id }).update({ role: 'admin' });
const adminToken = await login(api.baseUrl, admin);
const userToken = await login(api.baseUrl, user);
const code = unique('payment_method').replace(/[^a-z0-9_]/g, '_').slice(0, 30);
const created = await request(api.baseUrl, '/admin/payment-methods', {
method: 'POST',
headers: { authorization: `Bearer ${adminToken}` },
body: JSON.stringify({
code,
label_ar: 'اختبار دفع',
instructions: 'تعليمات قناة الدفع الاختبارية',
destination: 'test@example.com',
sort_order: 999
})
});
assert.equal(created.response.status, 201);
createdPaymentMethodIds.push(created.body.paymentMethod.id);
const active = await request(api.baseUrl, '/payment-methods', {
headers: { authorization: `Bearer ${userToken}` }
});
assert.equal(active.response.status, 200);
assert.equal(active.body.paymentMethods.some((method) => method.code === code), true);
const disabled = await request(api.baseUrl, `/admin/payment-methods/${created.body.paymentMethod.id}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ is_active: false })
});
assert.equal(disabled.response.status, 200);
assert.equal(disabled.body.paymentMethod.is_active, 0);
const hidden = await request(api.baseUrl, '/payment-methods', {
headers: { authorization: `Bearer ${userToken}` }
});
assert.equal(hidden.response.status, 200);
assert.equal(hidden.body.paymentMethods.some((method) => method.code === code), false);
} finally {
await api.close();
}
});
test('admin user update refuses self-ban and blocks rate-limited sensitive routes', async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_secret';
const api = await listen(createApp());
try {
const admin = await createDbAccount('security_admin', { role: 'admin' });
const user = await createDbAccount('security_user');
const adminToken = await login(api.baseUrl, admin);
const userToken = await login(api.baseUrl, user);
const selfBan = await request(api.baseUrl, `/admin/users/${admin.id}`, {
method: 'PATCH',
headers: { authorization: `Bearer ${adminToken}` },
body: JSON.stringify({ status: 'banned' })
});
assert.equal(selfBan.response.status, 400);
assert.equal(selfBan.body.error.code, 'ADMIN_SELF_BAN_FORBIDDEN');
const forgotResponses = [];
for (let index = 0; index < 6; index += 1) {
forgotResponses.push(await request(api.baseUrl, '/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: `missing_${index}@example.com` })
}));
}
assert.equal(forgotResponses.slice(0, 5).every((result) => result.response.status === 200), true);
assert.equal(forgotResponses[5].response.status, 429);
const depositResponses = [];
for (let index = 0; index < 11; index += 1) {
depositResponses.push(await request(api.baseUrl, '/deposits', {
method: 'POST',
headers: { authorization: `Bearer ${userToken}` },
body: JSON.stringify({
amount: '1.0000',
method: 'paypal',
tx_ref: unique(`rate_limited_deposit_${index}`)
})
}));
}
assert.equal(depositResponses.slice(0, 10).every((result) => result.response.status === 201), true);
assert.equal(depositResponses[10].response.status, 429);
} finally {
await api.close();
}
});
test('production environment requires CORS_ORIGIN', () => {
const previous = {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
PROVIDER_API_URL: process.env.PROVIDER_API_URL,
PROVIDER_API_KEY: process.env.PROVIDER_API_KEY,
CORS_ORIGIN: process.env.CORS_ORIGIN
};
try {
process.env.NODE_ENV = 'production';
process.env.DATABASE_URL = 'mysql://smm:smm_password@127.0.0.1:3306/smm';
process.env.JWT_SECRET = 'a'.repeat(32);
process.env.PROVIDER_API_URL = 'https://provider.real/api/v2';
process.env.PROVIDER_API_KEY = 'real_provider_key';
delete process.env.CORS_ORIGIN;
assert.throws(
() => validateEnv(),
/Missing required environment variables: CORS_ORIGIN/
);
process.env.CORS_ORIGIN = 'https://panel.example.com';
assert.doesNotThrow(() => validateEnv());
} finally {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
});
}
});
+182
View File
@@ -0,0 +1,182 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const { createApp } = require('../src/app');
const { closeDbPool, getDb } = require('../src/config/db');
const createdUserIds = [];
const createdServiceIds = [];
let providerMock;
function unique(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function startFailingProviderMock() {
const server = http.createServer(async (_req, res) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ error: 'forced provider failure' }));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
resolve({
baseUrl: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
return { response, body };
}
async function register(baseUrl, prefix) {
const account = {
username: unique(prefix),
email: `${unique(prefix)}@example.com`,
password: 'StrongPass123'
};
const result = await request(baseUrl, '/auth/register', {
method: 'POST',
body: JSON.stringify(account)
});
assert.equal(result.response.status, 201);
createdUserIds.push(result.body.user.id);
return {
...account,
id: result.body.user.id
};
}
async function login(baseUrl, account) {
const result = await request(baseUrl, '/auth/login', {
method: 'POST',
body: JSON.stringify({
email: account.email,
password: account.password
})
});
assert.equal(result.response.status, 200);
return result.body.accessToken;
}
test.before(async () => {
providerMock = await startFailingProviderMock();
process.env.PROVIDER_API_URL = providerMock.url;
process.env.PROVIDER_API_KEY = 'mock-key';
});
test.after(async () => {
const db = getDb();
if (createdUserIds.length > 0) {
await db('orders').whereIn('user_id', createdUserIds).del();
await db('deposits').whereIn('user_id', createdUserIds).orWhereIn('reviewed_by', createdUserIds).del();
await db('transactions').whereIn('user_id', createdUserIds).del();
await db('refresh_tokens').whereIn('user_id', createdUserIds).del();
await db('password_reset_tokens').whereIn('user_id', createdUserIds).del();
await db('users').whereIn('id', createdUserIds).del();
}
if (createdServiceIds.length > 0) {
await db('services').whereIn('id', createdServiceIds).del();
}
await closeDbPool();
await providerMock.close();
});
test('full flow refunds wallet when provider order fails', async () => {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_secret';
const api = await listen(createApp());
try {
const db = getDb();
const user = await register(api.baseUrl, 'e2e_user');
const admin = await register(api.baseUrl, 'e2e_admin');
await db('users').where({ id: admin.id }).update({ role: 'admin' });
const userToken = await login(api.baseUrl, user);
const adminToken = await login(api.baseUrl, admin);
const userAuth = { authorization: `Bearer ${userToken}` };
const adminAuth = { authorization: `Bearer ${adminToken}` };
const deposit = await request(api.baseUrl, '/deposits', {
method: 'POST',
headers: userAuth,
body: JSON.stringify({
amount: '10.0000',
method: 'paypal',
tx_ref: unique('e2e_tx')
})
});
assert.equal(deposit.response.status, 201);
const approved = await request(api.baseUrl, `/admin/deposits/${deposit.body.deposit.id}`, {
method: 'PATCH',
headers: adminAuth,
body: JSON.stringify({ action: 'approve' })
});
assert.equal(approved.response.status, 200);
const [serviceId] = await db('services').insert({
provider_service_id: unique('e2e_provider_service'),
name: 'E2E Failure Service',
category: 'E2E',
provider_price: '2.0000',
markup_percent: '25.0000',
sell_price: '2.5000',
min: 100,
max: 10000,
is_active: true
});
createdServiceIds.push(serviceId);
const failedOrder = await request(api.baseUrl, '/orders', {
method: 'POST',
headers: userAuth,
body: JSON.stringify({
service_id: serviceId,
link: 'https://example.com/e2e-failure',
quantity: 1000
})
});
assert.equal(failedOrder.response.status, 502);
const me = await request(api.baseUrl, '/me', { headers: userAuth });
const orders = await request(api.baseUrl, '/orders', { headers: userAuth });
const transactions = await request(api.baseUrl, '/transactions', { headers: userAuth });
assert.equal(me.body.user.balance, '10.0000');
assert.equal(orders.body.orders.some((order) => order.status === 'failed' && order.charge === '2.5000'), true);
assert.equal(transactions.body.transactions.some((transaction) => transaction.type === 'refund' && transaction.amount === '2.5000'), true);
} finally {
await api.close();
}
});
+163
View File
@@ -0,0 +1,163 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const { closeDbPool, getDb } = require('../src/config/db');
const { syncOrderStatuses } = require('../src/services/orders.service');
const createdUserIds = [];
const createdServiceIds = [];
let providerMock;
const statuses = new Map();
function unique(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function startProviderMock() {
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = new URLSearchParams(Buffer.concat(chunks).toString());
const orderId = body.get('order');
const payload = statuses.get(orderId) || { status: 'Processing', remains: 10 };
res.setHeader('content-type', 'application/json');
if (body.get('action') !== 'status') {
res.end(JSON.stringify({ error: 'unsupported action' }));
return;
}
res.end(JSON.stringify(payload));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
async function createUser(balance = '0.0000') {
const username = unique('status_user');
const [id] = await getDb()('users').insert({
username,
email: `${username}@example.com`,
password_hash: 'hash',
balance
});
createdUserIds.push(id);
return id;
}
async function createService() {
const [id] = await getDb()('services').insert({
provider_service_id: unique('status_provider_service'),
name: 'Status Test Service',
category: 'Status',
provider_price: '2.0000',
markup_percent: '25.0000',
sell_price: '2.5000',
min: 10,
max: 10000,
is_active: true
});
createdServiceIds.push(id);
return id;
}
async function createTrackedOrder(userId, serviceId, providerOrderId, status = 'processing') {
const [id] = await getDb()('orders').insert({
user_id: userId,
service_id: serviceId,
link: 'https://example.com/status-order',
quantity: 1000,
charge: '2.5000',
provider_order_id: providerOrderId,
status
});
return id;
}
test.before(async () => {
providerMock = await startProviderMock();
process.env.PROVIDER_API_URL = providerMock.url;
process.env.PROVIDER_API_KEY = 'mock-key';
});
test.after(async () => {
const db = getDb();
if (createdUserIds.length > 0) {
await db('orders').whereIn('user_id', createdUserIds).del();
await db('transactions').whereIn('user_id', createdUserIds).del();
await db('users').whereIn('id', createdUserIds).del();
}
if (createdServiceIds.length > 0) {
await db('services').whereIn('id', createdServiceIds).del();
}
await closeDbPool();
await providerMock.close();
});
test('syncOrderStatuses updates completed orders with remains and provider charge', async () => {
const userId = await createUser();
const serviceId = await createService();
const providerOrderId = unique('provider_completed');
const orderId = await createTrackedOrder(userId, serviceId, providerOrderId);
statuses.set(providerOrderId, {
status: 'Completed',
charge: '1.7500',
start_count: 100,
remains: 0
});
const result = await syncOrderStatuses();
const order = await getDb()('orders').where({ id: orderId }).first();
assert.equal(result.updated >= 1, true);
assert.equal(order.status, 'completed');
assert.equal(order.start_count, 100);
assert.equal(order.remains, 0);
assert.equal(order.provider_charge, '1.7500');
assert.ok(order.last_status_check_at);
});
test('partial status is recorded without automatic wallet refund', async () => {
const userId = await createUser('7.5000');
const serviceId = await createService();
const providerOrderId = unique('provider_partial');
const orderId = await createTrackedOrder(userId, serviceId, providerOrderId);
statuses.set(providerOrderId, {
status: 'Partial',
charge: '1.0000',
start_count: 55,
remains: 400
});
await syncOrderStatuses();
const order = await getDb()('orders').where({ id: orderId }).first();
const user = await getDb()('users').where({ id: userId }).first();
const refunds = await getDb()('transactions').where({ user_id: userId, type: 'refund' });
assert.equal(order.status, 'partial');
assert.equal(order.remains, 400);
assert.equal(user.balance, '7.5000');
assert.equal(refunds.length, 0);
});
+207
View File
@@ -0,0 +1,207 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const { closeDbPool, getDb } = require('../src/config/db');
const {
calculateOrderCharge,
createOrder,
getUserOrder,
listUserOrders
} = require('../src/services/orders.service');
const { creditBalance } = require('../src/services/wallet.service');
const createdUserIds = [];
const createdServiceIds = [];
let providerCalls = [];
let providerMode = 'success';
let providerMock;
function unique(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function startProviderMock() {
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = new URLSearchParams(Buffer.concat(chunks).toString());
providerCalls.push(Object.fromEntries(body.entries()));
res.setHeader('content-type', 'application/json');
if (body.get('action') !== 'add') {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'unsupported action' }));
return;
}
if (providerMode === 'failure') {
res.end(JSON.stringify({ error: 'provider rejected order' }));
return;
}
res.end(JSON.stringify({ order: `provider-${Date.now()}` }));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
url: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
async function createUser() {
const db = getDb();
const username = unique('order_user');
const [id] = await db('users').insert({
username,
email: `${username}@example.com`,
password_hash: 'hash',
balance: '0.0000'
});
createdUserIds.push(id);
return id;
}
async function createService(overrides = {}) {
const [id] = await getDb()('services').insert({
provider_service_id: unique('provider_service'),
name: 'Instagram Followers',
category: 'Instagram',
provider_price: '4.0000',
markup_percent: '25.0000',
sell_price: '5.0000',
min: 100,
max: 10000,
is_active: true,
...overrides
});
createdServiceIds.push(id);
return id;
}
async function readUser(userId) {
return getDb()('users').where({ id: userId }).first();
}
test.before(async () => {
providerMock = await startProviderMock();
process.env.PROVIDER_API_URL = providerMock.url;
process.env.PROVIDER_API_KEY = 'mock-key';
});
test.after(async () => {
const db = getDb();
if (createdUserIds.length > 0) {
await db('orders').whereIn('user_id', createdUserIds).del();
await db('deposits').whereIn('user_id', createdUserIds).orWhereIn('reviewed_by', createdUserIds).del();
await db('transactions').whereIn('user_id', createdUserIds).del();
await db('password_reset_tokens').whereIn('user_id', createdUserIds).del();
await db('refresh_tokens').whereIn('user_id', createdUserIds).del();
await db('users').whereIn('id', createdUserIds).del();
}
if (createdServiceIds.length > 0) {
await db('services').whereIn('id', createdServiceIds).del();
}
await closeDbPool();
await providerMock.close();
});
test('calculateOrderCharge uses server sell_price per 1000', () => {
assert.equal(calculateOrderCharge('5.0000', 1000), '5.0000');
assert.equal(calculateOrderCharge('5.0000', 250), '1.2500');
assert.equal(calculateOrderCharge('1.3333', 3), '0.0040');
});
test('successful order charges wallet, creates order, and sends provider order', async () => {
providerMode = 'success';
providerCalls = [];
const userId = await createUser();
const serviceId = await createService({ provider_service_id: 'provider-success-test' });
await creditBalance(userId, '20.0000', unique('seed_order_success'), 'seed balance');
const order = await createOrder(userId, {
service_id: serviceId,
link: 'https://example.com/post/1',
quantity: 1500
});
const user = await readUser(userId);
const transactions = await getDb()('transactions').where({ user_id: userId, type: 'order', reference: `order:${order.id}` });
assert.equal(order.status, 'processing');
assert.equal(order.charge, '7.5000');
assert.equal(user.balance, '12.5000');
assert.equal(transactions.length, 1);
assert.equal(providerCalls.length, 1);
assert.equal(providerCalls[0].service, 'provider-success-test');
assert.equal(providerCalls[0].quantity, '1500');
});
test('failed provider order refunds wallet and marks order failed', async () => {
providerMode = 'failure';
providerCalls = [];
const userId = await createUser();
const serviceId = await createService({ provider_service_id: 'provider-failure-test' });
await creditBalance(userId, '10.0000', unique('seed_order_failure'), 'seed balance');
await assert.rejects(
() => createOrder(userId, {
service_id: serviceId,
link: 'https://example.com/post/2',
quantity: 1000
}),
(error) => error.code === 'PROVIDER_ERROR'
);
const user = await readUser(userId);
const [order] = await getDb()('orders').where({ user_id: userId, service_id: serviceId });
const refund = await getDb()('transactions').where({ user_id: userId, type: 'refund', reference: `refund:order:${order.id}` }).first();
assert.equal(order.status, 'failed');
assert.equal(user.balance, '10.0000');
assert.equal(refund.amount, '5.0000');
});
test('users can list and read only their own orders', async () => {
providerMode = 'success';
const ownerId = await createUser();
const otherUserId = await createUser();
const serviceId = await createService({ provider_service_id: 'provider-visibility-test' });
await creditBalance(ownerId, '10.0000', unique('seed_owner'), 'seed owner');
await creditBalance(otherUserId, '10.0000', unique('seed_other'), 'seed other');
const ownerOrder = await createOrder(ownerId, {
service_id: serviceId,
link: 'https://example.com/owner',
quantity: 1000
});
await createOrder(otherUserId, {
service_id: serviceId,
link: 'https://example.com/other',
quantity: 1000
});
const ownerOrders = await listUserOrders(ownerId);
assert.equal(ownerOrders.some((order) => order.id === ownerOrder.id), true);
assert.equal(ownerOrders.every((order) => Number(order.user_id) === Number(ownerId)), true);
await assert.rejects(
() => getUserOrder(otherUserId, ownerOrder.id),
(error) => error.code === 'ORDER_NOT_FOUND'
);
});
+142
View File
@@ -0,0 +1,142 @@
const assert = require('node:assert/strict');
const http = require('node:http');
const test = require('node:test');
const { closeDbPool, getDb } = require('../src/config/db');
const {
calculateSellPrice,
listActiveServices,
listAdminServices,
syncServicesFromProvider,
updateService
} = require('../src/services/services.service');
const providerIds = [];
function startProviderMock() {
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = new URLSearchParams(Buffer.concat(chunks).toString());
const action = body.get('action');
res.setHeader('content-type', 'application/json');
if (action === 'services') {
res.end(JSON.stringify([
{
service: 'svc-test-1',
name: 'Instagram Followers',
category: 'Instagram',
rate: '4.0000',
min: 100,
max: 10000
},
{
service: 'svc-test-2',
name: 'TikTok Views',
category: 'TikTok',
rate: '1.2000',
min: 1000,
max: 500000
}
]));
return;
}
res.statusCode = 400;
res.end(JSON.stringify({ error: 'unknown action' }));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve({
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((done) => server.close(done))
});
});
});
}
test.after(async () => {
if (providerIds.length > 0) {
await getDb()('services').whereIn('provider_service_id', providerIds).del();
}
await closeDbPool();
});
test('calculateSellPrice applies markup and keeps four decimals', () => {
assert.equal(calculateSellPrice('4.0000', '25'), '5.0000');
assert.equal(calculateSellPrice('1.2000', '12.5'), '1.3500');
});
test('syncServicesFromProvider stores services and public listing hides provider price', async () => {
const mock = await startProviderMock();
const oldUrl = process.env.PROVIDER_API_URL;
const oldKey = process.env.PROVIDER_API_KEY;
process.env.PROVIDER_API_URL = mock.url;
process.env.PROVIDER_API_KEY = 'mock-key';
try {
const result = await syncServicesFromProvider('25');
assert.equal(result.synced, 2);
providerIds.push('svc-test-1', 'svc-test-2');
const adminServices = await listAdminServices();
const firstAdmin = adminServices.find((service) => service.provider_service_id === 'svc-test-1');
assert.equal(firstAdmin.provider_price, '4.0000');
assert.equal(firstAdmin.markup_percent, '25.0000');
assert.equal(firstAdmin.sell_price, '5.0000');
const publicServices = await listActiveServices();
const firstPublic = publicServices.find((service) => service.provider_service_id === 'svc-test-1');
assert.equal(firstPublic.sell_price, '5.0000');
assert.equal(Object.hasOwn(firstPublic, 'provider_price'), false);
assert.equal(Object.hasOwn(firstPublic, 'markup_percent'), false);
} finally {
process.env.PROVIDER_API_URL = oldUrl;
process.env.PROVIDER_API_KEY = oldKey;
await mock.close();
}
});
test('updateService can adjust markup, sell price, and activation', async () => {
let service = await getDb()('services').where({ provider_service_id: 'svc-test-2' }).first();
if (!service) {
const [id] = await getDb()('services').insert({
provider_service_id: 'svc-test-2',
name: 'TikTok Views',
category: 'TikTok',
provider_price: '1.2000',
markup_percent: '25.0000',
sell_price: '1.5000',
min: 1000,
max: 500000,
is_active: true
});
providerIds.push('svc-test-2');
service = await getDb()('services').where({ id }).first();
}
const updated = await updateService(
service.id,
{
markup_percent: '50',
is_active: false
}
);
assert.equal(updated.sell_price, '1.8000');
assert.equal(Boolean(updated.is_active), false);
const publicServices = await listActiveServices();
assert.equal(publicServices.some((service) => service.provider_service_id === 'svc-test-2'), false);
});
+188
View File
@@ -0,0 +1,188 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { closeDbPool, getDb } = require('../src/config/db');
const {
chargeBalance,
creditBalance,
createDepositRequest,
reviewDeposit,
runReconciliation
} = require('../src/services/wallet.service');
const createdUserIds = [];
function unique(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
async function createUser(overrides = {}) {
const db = getDb();
const username = unique('wallet_user');
const [id] = await db('users').insert({
username,
email: `${username}@example.com`,
password_hash: 'test_hash',
balance: '0.0000',
role: 'user',
status: 'active',
...overrides
});
createdUserIds.push(id);
return id;
}
async function readUser(userId) {
return getDb()('users').where({ id: userId }).first();
}
async function cleanup() {
if (createdUserIds.length === 0) {
return;
}
const db = getDb();
await db('password_reset_tokens').whereIn('user_id', createdUserIds).del();
await db('refresh_tokens').whereIn('user_id', createdUserIds).del();
await db('idempotency_keys').whereIn('user_id', createdUserIds).del();
await db('deposits').whereIn('user_id', createdUserIds).orWhereIn('reviewed_by', createdUserIds).del();
await db('transactions').whereIn('user_id', createdUserIds).del();
await db('users').whereIn('id', createdUserIds).del();
}
test.after(async () => {
await cleanup();
await closeDbPool();
});
test('charge bigger than balance is rejected and leaves balance unchanged', async () => {
const userId = await createUser();
await assert.rejects(
() => chargeBalance(userId, '1.0000', unique('order_ref'), 'too expensive'),
(error) => error.code === 'INSUFFICIENT_BALANCE'
);
const user = await readUser(userId);
const transactions = await getDb()('transactions').where({ user_id: userId });
assert.equal(user.balance, '0.0000');
assert.equal(transactions.length, 0);
});
test('parallel charges on same balance allow only one spend when funds cover one order', async () => {
const userId = await createUser();
await creditBalance(userId, '10.0000', unique('seed_ref'), 'seed balance');
const results = await Promise.allSettled([
chargeBalance(userId, '6.0000', unique('order_ref_a'), 'parallel A'),
chargeBalance(userId, '6.0000', unique('order_ref_b'), 'parallel B')
]);
const fulfilled = results.filter((result) => result.status === 'fulfilled');
const rejected = results.filter((result) => result.status === 'rejected');
const user = await readUser(userId);
const orderTransactions = await getDb()('transactions').where({ user_id: userId, type: 'order' });
assert.equal(fulfilled.length, 1);
assert.equal(rejected.length, 1);
assert.equal(rejected[0].reason.code, 'INSUFFICIENT_BALANCE');
assert.equal(user.balance, '4.0000');
assert.equal(orderTransactions.length, 1);
});
test('parallel approval of one deposit credits balance only once', async () => {
const adminId = await createUser({ role: 'admin' });
const userId = await createUser();
const deposit = await createDepositRequest(userId, {
amount: '15.0000',
method: 'paypal',
tx_ref: unique('tx_ref')
});
const results = await Promise.allSettled([
reviewDeposit(deposit.id, adminId, 'approve', 'approved once'),
reviewDeposit(deposit.id, adminId, 'approve', 'approved twice')
]);
const fulfilled = results.filter((result) => result.status === 'fulfilled');
const rejected = results.filter((result) => result.status === 'rejected');
const user = await readUser(userId);
const depositTransactions = await getDb()('transactions').where({
user_id: userId,
type: 'deposit',
reference: `deposit:${deposit.id}`
});
assert.equal(fulfilled.length, 1);
assert.equal(rejected.length, 1);
assert.equal(rejected[0].reason.code, 'DEPOSIT_ALREADY_REVIEWED');
assert.equal(user.balance, '15.0000');
assert.equal(depositTransactions.length, 1);
});
test('duplicate tx_ref is rejected without touching balance', async () => {
const userId = await createUser();
const txRef = unique('duplicate_tx_ref');
await createDepositRequest(userId, {
amount: '5.0000',
method: 'paypal',
tx_ref: txRef
});
await assert.rejects(
() => createDepositRequest(userId, {
amount: '9.0000',
method: 'paypal',
tx_ref: txRef
}),
(error) => error.code === 'DUPLICATE_TX_REF'
);
const user = await readUser(userId);
const deposits = await getDb()('deposits').where({ user_id: userId, tx_ref: txRef });
assert.equal(user.balance, '0.0000');
assert.equal(deposits.length, 1);
});
test('deposit with unavailable payment method is rejected before creating a request', async () => {
const userId = await createUser();
const txRef = unique('missing_method_tx_ref');
await assert.rejects(
() => createDepositRequest(userId, {
amount: '5.0000',
method: 'missing_method',
tx_ref: txRef
}),
(error) => error.code === 'PAYMENT_METHOD_UNAVAILABLE'
);
const deposits = await getDb()('deposits').where({ user_id: userId, tx_ref: txRef });
const user = await readUser(userId);
assert.equal(deposits.length, 0);
assert.equal(user.balance, '0.0000');
});
test('transaction sum remains equal to user balance after a mixed sequence', async () => {
const userId = await createUser();
await creditBalance(userId, '20.0000', unique('credit_ref'), 'first credit');
await chargeBalance(userId, '7.2500', unique('order_ref'), 'order charge');
await creditBalance(userId, '2.1250', unique('refund_ref'), 'partial refund', 'refund');
await chargeBalance(userId, '4.8750', unique('order_ref'), 'second order');
const mismatches = await runReconciliation();
const ownMismatch = mismatches.find((row) => Number(row.userId) === Number(userId));
const [sumRow] = await getDb()('transactions').where({ user_id: userId }).sum({ total: 'amount' });
const user = await readUser(userId);
assert.equal(ownMismatch, undefined);
assert.equal(sumRow.total, user.balance);
assert.equal(user.balance, '10.0000');
});