From ac12106770db2f9bd0b7323dee361042dd3ad904 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 5 May 2026 00:01:17 +0300 Subject: [PATCH] Update: 2026-05-05 00:01:17 --- app/config/plans.php | 116 ++++++++++ app/middleware/QuotaMiddleware.php | 270 ++++++++++++++++++++++ app/modules_app/companies/create.php | 4 + app/modules_app/invoices/upload.php | 12 + app/modules_app/subscriptions/assign.php | 95 ++++++++ app/modules_app/subscriptions/current.php | 25 ++ app/modules_app/subscriptions/plans.php | 52 +++++ app/modules_app/subscriptions/usage.php | 58 +++++ app/modules_app/users/create.php | 4 + public/index.php | 4 + public/shell.php | 114 ++++++++- scripts/migrate_phase1.php | 168 ++++++++++++++ scripts/seed_super_admin.php | 4 +- stratigy.md | 51 ++++ 14 files changed, 969 insertions(+), 8 deletions(-) create mode 100644 app/config/plans.php create mode 100644 app/middleware/QuotaMiddleware.php create mode 100644 app/modules_app/subscriptions/assign.php create mode 100644 app/modules_app/subscriptions/current.php create mode 100644 app/modules_app/subscriptions/plans.php create mode 100644 app/modules_app/subscriptions/usage.php create mode 100644 scripts/migrate_phase1.php create mode 100644 stratigy.md diff --git a/app/config/plans.php b/app/config/plans.php new file mode 100644 index 0000000..be2b4ba --- /dev/null +++ b/app/config/plans.php @@ -0,0 +1,116 @@ + [ + 'id' => 'free', + 'name_ar' => 'مجانية', + 'name_en' => 'Free', + 'max_companies' => 1, + 'max_invoices_month' => 15, + 'max_users' => 1, + 'price_jod' => 0.00, + 'ai_features' => false, + 'jofotara_enabled' => false, + 'badge_color' => 'gray', + 'description_ar' => 'للتجربة الأولية — شركة واحدة و15 فاتورة شهرياً', + 'features' => [ + 'استخراج بيانات الفواتير يدوياً', + 'شركة واحدة فقط', + '15 فاتورة شهرياً', + 'مستخدم واحد', + ], + ], + 'basic' => [ + 'id' => 'basic', + 'name_ar' => 'أساسية', + 'name_en' => 'Basic', + 'max_companies' => 3, + 'max_invoices_month' => 100, + 'max_users' => 3, + 'price_jod' => 15.00, + 'ai_features' => true, + 'jofotara_enabled' => false, + 'badge_color' => 'blue', + 'description_ar' => 'للمحاسبين المستقلين — ذكاء اصطناعي + 3 شركات', + 'features' => [ + 'استخراج ذكي بالـ AI', + 'حتى 3 شركات', + '100 فاتورة شهرياً', + '3 مستخدمين', + 'تقارير شهرية', + ], + ], + 'office' => [ + 'id' => 'office', + 'name_ar' => 'مكتبية', + 'name_en' => 'Office', + 'max_companies' => 10, + 'max_invoices_month' => 500, + 'max_users' => 10, + 'price_jod' => 45.00, + 'ai_features' => true, + 'jofotara_enabled' => true, + 'badge_color' => 'teal', + 'is_popular' => true, + 'description_ar' => 'للمكاتب المحاسبية — ربط مباشر بجوفوترة', + 'features' => [ + 'كل ميزات الأساسية', + 'ربط مباشر بنظام JoFotara', + 'حتى 10 شركات', + '500 فاتورة شهرياً', + '10 مستخدمين', + 'تقارير متقدمة + تصدير', + 'دعم فني بالأولوية', + ], + ], + 'pro' => [ + 'id' => 'pro', + 'name_ar' => 'احترافية', + 'name_en' => 'Pro', + 'max_companies' => 25, + 'max_invoices_month' => 2000, + 'max_users' => 25, + 'price_jod' => 99.00, + 'ai_features' => true, + 'jofotara_enabled' => true, + 'badge_color' => 'navy', + 'description_ar' => 'للمكاتب الكبيرة — حجم عمل ضخم بلا حدود عملية', + 'features' => [ + 'كل ميزات المكتبية', + 'حتى 25 شركة', + '2000 فاتورة شهرياً', + '25 مستخدم', + 'API كامل لتطبيق الهاتف', + 'تدقيق ذكي بالـ AI (Pre-Audit)', + 'مدير حساب مخصص', + ], + ], + 'enterprise' => [ + 'id' => 'enterprise', + 'name_ar' => 'مؤسسية', + 'name_en' => 'Enterprise', + 'max_companies' => 999, + 'max_invoices_month' => 99999, + 'max_users' => 999, + 'price_jod' => 249.00, + 'ai_features' => true, + 'jofotara_enabled' => true, + 'badge_color' => 'gold', + 'description_ar' => 'للمؤسسات — بلا حدود مع دعم مخصص', + 'features' => [ + 'كل ميزات الاحترافية', + 'شركات وفواتير بلا حدود عملية', + 'مستخدمين بلا حدود', + 'SLA مضمون 99.9%', + 'ربط API مخصص', + 'تدريب فريق المحاسبة', + 'نسخ احتياطي مخصص', + ], + ], +]; diff --git a/app/middleware/QuotaMiddleware.php b/app/middleware/QuotaMiddleware.php new file mode 100644 index 0000000..0dff7c1 --- /dev/null +++ b/app/middleware/QuotaMiddleware.php @@ -0,0 +1,270 @@ +prepare(" + SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled + FROM subscriptions s + LEFT JOIN subscription_plans sp ON s.plan_id = sp.id + WHERE s.tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) { + json_error('لا يوجد اشتراك فعّال لهذا المكتب. يرجى التواصل مع الإدارة.', 403); + } + + // Check subscription status + if ($sub['status'] === 'cancelled') { + json_error('تم إلغاء اشتراكك. يرجى تجديد الاشتراك للمتابعة.', 403); + } + + if ($sub['status'] === 'past_due') { + json_error('اشتراكك متأخر الدفع. يرجى تسوية المبلغ المستحق للمتابعة.', 403); + } + + // Auto-reset monthly counter if billing period has ended + if (!empty($sub['current_period_end']) && strtotime($sub['current_period_end']) < time()) { + $newStart = date('Y-m-d H:i:s'); + $newEnd = date('Y-m-d H:i:s', strtotime('+30 days')); + + $resetStmt = $db->prepare(" + UPDATE subscriptions + SET invoices_used_this_month = 0, + current_period_start = ?, + current_period_end = ?, + updated_at = NOW() + WHERE tenant_id = ? + "); + $resetStmt->execute([$newStart, $newEnd, $tenantId]); + + $sub['invoices_used_this_month'] = 0; + $sub['current_period_start'] = $newStart; + $sub['current_period_end'] = $newEnd; + + error_log("QuotaMiddleware: Auto-reset monthly counter for tenant {$tenantId}"); + } + + // Check invoice quota + $used = (int)$sub['invoices_used_this_month']; + $limit = (int)$sub['max_invoices_per_month']; + + if ($used >= $limit) { + json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة هذا الشهر (' . $limit . ' فاتورة). يرجى ترقية باقتك.', 429, [ + 'quota_type' => 'invoices', + 'used' => $used, + 'limit' => $limit, + 'plan' => $sub['plan_id'] ?? 'free', + 'plan_name' => $sub['plan_name'] ?? 'مجانية', + 'period_end' => $sub['current_period_end'], + ]); + } + + return $sub; + } + + /** + * Increment the monthly invoice counter after a successful upload. + */ + public static function incrementInvoiceUsage(string $tenantId): void + { + $db = Database::getInstance(); + $stmt = $db->prepare(" + UPDATE subscriptions + SET invoices_used_this_month = invoices_used_this_month + 1, + updated_at = NOW() + WHERE tenant_id = ? + "); + $stmt->execute([$tenantId]); + } + + /** + * Check if the tenant can add more companies. + */ + public static function checkCompanyQuota(string $tenantId): array + { + $db = Database::getInstance(); + + // Get subscription + $stmt = $db->prepare(" + SELECT s.*, sp.name_ar as plan_name + FROM subscriptions s + LEFT JOIN subscription_plans sp ON s.plan_id = sp.id + WHERE s.tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) { + json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403); + } + + // Count current active companies + $countStmt = $db->prepare(" + SELECT COUNT(*) FROM companies + WHERE tenant_id = ? AND (deleted_at IS NULL) + "); + $countStmt->execute([$tenantId]); + $currentCount = (int)$countStmt->fetchColumn(); + + $limit = (int)$sub['max_companies']; + + if ($currentCount >= $limit) { + json_error('لقد وصلت للحد الأقصى من الشركات المسموحة (' . $limit . ' شركة). يرجى ترقية باقتك.', 429, [ + 'quota_type' => 'companies', + 'used' => $currentCount, + 'limit' => $limit, + 'plan' => $sub['plan_id'] ?? 'free', + 'plan_name' => $sub['plan_name'] ?? 'مجانية', + ]); + } + + return $sub; + } + + /** + * Check if the tenant can add more users. + */ + public static function checkUserQuota(string $tenantId): array + { + $db = Database::getInstance(); + + // Get subscription + $stmt = $db->prepare(" + SELECT s.*, sp.name_ar as plan_name + FROM subscriptions s + LEFT JOIN subscription_plans sp ON s.plan_id = sp.id + WHERE s.tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) { + json_error('لا يوجد اشتراك فعّال لهذا المكتب.', 403); + } + + // Count current active users in this tenant + $countStmt = $db->prepare(" + SELECT COUNT(*) FROM users + WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1 + "); + $countStmt->execute([$tenantId]); + $currentCount = (int)$countStmt->fetchColumn(); + + $maxUsers = (int)($sub['max_users'] ?? 999); + + if ($currentCount >= $maxUsers) { + json_error('لقد وصلت للحد الأقصى من المستخدمين المسموحين (' . $maxUsers . ' مستخدم). يرجى ترقية باقتك.', 429, [ + 'quota_type' => 'users', + 'used' => $currentCount, + 'limit' => $maxUsers, + 'plan' => $sub['plan_id'] ?? 'free', + 'plan_name' => $sub['plan_name'] ?? 'مجانية', + ]); + } + + return $sub; + } + + /** + * Get usage summary for a tenant (for dashboard display). + */ + public static function getUsageSummary(string $tenantId): array + { + $db = Database::getInstance(); + + // Get subscription + $stmt = $db->prepare(" + SELECT s.*, sp.name_ar as plan_name, sp.name_en as plan_name_en, + sp.ai_features, sp.jofotara_enabled, sp.price_jod as plan_price + FROM subscriptions s + LEFT JOIN subscription_plans sp ON s.plan_id = sp.id + WHERE s.tenant_id = ? + "); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) { + return [ + 'has_subscription' => false, + 'plan' => 'none', + ]; + } + + // Count companies + $compStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL"); + $compStmt->execute([$tenantId]); + $companiesUsed = (int)$compStmt->fetchColumn(); + + // Count users + $userStmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND (deleted_at IS NULL) AND is_active = 1"); + $userStmt->execute([$tenantId]); + $usersUsed = (int)$userStmt->fetchColumn(); + + $invoicesUsed = (int)$sub['invoices_used_this_month']; + $invoicesLimit = (int)$sub['max_invoices_per_month']; + $companiesLimit = (int)$sub['max_companies']; + $usersLimit = (int)($sub['max_users'] ?? 999); + + return [ + 'has_subscription' => true, + 'plan_id' => $sub['plan_id'] ?? 'free', + 'plan_name' => $sub['plan_name'] ?? 'مجانية', + 'plan_name_en' => $sub['plan_name_en'] ?? 'Free', + 'plan_price' => (float)($sub['plan_price'] ?? 0), + 'status' => $sub['status'], + 'ai_features' => (bool)($sub['ai_features'] ?? false), + 'jofotara_enabled' => (bool)($sub['jofotara_enabled'] ?? false), + + 'invoices' => [ + 'used' => $invoicesUsed, + 'limit' => $invoicesLimit, + 'percent' => $invoicesLimit > 0 ? round(($invoicesUsed / $invoicesLimit) * 100) : 0, + 'warning' => $invoicesLimit > 0 && ($invoicesUsed / $invoicesLimit) >= 0.9, + ], + 'companies' => [ + 'used' => $companiesUsed, + 'limit' => $companiesLimit, + 'percent' => $companiesLimit > 0 ? round(($companiesUsed / $companiesLimit) * 100) : 0, + 'warning' => $companiesLimit > 0 && ($companiesUsed / $companiesLimit) >= 0.9, + ], + 'users' => [ + 'used' => $usersUsed, + 'limit' => $usersLimit, + 'percent' => $usersLimit > 0 ? round(($usersUsed / $usersLimit) * 100) : 0, + 'warning' => $usersLimit > 0 && ($usersUsed / $usersLimit) >= 0.9, + ], + + 'period_start' => $sub['current_period_start'], + 'period_end' => $sub['current_period_end'], + 'trial_ends_at' => $sub['trial_ends_at'], + 'days_remaining' => !empty($sub['current_period_end']) + ? max(0, (int)ceil((strtotime($sub['current_period_end']) - time()) / 86400)) + : null, + ]; + } +} diff --git a/app/modules_app/companies/create.php b/app/modules_app/companies/create.php index f908e26..b3ac126 100644 --- a/app/modules_app/companies/create.php +++ b/app/modules_app/companies/create.php @@ -59,6 +59,10 @@ try { $tenantId = $decoded['tenant_id']; } + // --- QUOTA CHECK --- + \App\Middleware\QuotaMiddleware::checkCompanyQuota($tenantId); + // ------------------- + $stmt->execute([ \App\Core\Database::generateUuid(), $tenantId, diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php index 818c339..d7c6827 100644 --- a/app/modules_app/invoices/upload.php +++ b/app/modules_app/invoices/upload.php @@ -8,6 +8,13 @@ use App\Middleware\AuthMiddleware; // 1. Auth Check $decoded = AuthMiddleware::check(); +$tenantId = $decoded['tenant_id']; +$userId = $decoded['user_id']; + +// --- QUOTA CHECK --- +\App\Middleware\QuotaMiddleware::checkInvoiceQuota($tenantId); +// ------------------- + $db = Database::getInstance(); $allowedRoles = ['admin', 'accountant', 'employee']; @@ -141,6 +148,11 @@ if (move_uploaded_file($_FILES['invoice']['tmp_name'], $targetFile)) { } $db->commit(); + + // --- INCREMENT QUOTA --- + \App\Middleware\QuotaMiddleware::incrementInvoiceUsage($tenantId); + // ----------------------- + json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح'); } catch (\Exception $e) { diff --git a/app/modules_app/subscriptions/assign.php b/app/modules_app/subscriptions/assign.php new file mode 100644 index 0000000..3db1d9b --- /dev/null +++ b/app/modules_app/subscriptions/assign.php @@ -0,0 +1,95 @@ +prepare("SELECT * FROM subscription_plans WHERE id = ? AND is_active = 1"); + $stmt->execute([$planId]); + $plan = $stmt->fetch(); + + if (!$plan) { + json_error('الباقة المختارة غير صالحة أو غير نشطة.', 422); + } + + // 2. Update or Create Subscription + $db->beginTransaction(); + + $startDate = date('Y-m-d H:i:s'); + $endDate = date('Y-m-d H:i:s', strtotime("+{$durationDays} days")); + + $stmt = $db->prepare(" + INSERT INTO subscriptions ( + tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, + price_jod, status, current_period_start, current_period_end, updated_at + ) VALUES ( + :t_id, :p_id, :max_c, :max_i, :max_u, :price, 'active', :start, :end, NOW() + ) + ON DUPLICATE KEY UPDATE + plan_id = VALUES(plan_id), + max_companies = VALUES(max_companies), + max_invoices_per_month = VALUES(max_invoices_per_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + status = 'active', + current_period_start = VALUES(current_period_start), + current_period_end = VALUES(current_period_end), + updated_at = NOW() + "); + + $stmt->execute([ + 't_id' => $targetTenantId, + 'p_id' => $planId, + 'max_c' => $plan['max_companies'], + 'max_i' => $plan['max_invoices_month'], + 'max_u' => $plan['max_users'], + 'price' => $plan['price_jod'], + 'start' => $startDate, + 'end' => $endDate + ]); + + // 3. Log the change + $logStmt = $db->prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, details) VALUES (?, ?, 'plan_assigned', 'tenant', ?, ?)"); + $logStmt->execute([ + $targetTenantId, + $decoded['user_id'], + $targetTenantId, + json_encode(['plan_id' => $planId, 'assigned_by' => $decoded['user_id']]) + ]); + + $db->commit(); + + json_success([ + 'tenant_id' => $targetTenantId, + 'plan_id' => $planId, + 'period_end' => $endDate + ], 'تم تحديث باقة الاشتراك بنجاح'); + +} catch (\Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + error_log("Subscription Assign Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500); +} diff --git a/app/modules_app/subscriptions/current.php b/app/modules_app/subscriptions/current.php new file mode 100644 index 0000000..25eb5a4 --- /dev/null +++ b/app/modules_app/subscriptions/current.php @@ -0,0 +1,25 @@ +getMessage()); + json_error('حدث خطأ أثناء جلب بيانات الاشتراك', 500); +} diff --git a/app/modules_app/subscriptions/plans.php b/app/modules_app/subscriptions/plans.php new file mode 100644 index 0000000..6301bb3 --- /dev/null +++ b/app/modules_app/subscriptions/plans.php @@ -0,0 +1,52 @@ +query(" + SELECT id, name_ar, name_en, max_companies, max_invoices_month, max_users, + price_jod, ai_features, jofotara_enabled, sort_order + FROM subscription_plans + WHERE is_active = 1 + ORDER BY sort_order ASC + "); + $plans = $stmt->fetchAll(); + + // Merge with config features (for richer display) + $configPlans = require APP_PATH . '/config/plans.php'; + + foreach ($plans as &$plan) { + $configPlan = $configPlans[$plan['id']] ?? null; + if ($configPlan) { + $plan['description_ar'] = $configPlan['description_ar'] ?? ''; + $plan['features'] = $configPlan['features'] ?? []; + $plan['badge_color'] = $configPlan['badge_color'] ?? 'gray'; + $plan['is_popular'] = $configPlan['is_popular'] ?? false; + } + // Cast numeric fields + $plan['max_companies'] = (int)$plan['max_companies']; + $plan['max_invoices_month'] = (int)$plan['max_invoices_month']; + $plan['max_users'] = (int)$plan['max_users']; + $plan['price_jod'] = (float)$plan['price_jod']; + $plan['ai_features'] = (bool)$plan['ai_features']; + $plan['jofotara_enabled'] = (bool)$plan['jofotara_enabled']; + } + + json_success($plans, 'الباقات المتاحة'); + +} catch (\Exception $e) { + error_log("Subscription Plans Error: " . $e->getMessage()); + + // Fallback to config file + $configPlans = require APP_PATH . '/config/plans.php'; + $fallback = array_values($configPlans); + json_success($fallback, 'الباقات المتاحة (من الإعدادات)'); +} diff --git a/app/modules_app/subscriptions/usage.php b/app/modules_app/subscriptions/usage.php new file mode 100644 index 0000000..5989448 --- /dev/null +++ b/app/modules_app/subscriptions/usage.php @@ -0,0 +1,58 @@ +prepare(" + SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count + FROM invoices + WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH) + GROUP BY month + ORDER BY month ASC + "); + $stmt->execute([$tenantId]); + $monthlyInvoices = $stmt->fetchAll(); + + // 2. Usage by company + $stmt = $db->prepare(" + SELECT c.name, COUNT(i.id) as count + FROM companies c + LEFT JOIN invoices i ON i.company_id = c.id + WHERE c.tenant_id = ? AND c.deleted_at IS NULL + GROUP BY c.id + ORDER BY count DESC + "); + $stmt->execute([$tenantId]); + $usageByCompany = $stmt->fetchAll(); + + // 3. Status distribution + $stmt = $db->prepare(" + SELECT status, COUNT(*) as count + FROM invoices + WHERE tenant_id = ? + GROUP BY status + "); + $stmt->execute([$tenantId]); + $statusDistribution = $stmt->fetchAll(); + + json_success([ + 'monthly_growth' => $monthlyInvoices, + 'usage_by_company' => $usageByCompany, + 'status_distribution' => $statusDistribution + ], 'إحصائيات الاستهلاك'); + +} catch (\Exception $e) { + error_log("Usage Stats Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء جلب إحصائيات الاستهلاك', 500); +} diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index e22a7ce..e4ff628 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -57,6 +57,10 @@ if ($decoded['role'] === 'super_admin') { $tenantId = $decoded['tenant_id']; } +// --- QUOTA CHECK --- +\App\Middleware\QuotaMiddleware::checkUserQuota($tenantId); +// ------------------- + // 4. Save to Database try { $stmt = $db->prepare("INSERT INTO users (id, tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); diff --git a/public/index.php b/public/index.php index 36081db..fa95ff6 100644 --- a/public/index.php +++ b/public/index.php @@ -38,6 +38,10 @@ $routes = [ 'v1/tenants/create' => ['POST', 'tenants/create.php'], 'v1/tenants/update' => ['POST', 'tenants/update.php'], 'v1/tenants/stats' => ['GET', 'tenants/stats.php'], + 'v1/subscriptions/plans' => ['GET', 'subscriptions/plans.php'], + 'v1/subscriptions/current' => ['GET', 'subscriptions/current.php'], + 'v1/subscriptions/assign' => ['POST', 'subscriptions/assign.php'], + 'v1/subscriptions/usage' => ['GET', 'subscriptions/usage.php'], ]; if (isset($routes[$route])) { diff --git a/public/shell.php b/public/shell.php index c5fdd55..bd65df8 100644 --- a/public/shell.php +++ b/public/shell.php @@ -420,6 +420,18 @@ color: var(--teal); } + .stat-red::after { + background: #e11d48; + } + + .stat-red .stat-icon-box { + background: #fff1f2; + } + + .stat-red .stat-value { + color: #e11d48; + } + /* ── QUICK ACTIONS ─────────────────────────── */ .quick-action-card { background: white; @@ -1069,12 +1081,16 @@ فريق العمل -
+
- +
@@ -1128,7 +1144,7 @@
-
+
📄
إجمالي الفواتير
@@ -1144,6 +1160,11 @@
مدققة ومعتمدة
+
+
📊
+
استهلاك الحصة
+
+
@@ -1464,6 +1485,85 @@
+ +
+
+
+ 💎 +

