diff --git a/app/core/AI.php b/app/Core/AI.php similarity index 100% rename from app/core/AI.php rename to app/Core/AI.php diff --git a/app/core/Database.php b/app/Core/Database.php similarity index 100% rename from app/core/Database.php rename to app/Core/Database.php diff --git a/app/core/Encryption.php b/app/Core/Encryption.php similarity index 100% rename from app/core/Encryption.php rename to app/Core/Encryption.php diff --git a/app/core/JWT.php b/app/Core/JWT.php similarity index 100% rename from app/core/JWT.php rename to app/Core/JWT.php diff --git a/app/core/JoFotara.php b/app/Core/JoFotara.php similarity index 100% rename from app/core/JoFotara.php rename to app/Core/JoFotara.php diff --git a/app/core/Security.php b/app/Core/Security.php similarity index 100% rename from app/core/Security.php rename to app/Core/Security.php diff --git a/app/core/Validator.php b/app/Core/Validator.php similarity index 100% rename from app/core/Validator.php rename to app/Core/Validator.php diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..385e551 --- /dev/null +++ b/app/Middleware/AuthMiddleware.php @@ -0,0 +1,49 @@ + false, + 'message' => 'انتهت صلاحية الجلسة', + 'code' => 'TOKEN_EXPIRED', + 'redirect'=> '/login.php' + ]); + exit; + } + + return $decoded; + } +} diff --git a/app/Middleware/HmacMiddleware.php b/app/Middleware/HmacMiddleware.php new file mode 100644 index 0000000..f265c92 --- /dev/null +++ b/app/Middleware/HmacMiddleware.php @@ -0,0 +1,62 @@ + $maxAgeSeconds) { + json_error('Request expired. Check your system clock.', 401); + } + + // 4. Build the expected signature + $body = file_get_contents('php://input'); + $payload = $timestamp . '.' . $body; + $secret = env('HMAC_SECRET_KEY'); + + if (!$secret || strlen($secret) < 32) { + error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env'); + json_error('Server configuration error', 500); + } + + // 5. Verify using constant-time comparison (prevents timing attacks) + if (!Security::verifySignature($payload, $signature, $secret)) { + error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? '')); + json_error('Invalid request signature', 401); + } + } +} 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/Middleware/RateLimitMiddleware.php b/app/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..aee49bf --- /dev/null +++ b/app/Middleware/RateLimitMiddleware.php @@ -0,0 +1,71 @@ + $ts > ($now - $timeWindow)) + ); + } + } + + if (count($requests) >= $maxRequests) { + flock($fp, LOCK_UN); + fclose($fp); + + header('Retry-After: ' . $timeWindow); + json_error('Too Many Requests. Please slow down.', 429); + } + + // Record this request + $requests[] = $now; + + // Write updated data back + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($requests)); + + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } + } +}