commit 7717a674c7f5a7ca282e20c3c51a63142e9b49b0 Author: diyaa Date: Thu Aug 13 19:05:47 2026 +0200 Initial SMM panel implementation 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'); +});