اشتراكي الحالي

+ +
+
+ +
+
+ الفواتير الشهرية + +
+
+
+
+
+
+ +
+
+ الشركات المدارة + +
+
+
+
+
إجمالي الشركات المسموح بها
+
+ +
+
+ فريق العمل + +
+
+
+
+
مستخدمين نشطين في النظام
+
+
+
+ + +

تغيير أو ترقية الباقة

+
+ +
+
+
@@ -2078,7 +2178,7 @@ Alpine.data('app', () => ({ user: JSON.parse(localStorage.getItem('user')), page: 'dashboard', - users: [], companies: [], invoices: [], tenants: [], + users: [], companies: [], invoices: [], tenants: [], subscription: null, plans: [], stats: { total: 0, pending: 0, approved: 0 }, showAddUserModal: false, showAddCompanyModal: false, showConnectModal: false, @@ -2099,8 +2199,8 @@ this.loadAll(); }, setPage(p) { this.page = p; this.loadAll(); }, - title() { return { dashboard: 'الرئيسية', users: 'فريق العمل', companies: 'الشركات', invoices: 'إدارة الفواتير', tenants: 'المكاتب المحاسبية' }[this.page] || ''; }, - subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة' }[this.page] || ''; }, + title() { return { dashboard: 'الرئيسية', users: 'فريق العمل', companies: 'الشركات', invoices: 'إدارة الفواتير', tenants: 'المكاتب المحاسبية', subscription: 'إدارة الاشتراك' }[this.page] || ''; }, + subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة', subscription: 'تفاصيل باقتك الحالية واستهلاك الموارد' }[this.page] || ''; }, token() { return localStorage.getItem('access_token'); }, showError(msg) { this.globalError = msg; setTimeout(() => this.globalError = '', 8000); }, @@ -2121,8 +2221,10 @@ async loadAll() { this.stats = await this.apiRequest('v1/dashboard/stats') || { total: 0, pending: 0, approved: 0 }; this.companies = await this.apiRequest('v1/companies') || []; + this.subscription = await this.apiRequest('v1/subscriptions/current'); if (this.page === 'users') this.users = await this.apiRequest('v1/users') || []; if (this.page === 'invoices') this.invoices = await this.apiRequest('v1/invoices') || []; + if (this.page === 'subscription') this.plans = await this.apiRequest('v1/subscriptions/plans') || []; if (this.user.role === 'super_admin') this.tenants = await this.apiRequest('v1/tenants') || []; }, // diff --git a/scripts/migrate_phase1.php b/scripts/migrate_phase1.php new file mode 100644 index 0000000..4aa5e45 --- /dev/null +++ b/scripts/migrate_phase1.php @@ -0,0 +1,168 @@ + "ALTER TABLE companies ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL", + + // 2. Add deleted_at to users + 'users_soft_delete' => "ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL", + + // 3. Add email_hash to users (if missing) + 'users_email_hash' => "ALTER TABLE users ADD COLUMN IF NOT EXISTS email_hash VARCHAR(64) NULL", + + // 4. Create subscription_plans table + 'subscription_plans_table' => " + CREATE TABLE IF NOT EXISTS subscription_plans ( + id VARCHAR(20) PRIMARY KEY, + name_ar VARCHAR(100) NOT NULL, + name_en VARCHAR(100) NOT NULL, + max_companies INT NOT NULL DEFAULT 1, + max_invoices_month INT NOT NULL DEFAULT 30, + max_users INT NOT NULL DEFAULT 2, + price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, + ai_features BOOLEAN DEFAULT FALSE, + jofotara_enabled BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + features_json JSON NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + ", + + // 5. Ensure subscriptions table exists with all needed columns + 'subscriptions_table' => " + CREATE TABLE IF NOT EXISTS subscriptions ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL UNIQUE, + plan_id VARCHAR(20) NOT NULL DEFAULT 'free', + max_companies INT NOT NULL DEFAULT 1, + max_invoices_per_month INT NOT NULL DEFAULT 15, + max_users INT NOT NULL DEFAULT 1, + price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, + invoices_used_this_month INT NOT NULL DEFAULT 0, + status ENUM('active','past_due','cancelled','trial') DEFAULT 'trial', + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + trial_ends_at DATETIME NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (plan_id) REFERENCES subscription_plans(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + ", + + // 6. Add plan_id column to subscriptions if upgrading from old schema + 'subscriptions_plan_id' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS plan_id VARCHAR(20) NOT NULL DEFAULT 'free'", + + // 7. Add max_users column to subscriptions if missing + 'subscriptions_max_users' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS max_users INT NOT NULL DEFAULT 1", + + // 8. Add trial_ends_at to subscriptions if missing + 'subscriptions_trial' => "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS trial_ends_at DATETIME NULL", + + // 9. Index on subscriptions status + 'subscriptions_status_idx' => "CREATE INDEX IF NOT EXISTS idx_sub_status ON subscriptions(status)", +]; + +$success = 0; +$skipped = 0; +$failed = 0; + +foreach ($migrations as $name => $sql) { + try { + $db->exec($sql); + echo " ✅ {$name}\n"; + $success++; + } catch (\PDOException $e) { + $msg = $e->getMessage(); + // Ignore "duplicate column" or "already exists" errors + if (str_contains($msg, 'Duplicate column') || str_contains($msg, 'already exists')) { + echo " ⏭️ {$name} (already exists)\n"; + $skipped++; + } else { + echo " ❌ {$name}: {$msg}\n"; + $failed++; + } + } +} + +echo "\n───────────────────────────────────────────\n"; + +// Seed subscription plans +echo "\n📦 Seeding subscription plans...\n"; + +$plans = [ + ['free', 'مجانية', 'Free', 1, 15, 1, 0.00, 0, 0, 10], + ['basic', 'أساسية', 'Basic', 3, 100, 3, 15.00, 1, 0, 20], + ['office', 'مكتبية', 'Office', 10, 500, 10, 45.00, 1, 1, 30], + ['pro', 'احترافية', 'Pro', 25, 2000, 25, 99.00, 1, 1, 40], + ['enterprise', 'مؤسسية', 'Enterprise', 999, 99999, 999, 249.00, 1, 1, 50], +]; + +$planStmt = $db->prepare(" + INSERT INTO subscription_plans (id, name_ar, name_en, max_companies, max_invoices_month, max_users, price_jod, ai_features, jofotara_enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name_ar = VALUES(name_ar), + name_en = VALUES(name_en), + max_companies = VALUES(max_companies), + max_invoices_month = VALUES(max_invoices_month), + max_users = VALUES(max_users), + price_jod = VALUES(price_jod), + ai_features = VALUES(ai_features), + jofotara_enabled = VALUES(jofotara_enabled), + sort_order = VALUES(sort_order) +"); + +foreach ($plans as $plan) { + $planStmt->execute($plan); + echo " ✅ Plan: {$plan[0]} ({$plan[1]})\n"; +} + +// Auto-assign 'free' plan to any tenant without a subscription +echo "\n🔗 Auto-assigning free plan to tenants without subscriptions...\n"; + +$stmt = $db->query(" + SELECT t.id FROM tenants t + LEFT JOIN subscriptions s ON s.tenant_id = t.id + WHERE s.id IS NULL +"); +$orphanTenants = $stmt->fetchAll(); + +if (!empty($orphanTenants)) { + $insertSub = $db->prepare(" + INSERT INTO subscriptions (tenant_id, plan_id, max_companies, max_invoices_per_month, max_users, price_jod, status, current_period_start, current_period_end, trial_ends_at) + VALUES (?, 'free', 1, 15, 1, 0.00, 'trial', NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), DATE_ADD(NOW(), INTERVAL 14 DAY)) + "); + foreach ($orphanTenants as $tenant) { + try { + $insertSub->execute([$tenant['id']]); + echo " ✅ Assigned free plan to tenant: {$tenant['id']}\n"; + } catch (\Exception $e) { + echo " ⚠️ Tenant {$tenant['id']}: " . $e->getMessage() . "\n"; + } + } +} else { + echo " ℹ️ All tenants already have subscriptions.\n"; +} + +echo "\n═══════════════════════════════════════════\n"; +echo " Migration Complete!\n"; +echo " ✅ Success: {$success} | ⏭️ Skipped: {$skipped} | ❌ Failed: {$failed}\n"; +echo "═══════════════════════════════════════════\n"; diff --git a/scripts/seed_super_admin.php b/scripts/seed_super_admin.php index 488991e..bc5d583 100644 --- a/scripts/seed_super_admin.php +++ b/scripts/seed_super_admin.php @@ -31,7 +31,7 @@ try { // 2. Setup Super Admin details $adminEmail = 'admin@musadaq.app'; $adminName = 'Hamza'; - $adminPassword = 'password123'; // Default password + $adminPassword = env('SEED_ADMIN_PASSWORD', 'password123'); // Default for dev only // Check if user already exists $emailHash = hash('sha256', strtolower($adminEmail)); @@ -64,7 +64,7 @@ try { echo "[OK] Super Admin created successfully!\n"; echo "----------------------------------------\n"; echo "Email: $adminEmail\n"; - echo "Password: $adminPassword\n"; + echo "Password: [FROM ENV]\n"; echo "Role: super_admin\n"; echo "----------------------------------------\n"; } diff --git a/stratigy.md b/stratigy.md new file mode 100644 index 0000000..e3d304c --- /dev/null +++ b/stratigy.md @@ -0,0 +1,51 @@ +
+أهلاً بك يا حمزة. لقد قمت بمراجعة الشيفرة المصدرية (Source Code) بالكامل، بدءاً من قاعدة البيانات (`schema.sql`) مروراً بخدمات الذكاء الاصطناعي (`InvoiceExtractionService.php`) وحتى الواجهة الجديدة (`shell.php`). + +العمل الذي تم إنجازه حتى الآن **ممتاز جداً** ويؤسس لقاعدة صلبة (Solid Foundation). لقد نجحت بالفعل في بناء نواة لنظام SaaS حقيقي (تعدد المستأجرين، تشفير البيانات، فصل الصلاحيات، وربط جوفوترة). أوافقك الرأي تماماً؛ نعتبر هذه النقطة هي "نقطة الصفر الذهبية" التي سننطلق منها نحو احتكار السوق. + +إليك تحليلي الشامل لما طلبته، وخطة العمل الاستراتيجية للمرحلة القادمة: + +### 1. تقييم التصميم الجديد (`shell.php`) +التصميم الجديد (Light Professional) بألوان (Navy, Gold, Teal) يعطي انطباعاً رسمياً، موثوقاً، ويشبه بالفعل الأنظمة المالية العالمية المتقدمة (مثل Bloomberg Terminal و Stripe Dashboard). +* **نقاط القوة في التصميم:** التركيز على البيانات، إخفاء التعقيد التقني عن المحاسبين، وتوفير نظام "إجراءات سريعة" يسهل العمل. +* **لمسة التطوير القادمة للواجهة:** سنحتاج لاحقاً لإضافة نظام التنبيهات اللحظية (Real-time Notifications) عبر WebSockets أو SSE، وإضافة شاشة "المراجعة الذكية" (Smart Audit View) التي تبرز الأخطاء في الفاتورة باللون الأحمر قبل اعتمادها. + +--- + +### 2. تحليل السوق والميزات التنافسية (كيف نكتسح السوق؟) +بناءً على التقرير الذي أرفقته، يجب أن يكون شعارنا التسويقي: **"نحن لسنا برنامج فوترة.. نحن مساعدك المالي بالذكاء الاصطناعي"**. +الميزات التي سنضيفها تباعاً لضرب المنافسين (مثل قيود، دفترة): + +1. **AI Pre-Audit (المدقق الذكي):** سنقوم بتطوير `InvoiceExtractionService` ليقوم ليس فقط باستخراج البيانات، بل بمطابقتها مع "شجرة الضرائب الأردنية". إذا استخرج الـ AI "سكر"، يجب أن يتأكد أن الضريبة 0% (أو معفاة)، وإذا وجد خطأ يضع `validation_warnings` للمحاسب قبل الإرسال لـ JoFotara. +2. **نظام الاشتراكات (Subscriptions & Quotas):** بما أننا نستخدم AI مكلف، يجب ضبط الاستهلاك. سنبني نظام باقات (Basic, Pro, Enterprise) يربط كل مكتب محاسبي بحد أقصى من الفواتير والشركات شهرياً، مع إيقاف الرفع التلقائي عند استنفاد الباقة (هذا ما غفلنا عنه وسيكون من أولوياتنا). +3. **Multi-Entity Dashboard (لوحة القيادة الموحدة):** المحاسب يدخل مرة واحدة، ويرى كل الشركات التي يديرها أمام عينه مع تنبيهات مثل (الشركة X لديها 15 فاتورة غير مدققة، الشركة Y استنفدت رصيدها). +4. **منع التكرار (Duplicate Invoice Hash):** خوارزمية ذكية تمنع المحاسب من رفع الفاتورة مرتين لتجنب الغرامات الضريبية. + +--- + +### 3. معمارية تطبيق الهاتف (Flutter) وتأمين الـ API +تطبيق الهاتف لن يكون مجرد "واجهة عرض"، بل سيكون **"ماسح ضوئي ذكي ومعالج طرفي" (Edge Processing Scanner)**. المحاسب سيقوم بتصوير الفواتير بسرعة، والتطبيق سيتولى الباقي. + +**البنية التحتية المقترحة لتطبيق Flutter (`musadaq-app`):** + +* **إدارة الحالة (State Management):** سنستخدم `GetX` أو `Riverpod`. أنا أفضل `GetX` هنا لسرعة الإنجاز وقوته في التعامل مع الـ Routing و Dependency Injection والـ Background Tasks في وقت واحد. +* **المعالجة الطرفية للصور (Edge Vision):** + * سنستخدم مكتبات مثل `edge_detection` لتحديد حواف الفاتورة تلقائياً وقصها (Auto-crop). + * سنقوم بضغط الصورة وتحويلها لـ أسود وأبيض (Binarization) لتقليل حجم الرفع (من 4MB إلى 200KB) ولزيادة دقة الـ AI في السيرفر. +* **نظام الطابور والمزامنة (Offline/Sync Queue):** المحاسب في الميدان قد لا يملك إنترنت قوي. سيصور الفواتير، ويحفظها التطبيق محلياً في قاعدة بيانات `Isar` أو `Hive`. بمجرد توفر الإنترنت، ستبدأ عملية الرفع المتزامن (Background Sync) لعدة فواتير معاً. +* **تأمين الـ API (HMAC Signature):** + لحماية النظام من هجمات الـ Replay والـ DDoS، لن نكتفي بـ JWT. كل طلب يخرج من الموبايل سيتم توقيعه باستخدام HMAC. + * **الطريقة:** الموبايل يولد `Timestamp`، ويقوم بدمجه مع جسم الطلب (Body)، ثم يشفرهم باستخدام `HMAC-SHA256` باستخدام `API_SECRET` خاص بالمستخدم. السيرفر (PHP) يطابق التوقيع ويرفض أي طلب قديم أو معدل. + +--- + +### خطة العمل للمرحلة القادمة (Next Steps): + +أقترح أن نقسم العمل إلى مراحل حتى نحافظ على نظافة الكود ولا نتشتت: + +* **المرحلة 1: بناء نظام الاشتراكات والباقات في الـ Backend.** (تجهيز الـ APIs الخاصة بالـ Subscriptions، وحماية نقطة رفع الفواتير بـ Quota Check). +* **المرحلة 2: تطوير الـ AI Pre-Audit.** (تحديث الـ `InvoiceExtractionService` ليقوم بحساب الـ Hash لمنع التكرار، ووضع تنبيهات الذكاء الاصطناعي للمحاسب). +* **المرحلة 3: تأسيس نواة تطبيق Flutter.** (إنشاء بنية المجلدات، إعداد GetX، نظام الـ Auth، وكتابة كود الـ HMAC Interceptor لطلبات الشبكة). + +**هل توافقني الرأي في البدء بـ "المرحلة الأولى" (الاشتراكات) وتثبيتها في قاعدة البيانات والـ API، أم تفضل أن نضع نواة تطبيق الـ Flutter أولاً لنرى الصورة تكتمل من جهة الموبايل؟** +
\ No newline at end of file