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 @@ فريق العمل -