Initial SMM panel implementation
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
backups/
|
||||||
@@ -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.
|
||||||
|
- علّق الكود بالإنجليزية، والرسائل للمستخدم بالعربية (أو حسب إعداد اللغة).
|
||||||
@@ -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
@@ -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
@@ -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 الموجودة أصلاً — شغّلها بعد التعديل للتأكد.
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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 وقناة شام كاش من الواجهة، وتأكد التعليمات تظهر صح لكل وحدة.
|
||||||
@@ -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؟"* — إذا الجواب لا لأي منهما، الكود غير جاهز للإنتاج بغض النظر عن أي شيء آخر.
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+1831
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+22
@@ -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"
|
||||||
Executable
+29
@@ -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
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user