From 7717a674c7f5a7ca282e20c3c51a63142e9b49b0 Mon Sep 17 00:00:00 2001 From: diyaa Date: Thu, 13 Aug 2026 19:05:47 +0200 Subject: [PATCH] Initial SMM panel implementation --- .dockerignore | 8 + .env.example | 11 + .gitignore | 6 + AGENTS.md | 303 +++ DECISIONS.md | 16 + Dockerfile | 12 + FRONTEND.md | 275 +++ Makefile | 52 + README.md | 130 ++ SECURITY.md | 14 + TASKS.md | 206 ++ WALLET.md | 188 ++ docker-compose.yml | 44 + knexfile.js | 34 + package-lock.json | 1831 +++++++++++++++++ package.json | 42 + public/app.js | 778 +++++++ public/index.html | 104 + public/styles.css | 746 +++++++ scripts/db-backup.sh | 22 + scripts/db-restore.sh | 29 + src/app.js | 78 + src/config/db.js | 67 + src/config/env.js | 32 + src/db/migrations/.gitkeep | 1 + .../20260810190000_create_core_schema.js | 116 ++ ...0810200000_create_password_reset_tokens.js | 18 + ...0210000_add_adjustment_transaction_type.js | 7 + ...260811090000_add_service_markup_percent.js | 11 + ...0260811100000_add_order_tracking_fields.js | 17 + .../20260812120000_create_payment_methods.js | 43 + src/jobs/order-status.job.js | 22 + src/jobs/order-status.scheduler.js | 29 + src/jobs/reconciliation.job.js | 24 + src/jobs/wallet-reconciliation.scheduler.js | 35 + src/middleware/auth.js | 79 + src/middleware/error-handler.js | 30 + src/middleware/validate.js | 20 + src/routes/admin.routes.js | 233 +++ src/routes/auth.routes.js | 138 ++ src/routes/deposit.routes.js | 51 + src/routes/health.routes.js | 23 + src/routes/me.routes.js | 19 + src/routes/order.routes.js | 61 + src/routes/payment-method.routes.js | 19 + src/routes/service.routes.js | 18 + src/routes/transaction.routes.js | 19 + src/scripts/create-admin.js | 63 + src/server.js | 48 + src/services/admin.service.js | 52 + src/services/auth.service.js | 219 ++ src/services/orders.service.js | 265 +++ src/services/payment-methods.service.js | 91 + src/services/provider.service.js | 86 + src/services/services.service.js | 130 ++ src/services/wallet.service.js | 270 +++ src/utils/logger.js | 28 + src/utils/money.js | 52 + tests/admin.test.js | 379 ++++ tests/e2e.test.js | 182 ++ tests/order-status.test.js | 163 ++ tests/orders.test.js | 207 ++ tests/services.test.js | 142 ++ tests/wallet.test.js | 188 ++ 64 files changed, 8626 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 DECISIONS.md create mode 100644 Dockerfile create mode 100644 FRONTEND.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 TASKS.md create mode 100644 WALLET.md create mode 100644 docker-compose.yml create mode 100644 knexfile.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100755 scripts/db-backup.sh create mode 100755 scripts/db-restore.sh create mode 100644 src/app.js create mode 100644 src/config/db.js create mode 100644 src/config/env.js create mode 100644 src/db/migrations/.gitkeep create mode 100644 src/db/migrations/20260810190000_create_core_schema.js create mode 100644 src/db/migrations/20260810200000_create_password_reset_tokens.js create mode 100644 src/db/migrations/20260810210000_add_adjustment_transaction_type.js create mode 100644 src/db/migrations/20260811090000_add_service_markup_percent.js create mode 100644 src/db/migrations/20260811100000_add_order_tracking_fields.js create mode 100644 src/db/migrations/20260812120000_create_payment_methods.js create mode 100644 src/jobs/order-status.job.js create mode 100644 src/jobs/order-status.scheduler.js create mode 100644 src/jobs/reconciliation.job.js create mode 100644 src/jobs/wallet-reconciliation.scheduler.js create mode 100644 src/middleware/auth.js create mode 100644 src/middleware/error-handler.js create mode 100644 src/middleware/validate.js create mode 100644 src/routes/admin.routes.js create mode 100644 src/routes/auth.routes.js create mode 100644 src/routes/deposit.routes.js create mode 100644 src/routes/health.routes.js create mode 100644 src/routes/me.routes.js create mode 100644 src/routes/order.routes.js create mode 100644 src/routes/payment-method.routes.js create mode 100644 src/routes/service.routes.js create mode 100644 src/routes/transaction.routes.js create mode 100644 src/scripts/create-admin.js create mode 100644 src/server.js create mode 100644 src/services/admin.service.js create mode 100644 src/services/auth.service.js create mode 100644 src/services/orders.service.js create mode 100644 src/services/payment-methods.service.js create mode 100644 src/services/provider.service.js create mode 100644 src/services/services.service.js create mode 100644 src/services/wallet.service.js create mode 100644 src/utils/logger.js create mode 100644 src/utils/money.js create mode 100644 tests/admin.test.js create mode 100644 tests/e2e.test.js create mode 100644 tests/order-status.test.js create mode 100644 tests/orders.test.js create mode 100644 tests/services.test.js create mode 100644 tests/wallet.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2691d4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log* +.env +.DS_Store +coverage +.git +.agents +.codex diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e48e08 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..033d7f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +npm-debug.log* +.DS_Store +coverage/ +backups/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49f5227 --- /dev/null +++ b/AGENTS.md @@ -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: } + +# متابعة حالة طلب +POST { key, action: "status", order: } +→ يرجّع { 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. +- علّق الكود بالإنجليزية، والرسائل للمستخدم بالعربية (أو حسب إعداد اللغة). diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..6e689c9 --- /dev/null +++ b/DECISIONS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2b98c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/FRONTEND.md b/FRONTEND.md new file mode 100644 index 0000000..e2b8da4 --- /dev/null +++ b/FRONTEND.md @@ -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)`** — يحوّل أي `
` لكائن 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)، وتحت كل لون **اسمه** (تركواز، + أزرق، بنفسجي، أخضر، برتقالي، وردي) — مو لون بدون تسمية. +- اللون **المختار حالياً** يبين عليه علامة واضحة (حلقة حدّ بلون داكن + ✓ صغيرة + بالنص) — حالياً ما في أي إشارة لأي لون مفعّل. +- قسم "لون مخصص" منفصل تحت الشبكة فيه: `` بحجم أكبر + حقل نصي + بجانبه يعرض/يقبل كود الـ 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 المشكلة بالضبط +حقل "الطريقة" بفورم الشحن حالياً `` +— نص حر بالكامل. هذا يعني: +- المستخدم لازم يخمّن شو يكتب. +- ما في أي تعليمات وين يحوّل المصاري فعلياً (رقم حساب 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. بدّل حقل النص الحر بقائمة اختيار (``). +- [x] عند اختيار لون: حوّل HEX إلى HSL بـ JS، حدّث المتغيرات الثلاث على `document.documentElement`، واحفظ الاختيار بـ `localStorage`. +- [x] طبّق اللون المحفوظ فوراً بأول تنفيذ للسكربت (قبل أول رسم) لمنع أي وميض بلون افتراضي. +- [x] **(تحسين مطلوب — راجع `FRONTEND.md` § 3.3 "نسخة منقّحة")** الزر الحالي دائرة صغيرة بدون تسمية وحسّه بدائي — بدّله بزر فيه أيقونة (SVG بسيط) + نص "المظهر" (يختفي على الموبايل، أيقونة بس). +- [x] لوحة الألوان: كبّر الشبكة (44px بدل 34px) وأضف اسم تحت كل لون (تركواز/أزرق/بنفسجي/أخضر/برتقالي/وردي). +- [x] بيّن اللون المفعّل حالياً بعلامة واضحة (حلقة حد + ✓) على الـ Preset المطابق. +- [x] قسم اللون المخصص: زد حجم ``، أضف حقل نصي 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] الواجهة: بدّل حقل "الطريقة" النصي بـ `${options} + + + +
+ ${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) => ``).join(''); + const instructions = selectedMethod ? renderPaymentInstructions(selectedMethod) : '
لا توجد قنوات شحن متاحة حالياً.
'; + + el.view.innerHTML = ` +
+ + +
${instructions}
+ + +
+ ${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 ` +
+ ${method.label_ar} +

${method.instructions}

+ ${method.destination ? `${method.destination}` : ''} +
+ `; +} + +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 = ` +
+
المستخدمون${stats.users_total ?? 0}
+
طلبات معلقة${stats.orders_pending ?? 0}
+
المبيعات${stats.gross_sales ?? '0.0000'}
+
الربح${stats.profit ?? '0.0000'}
+
+
+
+ + +
+
+

المستخدمون

+ ${table(['رقم', 'اسم المستخدم', 'البريد', 'الدور', 'الحالة', 'الرصيد', 'إجراء'], state.admin.users.map((user) => row(['رقم', 'اسم المستخدم', 'البريد', 'الدور', 'الحالة', 'الرصيد', 'إجراء'], [ + user.id, + user.username, + user.email, + user.role, + statusBadge(user.status), + user.balance, + ` + + + ` + ])))} +

طلبات الشحن

+ ${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' ? ` ` : '-' + ])))} +
+

قنوات الدفع

+
+ + + + + + +
+
+ ${table(['رقم', 'الكود', 'الاسم', 'الوجهة', 'الحالة', 'إجراء'], state.admin.paymentMethods.map((method) => row(['رقم', 'الكود', 'الاسم', 'الوجهة', 'الحالة', 'إجراء'], [ + method.id, + method.code, + method.label_ar, + method.destination || '-', + statusBadge(method.is_active ? 'active' : 'banned'), + `` + ])))} +

الخدمات

+ ${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'), + `` + ])))} +

كل الطلبات

+ ${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(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..681c8f9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,104 @@ + + + + + + SMM Panel + + + +
+
+
+ +
+

SMM Panel

+

إدارة الطلبات والرصيد والخدمات

+
+
+ +
+
+

تسجيل الدخول

+ + + +
+ +
+

حساب جديد

+ + + + +
+
+ +
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..d4145ad --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/scripts/db-backup.sh b/scripts/db-backup.sh new file mode 100755 index 0000000..f3bdd70 --- /dev/null +++ b/scripts/db-backup.sh @@ -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" diff --git a/scripts/db-restore.sh b/scripts/db-restore.sh new file mode 100755 index 0000000..bd8a922 --- /dev/null +++ b/scripts/db-restore.sh @@ -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" diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..3dad21d --- /dev/null +++ b/src/app.js @@ -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 }; diff --git a/src/config/db.js b/src/config/db.js new file mode 100644 index 0000000..223f4ff --- /dev/null +++ b/src/config/db.js @@ -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 +}; diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..45a41b7 --- /dev/null +++ b/src/config/env.js @@ -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 +}; diff --git a/src/db/migrations/.gitkeep b/src/db/migrations/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/db/migrations/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/db/migrations/20260810190000_create_core_schema.js b/src/db/migrations/20260810190000_create_core_schema.js new file mode 100644 index 0000000..a85186f --- /dev/null +++ b/src/db/migrations/20260810190000_create_core_schema.js @@ -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'); +}; diff --git a/src/db/migrations/20260810200000_create_password_reset_tokens.js b/src/db/migrations/20260810200000_create_password_reset_tokens.js new file mode 100644 index 0000000..1b2881c --- /dev/null +++ b/src/db/migrations/20260810200000_create_password_reset_tokens.js @@ -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'); +}; diff --git a/src/db/migrations/20260810210000_add_adjustment_transaction_type.js b/src/db/migrations/20260810210000_add_adjustment_transaction_type.js new file mode 100644 index 0000000..8d7b482 --- /dev/null +++ b/src/db/migrations/20260810210000_add_adjustment_transaction_type.js @@ -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"); +}; diff --git a/src/db/migrations/20260811090000_add_service_markup_percent.js b/src/db/migrations/20260811090000_add_service_markup_percent.js new file mode 100644 index 0000000..34704c3 --- /dev/null +++ b/src/db/migrations/20260811090000_add_service_markup_percent.js @@ -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'); + }); +}; diff --git a/src/db/migrations/20260811100000_add_order_tracking_fields.js b/src/db/migrations/20260811100000_add_order_tracking_fields.js new file mode 100644 index 0000000..7e075e0 --- /dev/null +++ b/src/db/migrations/20260811100000_add_order_tracking_fields.js @@ -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'); + }); +}; diff --git a/src/db/migrations/20260812120000_create_payment_methods.js b/src/db/migrations/20260812120000_create_payment_methods.js new file mode 100644 index 0000000..16d28e9 --- /dev/null +++ b/src/db/migrations/20260812120000_create_payment_methods.js @@ -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'); +}; diff --git a/src/jobs/order-status.job.js b/src/jobs/order-status.job.js new file mode 100644 index 0000000..a64c06b --- /dev/null +++ b/src/jobs/order-status.job.js @@ -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()); diff --git a/src/jobs/order-status.scheduler.js b/src/jobs/order-status.scheduler.js new file mode 100644 index 0000000..256a678 --- /dev/null +++ b/src/jobs/order-status.scheduler.js @@ -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 +}; diff --git a/src/jobs/reconciliation.job.js b/src/jobs/reconciliation.job.js new file mode 100644 index 0000000..08567a5 --- /dev/null +++ b/src/jobs/reconciliation.job.js @@ -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()); diff --git a/src/jobs/wallet-reconciliation.scheduler.js b/src/jobs/wallet-reconciliation.scheduler.js new file mode 100644 index 0000000..04ca80d --- /dev/null +++ b/src/jobs/wallet-reconciliation.scheduler.js @@ -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 +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..01f2c69 --- /dev/null +++ b/src/middleware/auth.js @@ -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 +}; diff --git a/src/middleware/error-handler.js b/src/middleware/error-handler.js new file mode 100644 index 0000000..071071f --- /dev/null +++ b/src/middleware/error-handler.js @@ -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 +}; diff --git a/src/middleware/validate.js b/src/middleware/validate.js new file mode 100644 index 0000000..e8257b0 --- /dev/null +++ b/src/middleware/validate.js @@ -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 +}; diff --git a/src/routes/admin.routes.js b/src/routes/admin.routes.js new file mode 100644 index 0000000..4df586a --- /dev/null +++ b/src/routes/admin.routes.js @@ -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 +}; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js new file mode 100644 index 0000000..7dd4422 --- /dev/null +++ b/src/routes/auth.routes.js @@ -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 +}; diff --git a/src/routes/deposit.routes.js b/src/routes/deposit.routes.js new file mode 100644 index 0000000..a1ffe47 --- /dev/null +++ b/src/routes/deposit.routes.js @@ -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 +}; diff --git a/src/routes/health.routes.js b/src/routes/health.routes.js new file mode 100644 index 0000000..3540443 --- /dev/null +++ b/src/routes/health.routes.js @@ -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 +}; diff --git a/src/routes/me.routes.js b/src/routes/me.routes.js new file mode 100644 index 0000000..c51d7d3 --- /dev/null +++ b/src/routes/me.routes.js @@ -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 +}; diff --git a/src/routes/order.routes.js b/src/routes/order.routes.js new file mode 100644 index 0000000..2a1671c --- /dev/null +++ b/src/routes/order.routes.js @@ -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 +}; diff --git a/src/routes/payment-method.routes.js b/src/routes/payment-method.routes.js new file mode 100644 index 0000000..e37f575 --- /dev/null +++ b/src/routes/payment-method.routes.js @@ -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 +}; diff --git a/src/routes/service.routes.js b/src/routes/service.routes.js new file mode 100644 index 0000000..b7521d7 --- /dev/null +++ b/src/routes/service.routes.js @@ -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 +}; diff --git a/src/routes/transaction.routes.js b/src/routes/transaction.routes.js new file mode 100644 index 0000000..3b5c721 --- /dev/null +++ b/src/routes/transaction.routes.js @@ -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 +}; diff --git a/src/scripts/create-admin.js b/src/scripts/create-admin.js new file mode 100644 index 0000000..90a0c2f --- /dev/null +++ b/src/scripts/create-admin.js @@ -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()); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..4df25ab --- /dev/null +++ b/src/server.js @@ -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); +}); diff --git a/src/services/admin.service.js b/src/services/admin.service.js new file mode 100644 index 0000000..6f214b6 --- /dev/null +++ b/src/services/admin.service.js @@ -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 +}; diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 0000000..80d0d1c --- /dev/null +++ b/src/services/auth.service.js @@ -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 +}; diff --git a/src/services/orders.service.js b/src/services/orders.service.js new file mode 100644 index 0000000..0f65b16 --- /dev/null +++ b/src/services/orders.service.js @@ -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 +}; diff --git a/src/services/payment-methods.service.js b/src/services/payment-methods.service.js new file mode 100644 index 0000000..6d27f80 --- /dev/null +++ b/src/services/payment-methods.service.js @@ -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 +}; diff --git a/src/services/provider.service.js b/src/services/provider.service.js new file mode 100644 index 0000000..1cff68c --- /dev/null +++ b/src/services/provider.service.js @@ -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 +}; diff --git a/src/services/services.service.js b/src/services/services.service.js new file mode 100644 index 0000000..17ef642 --- /dev/null +++ b/src/services/services.service.js @@ -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 +}; diff --git a/src/services/wallet.service.js b/src/services/wallet.service.js new file mode 100644 index 0000000..b3b60fa --- /dev/null +++ b/src/services/wallet.service.js @@ -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 +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..aeff0b7 --- /dev/null +++ b/src/utils/logger.js @@ -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 +}; diff --git a/src/utils/money.js b/src/utils/money.js new file mode 100644 index 0000000..ed93c4d --- /dev/null +++ b/src/utils/money.js @@ -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 +}; diff --git a/tests/admin.test.js b/tests/admin.test.js new file mode 100644 index 0000000..0ad52b2 --- /dev/null +++ b/tests/admin.test.js @@ -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; + } + }); + } +}); diff --git a/tests/e2e.test.js b/tests/e2e.test.js new file mode 100644 index 0000000..5431976 --- /dev/null +++ b/tests/e2e.test.js @@ -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(); + } +}); diff --git a/tests/order-status.test.js b/tests/order-status.test.js new file mode 100644 index 0000000..2576ce4 --- /dev/null +++ b/tests/order-status.test.js @@ -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); +}); diff --git a/tests/orders.test.js b/tests/orders.test.js new file mode 100644 index 0000000..f7851dd --- /dev/null +++ b/tests/orders.test.js @@ -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' + ); +}); diff --git a/tests/services.test.js b/tests/services.test.js new file mode 100644 index 0000000..6c296ef --- /dev/null +++ b/tests/services.test.js @@ -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); +}); diff --git a/tests/wallet.test.js b/tests/wallet.test.js new file mode 100644 index 0000000..91f1c52 --- /dev/null +++ b/tests/wallet.test.js @@ -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'); +});