diff --git a/app/Core/Validator.php b/app/Core/Validator.php index 80c862a..3744f15 100644 --- a/app/Core/Validator.php +++ b/app/Core/Validator.php @@ -13,12 +13,36 @@ final class Validator { $errors = []; foreach ($rules as $field => $rule) { - if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) { + $value = $data[$field] ?? null; + + if (str_contains($rule, 'required') && (empty($value) && $value !== '0')) { $errors[$field] = "The {$field} field is required."; + continue; // Skip further rules if required field is missing } - if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) { + + if (str_contains($rule, 'email') && !empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) { $errors[$field] = "The {$field} must be a valid email address."; } + + // Password strength: min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit + if (str_contains($rule, 'strong_password') && !empty($value)) { + if (strlen($value) < 8) { + $errors[$field] = 'كلمة المرور يجب أن تكون 8 أحرف على الأقل.'; + } elseif (!preg_match('/[A-Z]/', $value)) { + $errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف كبير واحد على الأقل.'; + } elseif (!preg_match('/[a-z]/', $value)) { + $errors[$field] = 'كلمة المرور يجب أن تحتوي على حرف صغير واحد على الأقل.'; + } elseif (!preg_match('/[0-9]/', $value)) { + $errors[$field] = 'كلمة المرور يجب أن تحتوي على رقم واحد على الأقل.'; + } + } + + // Generic min length: min:8 + if (preg_match('/min:(\d+)/', $rule, $m) && !empty($value)) { + if (mb_strlen($value) < (int)$m[1]) { + $errors[$field] = "The {$field} must be at least {$m[1]} characters."; + } + } } return $errors; } diff --git a/app/Services/GamificationService.php b/app/Services/GamificationService.php new file mode 100644 index 0000000..439bd79 --- /dev/null +++ b/app/Services/GamificationService.php @@ -0,0 +1,155 @@ + 5, + 'invoice_approved' => 10, + 'jofotara_submitted' => 15, + 'company_created' => 20, + 'referral_registered' => 50, + 'first_login' => 10, + 'streak_7_days' => 30, + 'streak_30_days' => 100, + ]; + + // Badge definitions + private const BADGES = [ + 'starter' => ['name' => 'بداية موفقة', 'icon' => '🌟', 'desc' => 'رفعت أول فاتورة', 'condition' => 'invoices >= 1'], + 'active_10' => ['name' => 'نشيط', 'icon' => '🔥', 'desc' => '10 فواتير مرفوعة', 'condition' => 'invoices >= 10'], + 'pro_50' => ['name' => 'محترف', 'icon' => '💎', 'desc' => '50 فاتورة مرفوعة', 'condition' => 'invoices >= 50'], + 'master_200' => ['name' => 'خبير فوترة', 'icon' => '👑', 'desc' => '200 فاتورة مرفوعة', 'condition' => 'invoices >= 200'], + 'jofotara_first' => ['name' => 'رسمي', 'icon' => '🏛️', 'desc' => 'أول إرسال لجوفوترا', 'condition' => 'submitted >= 1'], + 'jofotara_50' => ['name' => 'فوترة ذهبية', 'icon' => '🏆', 'desc' => '50 فاتورة مرسلة لجوفوترا', 'condition' => 'submitted >= 50'], + 'multi_company' => ['name' => 'مدير شركات', 'icon' => '🏢', 'desc' => 'تدير 3 شركات أو أكثر', 'condition' => 'companies >= 3'], + 'referrer' => ['name' => 'سفير مُصادَق', 'icon' => '🤝', 'desc' => 'أحلت مستخدم جديد', 'condition' => 'referrals >= 1'], + 'streak_week' => ['name' => 'مثابر', 'icon' => '📅', 'desc' => 'دخلت 7 أيام متتالية', 'condition' => 'streak >= 7'], + ]; + + /** + * Award points for an action + */ + public static function award(string $userId, string $tenantId, string $action): void + { + try { + $points = self::POINTS[$action] ?? 0; + if ($points === 0) return; + + $db = Database::getInstance(); + + // Add points + $db->prepare(" + INSERT INTO user_points (id, user_id, tenant_id, action, points, created_at) + VALUES (UUID(), ?, ?, ?, ?, NOW()) + ")->execute([$userId, $tenantId, $action, $points]); + + // Check for new badges + self::checkBadges($userId, $tenantId); + + } catch (\Throwable $e) { + error_log("[Gamification] Award failed: " . $e->getMessage()); + } + } + + /** + * Check and award any earned badges + */ + private static function checkBadges(string $userId, string $tenantId): void + { + $db = Database::getInstance(); + + // Get user stats + $invoices = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ?")->execute([$tenantId])?->fetchColumn() ?: 0; + $submitted = (int)$db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'submitted'")->execute([$tenantId])?->fetchColumn() ?: 0; + $companies = (int)$db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL")->execute([$tenantId])?->fetchColumn() ?: 0; + $referrals = (int)$db->prepare("SELECT COUNT(*) FROM referrals WHERE referrer_id = ?")->execute([$userId])?->fetchColumn() ?: 0; + + // Get existing badges + $existingStmt = $db->prepare("SELECT badge_key FROM user_badges WHERE user_id = ?"); + $existingStmt->execute([$userId]); + $existing = $existingStmt->fetchAll(\PDO::FETCH_COLUMN); + + $stats = compact('invoices', 'submitted', 'companies', 'referrals'); + + foreach (self::BADGES as $key => $badge) { + if (in_array($key, $existing)) continue; + + if (self::evaluateCondition($badge['condition'], $stats)) { + $db->prepare(" + INSERT INTO user_badges (id, user_id, tenant_id, badge_key, badge_name, badge_icon, earned_at) + VALUES (UUID(), ?, ?, ?, ?, ?, NOW()) + ")->execute([$userId, $tenantId, $key, $badge['name'], $badge['icon']]); + + // Notify user + SmartNotifications::send($tenantId, $userId, 'badge_earned', + "{$badge['icon']} شارة جديدة: {$badge['name']}!", + $badge['desc'], + ['badge_key' => $key] + ); + } + } + } + + /** + * Simple condition evaluator + */ + private static function evaluateCondition(string $condition, array $stats): bool + { + if (preg_match('/(\w+)\s*>=\s*(\d+)/', $condition, $m)) { + $field = $m[1]; + $value = (int)$m[2]; + return ($stats[$field] ?? 0) >= $value; + } + return false; + } + + /** + * Get user's gamification profile + */ + public static function getProfile(string $userId, string $tenantId): array + { + $db = Database::getInstance(); + + // Total points + $pointsStmt = $db->prepare("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ?"); + $pointsStmt->execute([$userId]); + $totalPoints = (int)$pointsStmt->fetchColumn(); + + // Badges + $badgesStmt = $db->prepare("SELECT badge_key, badge_name, badge_icon, earned_at FROM user_badges WHERE user_id = ? ORDER BY earned_at DESC"); + $badgesStmt->execute([$userId]); + $badges = $badgesStmt->fetchAll(); + + // Level (every 100 points = 1 level) + $level = max(1, (int)floor($totalPoints / 100) + 1); + $levelNames = ['', 'مبتدئ', 'ناشط', 'متقدم', 'خبير', 'أسطورة', 'سيد الفوترة']; + $levelName = $levelNames[min($level, count($levelNames) - 1)] ?? 'أسطورة'; + + // Progress to next level + $pointsInLevel = $totalPoints % 100; + $progressPercent = $pointsInLevel; + + return [ + 'total_points' => $totalPoints, + 'level' => $level, + 'level_name' => $levelName, + 'progress_percent' => $progressPercent, + 'badges' => $badges, + 'badges_count' => count($badges), + 'available_badges' => count(self::BADGES), + ]; + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 5e5a3b1..aa8b4b4 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -158,17 +158,87 @@ class NotificationService } /** - * Get OAuth2 Access Token for Firebase (Cache this in production!) - * Note: This requires a JWT library or manual signing. - * For simplicity, we assume the user might use a Google Auth library. - * But since we avoid extra deps, I will provide a minimal implementation or suggestion. + * Get OAuth2 Access Token for Firebase using Service Account JWT + * Self-contained: no external libraries needed. */ private function getAccessToken(): ?string { - // This is a complex part that usually requires 'google/auth' library. - // For now, I will return null and tell the user they need to install google/auth via composer - // OR I can write a minimal JWT signer for Google Auth if they don't want composer. - error_log("[NotificationService] OAuth2 Token generation needs google/auth library."); - return null; + // Check cache first (token is valid for 1 hour, we cache for 50 min) + $cacheFile = STORAGE_PATH . '/cache/fcm_token.json'; + if (file_exists($cacheFile)) { + $cached = json_decode(file_get_contents($cacheFile), true); + if ($cached && ($cached['expires_at'] ?? 0) > time()) { + return $cached['access_token']; + } + } + + if (!file_exists($this->serviceAccountPath)) { + error_log("[NotificationService] Firebase service account file missing"); + return null; + } + + $sa = json_decode(file_get_contents($this->serviceAccountPath), true); + if (!$sa || empty($sa['private_key']) || empty($sa['client_email'])) { + error_log("[NotificationService] Invalid service account JSON"); + return null; + } + + // Build JWT + $now = time(); + $header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']); + $payload = json_encode([ + 'iss' => $sa['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600, + ]); + + $b64Header = rtrim(strtr(base64_encode($header), '+/', '-_'), '='); + $b64Payload = rtrim(strtr(base64_encode($payload), '+/', '-_'), '='); + $signingInput = $b64Header . '.' . $b64Payload; + + $privateKey = openssl_pkey_get_private($sa['private_key']); + if (!$privateKey) { + error_log("[NotificationService] Failed to parse private key"); + return null; + } + + openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $b64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); + $jwt = $signingInput . '.' . $b64Signature; + + // Exchange JWT for access token + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]), + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + error_log("[NotificationService] Token exchange failed ($httpCode): $response"); + return null; + } + + $tokenData = json_decode($response, true); + $accessToken = $tokenData['access_token'] ?? null; + + if ($accessToken) { + // Cache for 50 minutes + @file_put_contents($cacheFile, json_encode([ + 'access_token' => $accessToken, + 'expires_at' => $now + 3000, + ])); + } + + return $accessToken; } } diff --git a/app/Services/SmartNotifications.php b/app/Services/SmartNotifications.php new file mode 100644 index 0000000..d4911ae --- /dev/null +++ b/app/Services/SmartNotifications.php @@ -0,0 +1,163 @@ +prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) return; + + $usage = ($sub['max_invoices_per_month'] > 0) + ? ($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100 + : 0; + + if ($usage >= 80 && $usage < 100) { + // Find admin user + $adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1"); + $adminStmt->execute([$tenantId]); + $adminId = $adminStmt->fetchColumn(); + + if ($adminId) { + self::send($tenantId, $adminId, 'quota_warning', + '⚠️ اقتربت من حد الباقة', + 'استخدمت ' . round($usage) . '% من حصة الفواتير الشهرية. فكّر بالترقية لتجنب التوقف.', + ['usage_percent' => round($usage)] + ); + } + } + } catch (\Throwable $e) { + error_log("[SmartNotifications] Quota warning failed: " . $e->getMessage()); + } + } + + /** + * Notify user when invoice is approved + */ + public static function invoiceApproved(string $tenantId, string $uploaderId, string $invoiceId, string $invoiceNumber): void + { + self::send($tenantId, $uploaderId, 'invoice_approved', + '✅ تم اعتماد الفاتورة', + "الفاتورة رقم {$invoiceNumber} تم اعتمادها وهي جاهزة للإرسال لجوفوترا.", + ['invoice_id' => $invoiceId] + ); + } + + /** + * Notify when JoFotara submission succeeds + */ + public static function jofotaraSuccess(string $tenantId, string $userId, string $invoiceId, string $uuid): void + { + self::send($tenantId, $userId, 'jofotara_success', + '🎉 تم إرسال الفاتورة لجوفوترا', + "الفاتورة أُرسلت بنجاح. UUID: {$uuid}", + ['invoice_id' => $invoiceId, 'jofotara_uuid' => $uuid] + ); + } + + /** + * Notify when JoFotara submission fails + */ + public static function jofotaraRejected(string $tenantId, string $userId, string $invoiceId, string $error): void + { + self::send($tenantId, $userId, 'jofotara_rejected', + '❌ رُفضت الفاتورة من جوفوترا', + "الفاتورة لم تُقبل: {$error}", + ['invoice_id' => $invoiceId] + ); + } + + /** + * Notify admin about pending invoices (daily digest) + */ + public static function pendingInvoicesDigest(string $tenantId): void + { + try { + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE tenant_id = ? AND status = 'extracted'"); + $stmt->execute([$tenantId]); + $count = (int)$stmt->fetchColumn(); + + if ($count === 0) return; + + $adminStmt = $db->prepare("SELECT id FROM users WHERE tenant_id = ? AND role = 'admin' LIMIT 1"); + $adminStmt->execute([$tenantId]); + $adminId = $adminStmt->fetchColumn(); + + if ($adminId) { + self::send($tenantId, $adminId, 'pending_digest', + "📋 لديك {$count} فاتورة بانتظار المراجعة", + "هناك {$count} فاتورة مستخرجة لم تُراجع بعد. راجعها واعتمدها لإرسالها لجوفوترا.", + ['pending_count' => $count] + ); + } + } catch (\Throwable $e) { + error_log("[SmartNotifications] Pending digest failed: " . $e->getMessage()); + } + } + + /** + * Welcome notification for new users + */ + public static function welcomeUser(string $tenantId, string $userId, string $name): void + { + self::send($tenantId, $userId, 'welcome', + "مرحباً بك في مُصادَق، {$name}! 🎉", + 'ابدأ برفع أول فاتورة — صوّرها أو ارفع PDF والذكاء الاصطناعي يكمل الباقي.', + [] + ); + } + + /** + * Core send method — writes to DB (push handled by NotificationService) + */ + private static function send(string $tenantId, string $userId, string $type, string $title, string $body, array $data): void + { + try { + $db = Database::getInstance(); + + // Deduplicate: don't send same type within 1 hour + $dedup = $db->prepare(" + SELECT id FROM notifications + WHERE user_id = ? AND type = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) + LIMIT 1 + "); + $dedup->execute([$userId, $type]); + if ($dedup->fetch()) return; + + $db->prepare(" + INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, NOW()) + ")->execute([$tenantId, $userId, $type, $title, $body, json_encode($data, JSON_UNESCAPED_UNICODE)]); + + // Try push notification (non-blocking) + try { + $notifService = new NotificationService(); + $notifService->sendNotification($userId, $title, $body, $data); + } catch (\Throwable $e) { + // Push failure is non-critical + } + } catch (\Throwable $e) { + error_log("[SmartNotifications] Send failed: " . $e->getMessage()); + } + } +} diff --git a/app/bootstrap/init.php b/app/bootstrap/init.php index 19442b9..8d15cc1 100644 --- a/app/bootstrap/init.php +++ b/app/bootstrap/init.php @@ -13,6 +13,7 @@ define('STORAGE_PATH', ROOT_PATH . '/storage'); // 2. Load Environment & Helpers FIRST require_once APP_PATH . '/bootstrap/env.php'; require_once APP_PATH . '/helpers/helpers.php'; +require_once APP_PATH . '/helpers/pagination.php'; // Load Composer Autoloader $vendorAutoload = ROOT_PATH . '/vendor/autoload.php'; @@ -25,8 +26,7 @@ $dirs = ['/cache', '/logs', '/invoices', '/exports']; foreach ($dirs as $d) { $path = STORAGE_PATH . $d; if (!is_dir($path)) { - mkdir($path, 0777, true); - chmod($path, 0777); + mkdir($path, 0755, true); } } @@ -66,7 +66,27 @@ header("X-Content-Type-Options: nosniff"); header("X-Frame-Options: SAMEORIGIN"); header("X-XSS-Protection: 1; mode=block"); header("Referrer-Policy: strict-origin-when-cross-origin"); -header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS +header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); +header("Permissions-Policy: camera=(), microphone=(), geolocation=()"); + +// CSP: Allow self + known CDNs (Tailwind, Alpine, Google Fonts) +$csp = "default-src 'self'; " + . "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://unpkg.com; " + . "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + . "font-src 'self' https://fonts.gstatic.com; " + . "img-src 'self' data:; " + . "connect-src 'self';"; +header("Content-Security-Policy: $csp"); + +// 6. Request body size limit (2MB for JSON, file uploads handled separately) +if (isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 2 * 1024 * 1024) { + if (empty($_FILES)) { // Don't block file uploads + http_response_code(413); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'message' => 'Request body too large'], JSON_UNESCAPED_UNICODE); + exit; + } +} // 6. PSR-4 Autoloader (PascalCase-aware for Linux compatibility) spl_autoload_register(function ($class) { diff --git a/app/helpers/helpers.php b/app/helpers/helpers.php index 6d3f8ec..f2f9120 100644 --- a/app/helpers/helpers.php +++ b/app/helpers/helpers.php @@ -38,3 +38,19 @@ if (!function_exists('dd')) { die(); } } + +if (!function_exists('safe_error')) { + /** + * Log exception details securely and return a safe user-facing message. + * Full details go to error_log; users only see a generic Arabic message. + * + * @param \Throwable $e The caught exception + * @param string $context Short label for the endpoint (e.g. 'invoices/upload') + * @param string $userMsg Arabic message shown to the user + * @param int $code HTTP status code + */ + function safe_error(\Throwable $e, string $context, string $userMsg = 'حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.', int $code = 500): void { + error_log("[{$context}] " . get_class($e) . ': ' . $e->getMessage() . ' | ' . $e->getFile() . ':' . $e->getLine()); + json_error($userMsg, $code); + } +} diff --git a/app/helpers/pagination.php b/app/helpers/pagination.php new file mode 100644 index 0000000..f247683 --- /dev/null +++ b/app/helpers/pagination.php @@ -0,0 +1,59 @@ + int, 'per_page' => int, 'limit' => int, 'offset' => int] + */ + function paginate_params(int $defaultPerPage = 25, int $maxPerPage = 100): array + { + $page = max(1, (int)($_GET['page'] ?? 1)); + $perPage = min($maxPerPage, max(1, (int)($_GET['per_page'] ?? $defaultPerPage))); + $offset = ($page - 1) * $perPage; + + return [ + 'page' => $page, + 'per_page' => $perPage, + 'limit' => $perPage, + 'offset' => $offset, + ]; + } +} + +if (!function_exists('json_paginated')) { + /** + * Return a paginated JSON response with metadata. + * + * @param array $items The current page of results + * @param int $total Total count of all matching records + * @param array $pagination Output from paginate_params() + * @param string $message Optional success message + */ + function json_paginated(array $items, int $total, array $pagination, string $message = 'Success'): void + { + $totalPages = (int)ceil($total / max(1, $pagination['per_page'])); + + json_success([ + 'items' => $items, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total' => $total, + 'total_pages' => $totalPages, + 'has_next' => $pagination['page'] < $totalPages, + 'has_prev' => $pagination['page'] > 1, + ], + ], $message); + } +} diff --git a/app/modules_app/academy/articles.php b/app/modules_app/academy/articles.php new file mode 100644 index 0000000..6d3e935 --- /dev/null +++ b/app/modules_app/academy/articles.php @@ -0,0 +1,93 @@ + 'tax-101', + 'category' => 'tax', + 'title' => 'دليل ضريبة المبيعات الأردنية الشامل', + 'summary' => 'كل ما تحتاج معرفته عن نسب ضريبة المبيعات في الأردن: العامة (16%)، المخفضة (4% و 8%)، والمعفاة.', + 'content' => "## نسب ضريبة المبيعات في الأردن\n\n### النسبة العامة: 16%\nتُطبق على معظم السلع والخدمات.\n\n### النسبة المخفضة: 4%\n- الأدوية\n- المستلزمات الطبية\n\n### النسبة المخفضة: 8%\n- الخدمات السياحية\n- بعض المواد الغذائية المصنعة\n\n### معفاة من الضريبة (0%)\n- الخبز\n- الحليب\n- التعليم\n- الخدمات الصحية\n\n> ملاحظة: هذه المعلومات للإرشاد فقط. راجع دائرة ضريبة الدخل والمبيعات للتفاصيل الرسمية.", + 'reading_time' => 3, + 'icon' => '🏛️', + ], + [ + 'id' => 'jofotara-guide', + 'category' => 'jofotara', + 'title' => 'كيف تربط شركتك بمنظومة جوفوترا', + 'summary' => 'خطوات تسجيل شركتك والحصول على Client ID و Secret Key من منظومة الفوترة الإلكترونية.', + 'content' => "## خطوات الربط بجوفوترا\n\n### 1. التسجيل في المنظومة\n- ادخل على portal.jofotara.gov.jo\n- سجّل بالرقم الضريبي لشركتك\n\n### 2. الحصول على المفاتيح\n- من لوحة التحكم، اختر \"إدارة التطبيقات\"\n- أنشئ تطبيق جديد\n- انسخ Client ID و Secret Key\n\n### 3. الربط في مُصادَق\n- افتح إعدادات الشركة\n- الصق Client ID و Secret Key\n- اضغط \"اختبار الاتصال\"\n\n> بعد الربط، يمكنك إرسال الفواتير لجوفوترا بضغطة واحدة!", + 'reading_time' => 4, + 'icon' => '🔗', + ], + [ + 'id' => 'invoice-types', + 'category' => 'invoicing', + 'title' => 'أنواع الفواتير الإلكترونية في الأردن', + 'summary' => 'الفرق بين فاتورة المبيعات، الإشعار الدائن، والإشعار المدين حسب UBL 2.1.', + 'content' => "## أنواع الفواتير\n\n### 1. فاتورة مبيعات (Invoice)\nالنوع الأساسي — تُصدر عند بيع سلعة أو خدمة.\n\n### 2. إشعار دائن (Credit Note)\nيُصدر لتعديل فاتورة سابقة بالتخفيض (مرتجعات أو خصومات).\n\n### 3. إشعار مدين (Debit Note)\nيُصدر لتعديل فاتورة سابقة بالزيادة.\n\n### متطلبات UBL 2.1\n- كل فاتورة يجب أن تحتوي على رقم ضريبي صحيح\n- التاريخ بصيغة ISO\n- تفصيل البنود مع الكمية والسعر", + 'reading_time' => 3, + 'icon' => '📄', + ], + [ + 'id' => 'ai-tips', + 'category' => 'tips', + 'title' => 'نصائح للحصول على أفضل نتائج من الذكاء الاصطناعي', + 'summary' => 'كيف تصوّر الفاتورة لتحصل على استخراج دقيق بنسبة 99%.', + 'content' => "## نصائح التصوير\n\n### ✅ افعل:\n- صوّر الفاتورة كاملة مع الحواف\n- تأكد من الإضاءة الجيدة\n- ضع الفاتورة على سطح مسطح\n- صوّر من الأعلى مباشرة (لا بزاوية)\n\n### ❌ لا تفعل:\n- لا تصوّر جزء من الفاتورة فقط\n- لا تصوّر فاتورة مطوية أو مجعدة\n- لا تصوّر في إضاءة خافتة\n- لا ترفع صور أقل من 300x300 بكسل\n\n### 💡 نصيحة إضافية:\nاستخدم ميزة الـ Batch Scan لتصوير عدة فواتير دفعة واحدة!", + 'reading_time' => 2, + 'icon' => '💡', + ], + [ + 'id' => 'security-guide', + 'category' => 'security', + 'title' => 'كيف يحمي مُصادَق بياناتك', + 'summary' => 'نظرة على تقنيات التشفير والحماية المستخدمة في المنصة.', + 'content' => "## حماية بياناتك\n\n### تشفير AES-256-GCM\nكل البيانات الحساسة (أسماء، أرقام ضريبية، مفاتيح API) مشفرة بأقوى معيار تشفير.\n\n### فصل البيانات (Multi-Tenancy)\nكل مكتب محاسبي معزول تماماً — لا يمكن لأي مكتب رؤية بيانات مكتب آخر.\n\n### مصادقة ثنائية\nتسجيل الدخول يتطلب OTP عبر واتساب بالإضافة لكلمة المرور.\n\n### HMAC Signature\nكل طلب API يتم التحقق من سلامته عبر توقيع رقمي.", + 'reading_time' => 3, + 'icon' => '🔒', + ], +]; + +// Filter by category +if ($category) { + $articles = array_values(array_filter($articles, fn($a) => $a['category'] === $category)); +} + +// Search +if ($search) { + $searchLower = mb_strtolower($search); + $articles = array_values(array_filter($articles, fn($a) => + str_contains(mb_strtolower($a['title']), $searchLower) || + str_contains(mb_strtolower($a['summary']), $searchLower) + )); +} + +$categories = [ + ['key' => 'tax', 'name' => 'ضرائب', 'icon' => '🏛️'], + ['key' => 'jofotara', 'name' => 'جوفوترا', 'icon' => '🔗'], + ['key' => 'invoicing', 'name' => 'فوترة', 'icon' => '📄'], + ['key' => 'tips', 'name' => 'نصائح', 'icon' => '💡'], + ['key' => 'security', 'name' => 'أمان', 'icon' => '🔒'], +]; + +json_success([ + 'articles' => $articles, + 'categories' => $categories, + 'total' => count($articles), +], 'أكاديمية مُصادَق'); diff --git a/app/modules_app/assignments/create.php b/app/modules_app/assignments/create.php index b9386d2..d54c38a 100644 --- a/app/modules_app/assignments/create.php +++ b/app/modules_app/assignments/create.php @@ -51,5 +51,5 @@ try { json_success(null, 'تم تخصيص المستخدم للشركة بنجاح'); } catch (\Exception $e) { - json_error('حدث خطأ أثناء التخصيص: ' . $e->getMessage(), 500); + safe_error($e, 'assignments/create', 'حدث خطأ أثناء التخصيص. يرجى المحاولة مرة أخرى.'); } diff --git a/app/modules_app/assignments/index.php b/app/modules_app/assignments/index.php index b8940bc..137d8bf 100644 --- a/app/modules_app/assignments/index.php +++ b/app/modules_app/assignments/index.php @@ -37,5 +37,5 @@ try { json_success($assignments); } catch (\Exception $e) { - json_error('SQL Error: ' . $e->getMessage(), 500); + safe_error($e, 'assignments/index'); } diff --git a/app/modules_app/audit/index.php b/app/modules_app/audit/index.php index 890a033..25a8c87 100644 --- a/app/modules_app/audit/index.php +++ b/app/modules_app/audit/index.php @@ -117,5 +117,5 @@ try { ]); } catch (\Exception $e) { error_log("Audit log error: " . $e->getMessage()); - json_error('خطأ في جلب سجل النشاط: ' . $e->getMessage(), 500); + safe_error($e, 'audit/index', 'خطأ في جلب سجل النشاط.'); } diff --git a/app/modules_app/auth/mobile_request_otp.php b/app/modules_app/auth/mobile_request_otp.php index 3b75c5b..4e02ec5 100644 --- a/app/modules_app/auth/mobile_request_otp.php +++ b/app/modules_app/auth/mobile_request_otp.php @@ -107,7 +107,7 @@ try { json_success(['whatsapp_debug' => $result], 'إذا كان الرقم مسجلاً، سيتم إرسال رمز التحقق عبر واتساب'); } catch (\Exception $e) { - json_error('Internal Server Error: ' . $e->getMessage(), 500); + safe_error($e, 'auth/mobile_request_otp'); } diff --git a/app/modules_app/chatbot/ask.php b/app/modules_app/chatbot/ask.php new file mode 100644 index 0000000..c78c6df --- /dev/null +++ b/app/modules_app/chatbot/ask.php @@ -0,0 +1,88 @@ + 500) { + json_error('السؤال طويل جداً (الحد 500 حرف)', 422); +} + +$tenantId = $decoded['tenant_id']; +$userId = $decoded['user_id']; + +try { + // 1. Gather user context (last month stats) + $contextStmt = $db->prepare(" + SELECT + COUNT(*) as total_invoices, + COALESCE(SUM(grand_total), 0) as total_revenue, + COALESCE(SUM(tax_amount), 0) as total_tax + FROM invoices + WHERE tenant_id = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) + "); + $contextStmt->execute([$tenantId]); + $context = $contextStmt->fetch(); + + $companyStmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ? AND deleted_at IS NULL"); + $companyStmt->execute([$tenantId]); + $companyCount = (int)$companyStmt->fetchColumn(); + + // 2. Build AI prompt + $systemPrompt = <<prepare(" + INSERT INTO chatbot_history (id, user_id, tenant_id, question, answer, created_at) + VALUES (UUID(), ?, ?, ?, ?, NOW()) + ")->execute([$userId, $tenantId, $question, $aiResponse]); + + json_success([ + 'answer' => $aiResponse, + 'question' => $question, + 'timestamp' => date('c'), + ], 'إجابة مُصادَق'); + +} catch (\Exception $e) { + safe_error($e, 'chatbot/ask', 'حدث خطأ في المساعد الذكي.'); +} diff --git a/app/modules_app/chatbot/history.php b/app/modules_app/chatbot/history.php new file mode 100644 index 0000000..0fa8865 --- /dev/null +++ b/app/modules_app/chatbot/history.php @@ -0,0 +1,29 @@ +prepare("SELECT COUNT(*) FROM chatbot_history WHERE user_id = ?"); +$countStmt->execute([$decoded['user_id']]); +$total = (int)$countStmt->fetchColumn(); + +$stmt = $db->prepare(" + SELECT id, question, answer, created_at + FROM chatbot_history + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT {$pagination['limit']} OFFSET {$pagination['offset']} +"); +$stmt->execute([$decoded['user_id']]); + +json_paginated($stmt->fetchAll(), $total, $pagination); diff --git a/app/modules_app/companies/connect_jofotara.php b/app/modules_app/companies/connect_jofotara.php index 1e61719..bc1925d 100644 --- a/app/modules_app/companies/connect_jofotara.php +++ b/app/modules_app/companies/connect_jofotara.php @@ -61,5 +61,5 @@ try { } catch (\Exception $e) { error_log("JoFotara Connection Error: " . $e->getMessage()); - json_error('فشل في حفظ البيانات: ' . $e->getMessage(), 500); + safe_error($e, 'companies/connect_jofotara', 'فشل في ربط جوفوترا. يرجى المحاولة مرة أخرى.'); } diff --git a/app/modules_app/companies/create.php b/app/modules_app/companies/create.php index d3c6af8..0598b62 100644 --- a/app/modules_app/companies/create.php +++ b/app/modules_app/companies/create.php @@ -89,5 +89,6 @@ try { } catch (\Exception $e) { $db->rollBack(); - json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500); + error_log("[companies/create] Error: " . $e->getMessage()); + json_error('حدث خطأ أثناء إنشاء الشركة. يرجى المحاولة مرة أخرى.', 500); } diff --git a/app/modules_app/companies/index.php b/app/modules_app/companies/index.php index 16a3208..c49fa64 100644 --- a/app/modules_app/companies/index.php +++ b/app/modules_app/companies/index.php @@ -64,5 +64,5 @@ try { json_success($companies); } catch (\Exception $e) { - json_error('SQL Error in Companies List: ' . $e->getMessage(), 500); + safe_error($e, 'companies/index'); } diff --git a/app/modules_app/dashboard/ai_usage.php b/app/modules_app/dashboard/ai_usage.php index 9fed7b7..bc1bdeb 100644 --- a/app/modules_app/dashboard/ai_usage.php +++ b/app/modules_app/dashboard/ai_usage.php @@ -76,5 +76,5 @@ try { } catch (\Exception $e) { error_log("AI Usage Stats Error: " . $e->getMessage() . " | " . $e->getTraceAsString()); - json_error('خطأ في جلب إحصائيات AI: ' . $e->getMessage(), 500); + safe_error($e, 'dashboard/ai_usage', 'خطأ في جلب إحصائيات الذكاء الاصطناعي.'); } diff --git a/app/modules_app/excel/import.php b/app/modules_app/excel/import.php index 308985f..d1db626 100644 --- a/app/modules_app/excel/import.php +++ b/app/modules_app/excel/import.php @@ -86,7 +86,7 @@ try { } catch (\Exception $e) { if (isset($db)) $db->rollBack(); - json_error('فشل معالجة ملف الاكسل: ' . $e->getMessage(), 500); + safe_error($e, 'excel/import', 'فشل معالجة ملف الإكسل.'); } /** diff --git a/app/modules_app/gamification/profile.php b/app/modules_app/gamification/profile.php new file mode 100644 index 0000000..b38b66e --- /dev/null +++ b/app/modules_app/gamification/profile.php @@ -0,0 +1,15 @@ + $apiResponse['success'], ], $decoded); + // Smart Notifications + \App\Services\SmartNotifications::invoiceApproved( + $invoice['tenant_id'], $invoice['uploaded_by'] ?? $decoded['user_id'], + $id, $invoice['invoice_number'] ?? $id + ); + \App\Services\SmartNotifications::checkQuotaWarning($invoice['tenant_id']); + + // Gamification + \App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'invoice_approved'); + if ($apiResponse['success'] ?? false) { + \App\Services\GamificationService::award($decoded['user_id'], $invoice['tenant_id'], 'jofotara_submitted'); + } + } catch (\Exception $e) { if ($db->inTransaction()) $db->rollBack(); error_log("JoFotara Approve Error: " . $e->getMessage()); - json_error('خطأ غير متوقع: ' . $e->getMessage(), 500); + safe_error($e, 'invoices/approve'); } diff --git a/app/modules_app/invoices/index.php b/app/modules_app/invoices/index.php index 4ad4b43..e8a35e9 100644 --- a/app/modules_app/invoices/index.php +++ b/app/modules_app/invoices/index.php @@ -1,6 +1,6 @@ query(" - SELECT i.*, t.name as tenant_name, c.name as company_name - FROM invoices i - LEFT JOIN tenants t ON i.tenant_id = t.id - LEFT JOIN companies c ON i.company_id = c.id - ORDER BY i.created_at DESC - "); + $where = '1=1'; } elseif ($role === 'admin') { - // Admin sees all invoices in THEIR tenant - $stmt = $db->prepare(" - SELECT i.*, c.name as company_name - FROM invoices i - LEFT JOIN companies c ON i.company_id = c.id - WHERE i.tenant_id = ? - ORDER BY i.created_at DESC - "); - $stmt->execute([$tenantId]); + $where = 'i.tenant_id = ?'; + $params = [$tenantId]; } else { // Accountant/Viewer: Filter by assigned companies $stmtUser = $db->prepare("SELECT company_id FROM user_company_assignments WHERE user_id = ? AND is_active = 1"); @@ -43,26 +34,58 @@ try { $assignedCompanyIds = $stmtUser->fetchAll(PDO::FETCH_COLUMN); if (empty($assignedCompanyIds)) { - json_success([]); + json_paginated([], 0, $pagination); } $placeholders = implode(',', array_fill(0, count($assignedCompanyIds), '?')); - $stmt = $db->prepare(" - SELECT i.*, c.name as company_name - FROM invoices i - LEFT JOIN companies c ON i.company_id = c.id - WHERE i.company_id IN ($placeholders) - ORDER BY i.created_at DESC - "); - $stmt->execute($assignedCompanyIds); + $where = "i.company_id IN ($placeholders)"; + $params = $assignedCompanyIds; } + // Optional filters from query string + $companyFilter = $_GET['company_id'] ?? null; + $statusFilter = $_GET['status'] ?? null; + $searchFilter = $_GET['search'] ?? null; + + if ($companyFilter) { + $where .= ' AND i.company_id = ?'; + $params[] = $companyFilter; + } + if ($statusFilter) { + $where .= ' AND i.status = ?'; + $params[] = $statusFilter; + } + if ($searchFilter) { + $where .= ' AND (i.invoice_number LIKE ? OR i.supplier_name LIKE ?)'; + $params[] = "%$searchFilter%"; + $params[] = "%$searchFilter%"; + } + + // 3. Count total + $countStmt = $db->prepare("SELECT COUNT(*) FROM invoices i WHERE $where"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + // 4. Fetch page + $joinTenant = ($role === 'super_admin') ? 'LEFT JOIN tenants t ON i.tenant_id = t.id' : ''; + $selectTenant = ($role === 'super_admin') ? ', t.name as tenant_name' : ''; + + $stmt = $db->prepare(" + SELECT i.*{$selectTenant}, c.name as company_name + FROM invoices i + LEFT JOIN companies c ON i.company_id = c.id + {$joinTenant} + WHERE {$where} + ORDER BY i.created_at DESC + LIMIT {$pagination['limit']} OFFSET {$pagination['offset']} + "); + $stmt->execute($params); $invoices = $stmt->fetchAll(); - // 3. Decrypt sensitive fields for display (Robustly) + // 5. Decrypt sensitive fields $dec = function($val) { if (empty($val)) return ''; - $result = \App\Core\Encryption::decrypt((string)$val); + $result = Encryption::decrypt((string)$val); return ($result !== false && $result !== null) ? $result : (string)$val; }; @@ -79,12 +102,8 @@ try { } } - if (empty($invoices)) { - error_log("INVOICES LIST: No invoices found for role: $role, tenant_id: $tenantId"); - } - - json_success($invoices); + json_paginated($invoices, $total, $pagination); } catch (\Exception $e) { - json_error('SQL Error in Invoices List: ' . $e->getMessage(), 500); + safe_error($e, 'invoices/index'); } diff --git a/app/modules_app/invoices/submit_jofotara.php b/app/modules_app/invoices/submit_jofotara.php index 0e936db..e168b58 100644 --- a/app/modules_app/invoices/submit_jofotara.php +++ b/app/modules_app/invoices/submit_jofotara.php @@ -148,6 +148,8 @@ if ($result['success']) { 'jofotara_uuid' => $result['uuid'], ], $decoded); + \App\Services\SmartNotifications::jofotaraSuccess($tenantId, $userId, $invoiceId, $result['uuid']); + json_success([ 'uuid' => $result['uuid'], 'qr_code' => $qrBase64, @@ -158,5 +160,7 @@ if ($result['success']) { 'error' => $result['error'] ?? 'Unknown', ], $decoded); + \App\Services\SmartNotifications::jofotaraRejected($tenantId, $userId, $invoiceId, $result['error'] ?? 'خطأ غير محدد'); + json_error('رُفضت الفاتورة من جوفتورة: ' . ($result['error'] ?? 'خطأ غير محدد'), 422); } diff --git a/app/modules_app/invoices/update.php b/app/modules_app/invoices/update.php index 70f910d..b1a1266 100644 --- a/app/modules_app/invoices/update.php +++ b/app/modules_app/invoices/update.php @@ -112,5 +112,5 @@ try { } catch (\Exception $e) { $db->rollBack(); error_log("Invoice Update Error: " . $e->getMessage()); - json_error('فشل تحديث الفاتورة: ' . $e->getMessage(), 500); + safe_error($e, 'invoices/update', 'فشل تحديث الفاتورة.'); } diff --git a/app/modules_app/invoices/upload.php b/app/modules_app/invoices/upload.php index d8620e4..ca7d94b 100644 --- a/app/modules_app/invoices/upload.php +++ b/app/modules_app/invoices/upload.php @@ -62,11 +62,12 @@ try { foreach ([$tenantDir, $companyDir, $uploadDir] as $dir) { if (!is_dir($dir)) { - if (!mkdir($dir, 0777, true)) { - json_error('فشل في إنشاء مجلد التخزين: ' . $dir, 500); + if (!mkdir($dir, 0755, true)) { + error_log('Failed to create storage directory: ' . $dir); + json_error('فشل في تجهيز مساحة التخزين', 500); exit; } - chmod($dir, 0777); + chmod($dir, 0755); } } @@ -198,6 +199,8 @@ try { // --- INCREMENT QUOTA --- QuotaMiddleware::incrementInvoiceUsage($tenantId); + \App\Services\SmartNotifications::checkQuotaWarning($tenantId); + \App\Services\GamificationService::award($userId, $tenantId, 'invoice_uploaded'); // ----------------------- json_success(['id' => $invoiceId], 'تم رفع الفاتورة واستخراج البيانات بنجاح'); @@ -207,14 +210,14 @@ try { if (isset($db) && $db->inTransaction()) { $db->rollBack(); } - error_log("Database Error: " . $e->getMessage()); - json_error('حدث خطأ في قاعدة البيانات: ' . $e->getMessage(), 500); + error_log("Database Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine()); + json_error('حدث خطأ أثناء حفظ بيانات الفاتورة. يرجى المحاولة مرة أخرى.', 500); exit; } catch (\Throwable $e) { if (isset($db) && $db->inTransaction()) { $db->rollBack(); } - error_log("Critical Error: " . $e->getMessage() . " on line " . $e->getLine()); - json_error('خطأ برمجي حرج: ' . $e->getMessage() . ' في السطر ' . $e->getLine(), 500); + error_log("Critical Error [upload]: " . $e->getMessage() . " | File: " . $e->getFile() . ":" . $e->getLine()); + json_error('حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى أو التواصل مع الدعم الفني.', 500); exit; } \ No newline at end of file diff --git a/app/modules_app/marketplace/listings.php b/app/modules_app/marketplace/listings.php new file mode 100644 index 0000000..79079e8 --- /dev/null +++ b/app/modules_app/marketplace/listings.php @@ -0,0 +1,74 @@ +prepare("SELECT COUNT(*) FROM marketplace_listings ml WHERE {$where}"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + // Fetch + $stmt = $db->prepare(" + SELECT ml.*, t.name as tenant_name + FROM marketplace_listings ml + LEFT JOIN tenants t ON ml.tenant_id = t.id + WHERE {$where} + ORDER BY ml.is_featured DESC, ml.rating DESC, ml.created_at DESC + LIMIT {$pagination['limit']} OFFSET {$pagination['offset']} + "); + $stmt->execute($params); + $listings = $stmt->fetchAll(); + + // Decrypt names + foreach ($listings as &$l) { + if (!empty($l['tenant_name'])) { + $dec = Encryption::decrypt($l['tenant_name']); + $l['tenant_name'] = ($dec !== false && $dec !== null) ? $dec : $l['tenant_name']; + } + } + + $cities = ['amman' => 'عمّان', 'irbid' => 'إربد', 'zarqa' => 'الزرقاء', 'aqaba' => 'العقبة', 'salt' => 'السلط', 'madaba' => 'مأدبا', 'karak' => 'الكرك', 'other' => 'أخرى']; + $specialties = ['tax' => 'ضرائب', 'audit' => 'تدقيق', 'bookkeeping' => 'مسك دفاتر', 'payroll' => 'رواتب', 'consulting' => 'استشارات', 'general' => 'عام']; + + json_paginated($listings, $total, $pagination, 'سوق المحاسبين'); + +} catch (\Exception $e) { + safe_error($e, 'marketplace/listings', 'حدث خطأ في تحميل القوائم.'); +} diff --git a/app/modules_app/marketplace/my_listing.php b/app/modules_app/marketplace/my_listing.php new file mode 100644 index 0000000..7929be7 --- /dev/null +++ b/app/modules_app/marketplace/my_listing.php @@ -0,0 +1,63 @@ + 'required', + 'city' => 'required', + 'specialty' => 'required', +]); + +if ($errors) { + json_error('بيانات ناقصة', 422, $errors); +} + +try { + // Check if listing exists + $existing = $db->prepare("SELECT id FROM marketplace_listings WHERE tenant_id = ? LIMIT 1"); + $existing->execute([$tenantId]); + $row = $existing->fetch(); + + if ($row) { + // Update + $db->prepare(" + UPDATE marketplace_listings SET + office_name = ?, city = ?, specialty = ?, description = ?, + contact_phone = ?, contact_email = ?, updated_at = NOW() + WHERE tenant_id = ? + ")->execute([ + $data['office_name'], $data['city'], $data['specialty'], + $data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '', + $tenantId + ]); + json_success(['id' => $row['id']], 'تم تحديث القائمة بنجاح'); + } else { + // Create + $id = Database::generateUuid(); + $db->prepare(" + INSERT INTO marketplace_listings (id, tenant_id, office_name, city, specialty, description, contact_phone, contact_email, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NOW()) + ")->execute([ + $id, $tenantId, $data['office_name'], $data['city'], $data['specialty'], + $data['description'] ?? '', $data['phone'] ?? '', $data['email'] ?? '' + ]); + json_success(['id' => $id], 'تم إضافة مكتبك للسوق بنجاح! 🎉'); + } +} catch (\Exception $e) { + safe_error($e, 'marketplace/my-listing', 'حدث خطأ في حفظ القائمة.'); +} diff --git a/app/modules_app/referral/apply.php b/app/modules_app/referral/apply.php new file mode 100644 index 0000000..9b9ed4e --- /dev/null +++ b/app/modules_app/referral/apply.php @@ -0,0 +1,86 @@ +prepare("SELECT * FROM referral_codes WHERE code = ? LIMIT 1"); + $stmt->execute([$code]); + $referralCode = $stmt->fetch(); + + if (!$referralCode) { + json_error('رمز الإحالة غير صالح', 404); + } + + // Prevent self-referral + if ($referralCode['user_id'] === $userId) { + json_error('لا يمكنك استخدام رمز الإحالة الخاص بك', 400); + } + + // Check if user already used a referral + $checkStmt = $db->prepare("SELECT id FROM referrals WHERE referred_id = ? LIMIT 1"); + $checkStmt->execute([$userId]); + if ($checkStmt->fetch()) { + json_error('لقد استخدمت رمز إحالة مسبقاً', 409); + } + + // 2. Create the referral record + $db->beginTransaction(); + + $referralId = \App\Core\Database::generateUuid(); + $stmt = $db->prepare(" + INSERT INTO referrals (id, referrer_id, referred_id, referral_code_id, status, created_at) + VALUES (?, ?, ?, ?, 'registered', NOW()) + "); + $stmt->execute([$referralId, $referralCode['user_id'], $userId, $referralCode['id']]); + + // 3. Notify the referrer + try { + $notifStmt = $db->prepare(" + INSERT INTO notifications (id, tenant_id, user_id, type, title, body, data, created_at) + VALUES (UUID(), ?, ?, 'referral', '🎉 إحالة جديدة!', 'شخص جديد انضم باستخدام رمز إحالتك', ?, NOW()) + "); + $notifStmt->execute([ + $referralCode['tenant_id'], + $referralCode['user_id'], + json_encode(['referral_id' => $referralId, 'code' => $code]) + ]); + } catch (\Exception $e) { + // Don't fail the whole operation if notification fails + error_log("[referral/apply] Notification failed: " . $e->getMessage()); + } + + $db->commit(); + + json_success([ + 'referral_id' => $referralId, + 'referrer_code' => $code, + 'status' => 'registered', + ], 'تم تطبيق رمز الإحالة بنجاح! 🎉'); + +} catch (\Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + safe_error($e, 'referral/apply', 'حدث خطأ في تطبيق رمز الإحالة.'); +} diff --git a/app/modules_app/referral/my_code.php b/app/modules_app/referral/my_code.php index f4d609f..f2362d8 100644 --- a/app/modules_app/referral/my_code.php +++ b/app/modules_app/referral/my_code.php @@ -93,5 +93,5 @@ try { } } catch (\Exception $e) { error_log("Referral error: " . $e->getMessage() . " | Trace: " . $e->getTraceAsString()); - json_error('حدث خطأ في نظام الإحالة: ' . $e->getMessage(), 500); + safe_error($e, 'referral/my_code', 'حدث خطأ في نظام الإحالة.'); } diff --git a/app/modules_app/reports/company_health.php b/app/modules_app/reports/company_health.php new file mode 100644 index 0000000..039a159 --- /dev/null +++ b/app/modules_app/reports/company_health.php @@ -0,0 +1,142 @@ +prepare($accessQuery); +$stmt->execute($accessParams); +$company = $stmt->fetch(); + +if (!$company) { + json_error('الشركة غير موجودة أو ليس لديك صلاحية', 404); +} + +$companyName = Encryption::decrypt($company['name']) ?: $company['name']; + +try { + // 1. Gather last 3 months of data + $months = []; + for ($i = 0; $i < 3; $i++) { + $m = date('m', strtotime("-{$i} months")); + $y = date('Y', strtotime("-{$i} months")); + + $stmt = $db->prepare(" + SELECT + COUNT(*) as total_invoices, + COALESCE(SUM(grand_total), 0) as revenue, + COALESCE(SUM(tax_amount), 0) as tax, + COALESCE(SUM(discount_total), 0) as discounts, + COALESCE(AVG(grand_total), 0) as avg_invoice, + SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count, + SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count + FROM invoices + WHERE company_id = ? AND MONTH(created_at) = ? AND YEAR(created_at) = ? + "); + $stmt->execute([$companyId, $m, $y]); + $data = $stmt->fetch(); + $data['month'] = (int)$m; + $data['year'] = (int)$y; + $months[] = $data; + } + + // 2. Pending invoices count + $pendingStmt = $db->prepare("SELECT COUNT(*) FROM invoices WHERE company_id = ? AND status = 'extracted'"); + $pendingStmt->execute([$companyId]); + $pendingCount = (int)$pendingStmt->fetchColumn(); + + // 3. Build AI prompt + $dataJson = json_encode([ + 'company_name' => $companyName, + 'tin' => $company['tax_identification_number'], + 'monthly_data' => $months, + 'pending_invoices' => $pendingCount, + ], JSON_UNESCAPED_UNICODE); + + $prompt = << 0) $score += 2; + if (($currentMonth['submitted_count'] ?? 0) > 0) $score += 1; + if ($pendingCount === 0) $score += 1; + if (($currentMonth['revenue'] ?? 0) > ($prevMonth['revenue'] ?? 0)) $score += 1; + + $report = [ + 'health_score' => min(10, $score), + 'health_label' => $score >= 8 ? 'ممتاز' : ($score >= 6 ? 'جيد' : 'متوسط'), + 'summary' => 'تقرير مبني على البيانات المتوفرة بدون تحليل AI.', + 'insights' => ['عدد الفواتير: ' . ($currentMonth['total_invoices'] ?? 0)], + 'warnings' => $pendingCount > 0 ? ["يوجد {$pendingCount} فاتورة بانتظار المراجعة"] : [], + 'recommendations' => ['تأكد من إرسال جميع الفواتير المعتمدة لجوفوترا'], + ]; + } + + json_success([ + 'company_id' => $companyId, + 'company_name' => $companyName, + 'report' => $report, + 'data' => [ + 'monthly_summary' => $months, + 'pending_count' => $pendingCount, + ], + 'generated_at' => date('c'), + ], 'تقرير صحة الشركة'); + +} catch (\Exception $e) { + safe_error($e, 'reports/company-health', 'حدث خطأ في إنشاء التقرير.'); +} diff --git a/app/modules_app/sms/receive.php b/app/modules_app/sms/receive.php new file mode 100644 index 0000000..f451544 --- /dev/null +++ b/app/modules_app/sms/receive.php @@ -0,0 +1,188 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$json_data = file_get_contents('php://input'); +$data = json_decode($json_data, true); + +if (!$data || empty($data['sender']) || empty($data['message'])) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'بيانات غير مكتملة. يجب إرسال sender و message.']); + exit; +} + +$sender = trim($data['sender']); +$message = trim($data['message']); + +$db = Database::getInstance(); + +try { + // 1. Save raw SMS log + $smsId = \App\Core\Database::generateUuid(); + $stmt = $db->prepare(" + INSERT INTO raw_sms_log (id, sender, message_body, status, received_at) + VALUES (?, ?, ?, 'pending', NOW()) + "); + $stmt->execute([$smsId, $sender, $message]); + + // 2. Try to auto-match with pending payments + $matchResult = matchPayment($db, $smsId, $sender, $message); + + http_response_code(200); + echo json_encode([ + 'status' => 'success', + 'message' => 'SMS received and processed.', + 'matched' => $matchResult['matched'], + 'details' => $matchResult['details'] ?? null, + ], JSON_UNESCAPED_UNICODE); + +} catch (\Exception $e) { + error_log("[sms/receive] Error: " . $e->getMessage()); + http_response_code(200); // Return 200 so bot doesn't retry + echo json_encode(['status' => 'error', 'message' => 'خطأ داخلي في المعالجة.']); +} + +/** + * Try to match the incoming SMS with a pending payment request. + * + * Matching logic: + * 1. Extract reference number from SMS (formats: MSQ-XXXX, REF-XXXX, or plain digits) + * 2. Extract amount from SMS + * 3. Find pending payment request matching reference OR amount + * 4. If matched → confirm payment → activate/extend subscription + */ +function matchPayment(\PDO $db, string $smsId, string $sender, string $message): array +{ + // Extract reference number (MSQ-XXXX pattern or any 6+ digit number) + $reference = null; + if (preg_match('/MSQ-([A-Z0-9]{4,10})/i', $message, $m)) { + $reference = 'MSQ-' . strtoupper($m[1]); + } elseif (preg_match('/REF[:\s-]*([A-Z0-9]{4,12})/i', $message, $m)) { + $reference = $m[1]; + } + + // Extract amount (Arabic or English digits) + $amount = null; + $msgNormalized = strtr($message, ['٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']); + if (preg_match('/(\d+[\.,]?\d{0,3})\s*(دينار|JOD|JD)/iu', $msgNormalized, $m)) { + $amount = (float)str_replace(',', '.', $m[1]); + } elseif (preg_match('/(\d+[\.,]\d{2})/', $msgNormalized, $m)) { + $amount = (float)str_replace(',', '.', $m[1]); + } + + if (!$reference && !$amount) { + // Can't match — mark SMS as unmatched + $db->prepare("UPDATE raw_sms_log SET status = 'unmatched', processed_at = NOW() WHERE id = ?")->execute([$smsId]); + return ['matched' => false, 'details' => 'لم يتم العثور على مرجع أو مبلغ في الرسالة']; + } + + // Search for pending payment request + $where = "pr.status = 'pending'"; + $params = []; + + if ($reference) { + $where .= " AND pr.reference_number = ?"; + $params[] = $reference; + } + if ($amount) { + $where .= " AND pr.amount = ?"; + $params[] = $amount; + } + + $stmt = $db->prepare(" + SELECT pr.*, t.name as tenant_name + FROM payment_requests pr + LEFT JOIN tenants t ON pr.tenant_id = t.id + WHERE {$where} + ORDER BY pr.created_at DESC + LIMIT 1 + "); + $stmt->execute($params); + $payment = $stmt->fetch(); + + if (!$payment) { + $db->prepare("UPDATE raw_sms_log SET status = 'unmatched', extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ?") + ->execute([$reference, $amount, $smsId]); + return ['matched' => false, 'details' => "مرجع: {$reference}, مبلغ: {$amount} — لم يتطابق مع أي طلب دفع"]; + } + + // MATCH FOUND — Process payment + $db->beginTransaction(); + + try { + // 1. Update payment request → confirmed + $db->prepare(" + UPDATE payment_requests SET status = 'confirmed', sms_log_id = ?, confirmed_at = NOW() WHERE id = ? + ")->execute([$smsId, $payment['id']]); + + // 2. Update SMS log → matched + $db->prepare(" + UPDATE raw_sms_log SET status = 'matched', payment_request_id = ?, extracted_ref = ?, extracted_amount = ?, processed_at = NOW() WHERE id = ? + ")->execute([$payment['id'], $reference, $amount, $smsId]); + + // 3. Activate/extend subscription + $planMonths = (int)($payment['plan_months'] ?? 1); + $db->prepare(" + UPDATE subscriptions + SET is_active = 1, + started_at = COALESCE(started_at, NOW()), + expires_at = DATE_ADD(COALESCE(expires_at, NOW()), INTERVAL ? MONTH), + updated_at = NOW() + WHERE tenant_id = ? + ")->execute([$planMonths, $payment['tenant_id']]); + + // 4. Notify user + \App\Services\SmartNotifications::send( + $payment['tenant_id'], + $payment['user_id'] ?? '', + 'payment_confirmed', + '✅ تم تأكيد الدفع!', + "تم تأكيد دفعة بقيمة {$payment['amount']} دينار. اشتراكك فعّال الآن.", + ['payment_id' => $payment['id'], 'amount' => $payment['amount']] + ); + + // 5. Audit log + AuditLogger::log('payment.auto_confirmed', 'payment', $payment['id'], null, [ + 'sms_id' => $smsId, + 'sender' => $sender, + 'reference' => $reference, + 'amount' => $amount, + ], ['user_id' => 'system', 'tenant_id' => $payment['tenant_id'], 'role' => 'system']); + + $db->commit(); + + return [ + 'matched' => true, + 'details' => "تم مطابقة الدفعة: {$payment['amount']} دينار — الاشتراك مُفعّل", + ]; + + } catch (\Exception $e) { + $db->rollBack(); + error_log("[sms/match] Failed: " . $e->getMessage()); + return ['matched' => false, 'details' => 'خطأ أثناء تأكيد الدفعة']; + } +} diff --git a/app/modules_app/subscriptions/assign.php b/app/modules_app/subscriptions/assign.php index 3db1d9b..125ffaa 100644 --- a/app/modules_app/subscriptions/assign.php +++ b/app/modules_app/subscriptions/assign.php @@ -91,5 +91,5 @@ try { } catch (\Exception $e) { if ($db->inTransaction()) $db->rollBack(); error_log("Subscription Assign Error: " . $e->getMessage()); - json_error('حدث خطأ أثناء تعيين الباقة: ' . $e->getMessage(), 500); + safe_error($e, 'subscriptions/assign', 'حدث خطأ أثناء تعيين الباقة.'); } diff --git a/app/modules_app/tenants/create.php b/app/modules_app/tenants/create.php index 2824415..fba38ad 100644 --- a/app/modules_app/tenants/create.php +++ b/app/modules_app/tenants/create.php @@ -78,6 +78,6 @@ try { json_success(null, 'تم إنشاء المكتب ومدير المكتب بنجاح'); } catch (\Exception $e) { $db->rollBack(); - json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500); + safe_error($e, 'tenants/create', 'حدث خطأ أثناء إنشاء المكتب.'); } diff --git a/app/modules_app/tenants/index.php b/app/modules_app/tenants/index.php index d4e8399..ffe1f99 100644 --- a/app/modules_app/tenants/index.php +++ b/app/modules_app/tenants/index.php @@ -42,5 +42,5 @@ try { json_success($tenants); } catch (\Exception $e) { - json_error('SQL Error in Tenants List: ' . $e->getMessage(), 500); + safe_error($e, 'tenants/index'); } diff --git a/app/modules_app/tenants/stats.php b/app/modules_app/tenants/stats.php index 5ea9171..7431c83 100644 --- a/app/modules_app/tenants/stats.php +++ b/app/modules_app/tenants/stats.php @@ -56,5 +56,5 @@ try { ]); } catch (\Exception $e) { - json_error('Stats Error: ' . $e->getMessage(), 500); + safe_error($e, 'tenants/stats'); } diff --git a/app/modules_app/tenants/update.php b/app/modules_app/tenants/update.php index 3bc1682..39b5126 100644 --- a/app/modules_app/tenants/update.php +++ b/app/modules_app/tenants/update.php @@ -59,5 +59,5 @@ try { json_success(null, 'تم تحديث بيانات المكتب بنجاح'); } catch (\Exception $e) { - json_error('حدث خطأ أثناء التحديث: ' . $e->getMessage(), 500); + safe_error($e, 'tenants/update', 'حدث خطأ أثناء التحديث.'); } diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index 8f49dd5..27fe2d4 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -31,7 +31,7 @@ $errors = Validator::validate($data, [ 'name' => 'required', 'email' => 'required|email', 'phone' => 'required', - 'password' => 'required', + 'password' => 'required|strong_password', 'role' => 'required' ]); diff --git a/app/modules_app/users/index.php b/app/modules_app/users/index.php index ed870aa..e4592c0 100644 --- a/app/modules_app/users/index.php +++ b/app/modules_app/users/index.php @@ -1,6 +1,6 @@ query(" - SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name - FROM users u - LEFT JOIN tenants t ON u.tenant_id = t.id - ORDER BY u.created_at DESC - "); - } elseif ($role === 'admin') { - // Admin sees only users in THEIR tenant (Accounting Office) - $stmt = $db->prepare(" - SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name - FROM users u - LEFT JOIN tenants t ON u.tenant_id = t.id - WHERE u.tenant_id = ? - ORDER BY u.created_at DESC - "); - $stmt->execute([$tenantId]); + $where = '1=1'; } else { - // Other roles shouldn't see user list - json_error('Unauthorized', 403); + $where = 'u.tenant_id = ?'; + $params = [$tenantId]; } + // Optional filters + $roleFilter = $_GET['role'] ?? null; + $activeFilter = $_GET['is_active'] ?? null; + + if ($roleFilter) { + $where .= ' AND u.role = ?'; + $params[] = $roleFilter; + } + if ($activeFilter !== null && $activeFilter !== '') { + $where .= ' AND u.is_active = ?'; + $params[] = (int)$activeFilter; + } + + // 3. Count total + $countStmt = $db->prepare("SELECT COUNT(*) FROM users u WHERE $where"); + $countStmt->execute($params); + $total = (int)$countStmt->fetchColumn(); + + // 4. Fetch page + $stmt = $db->prepare(" + SELECT u.id, u.name, u.email, u.phone, u.role, u.is_active, u.created_at, t.name as tenant_name + FROM users u + LEFT JOIN tenants t ON u.tenant_id = t.id + WHERE $where + ORDER BY u.created_at DESC + LIMIT {$pagination['limit']} OFFSET {$pagination['offset']} + "); + $stmt->execute($params); $users = $stmt->fetchAll(); - // 3. Decrypt data and format + // 5. Decrypt data $dec = function($val) { if (empty($val)) return ''; - $result = \App\Core\Encryption::decrypt((string)$val); + $result = Encryption::decrypt((string)$val); return ($result !== false && $result !== null) ? $result : (string)$val; }; @@ -54,18 +75,13 @@ try { if (!empty($user['phone'])) { $user['phone'] = $dec($user['phone']); } - if (!empty($user['tenant_name'])) { $user['tenant_name'] = $dec($user['tenant_name']); } } - if (empty($users)) { - error_log("USERS LIST: No users found for role: $role, tenant_id: $tenantId"); - } - - json_success($users); + json_paginated($users, $total, $pagination); } catch (\Exception $e) { - json_error('SQL Error in Users List: ' . $e->getMessage(), 500); + safe_error($e, 'users/index'); } diff --git a/app/modules_app/whatsapp/link_code.php b/app/modules_app/whatsapp/link_code.php new file mode 100644 index 0000000..b1b2bd5 --- /dev/null +++ b/app/modules_app/whatsapp/link_code.php @@ -0,0 +1,35 @@ +prepare("UPDATE users SET whatsapp_link_code = ? WHERE id = ?"); + $stmt->execute([$code, $userId]); + + json_success([ + 'code' => $code, + 'expires_in' => 600, // 10 minutes + 'instruction' => "أرسل هذه الرسالة للرقم التالي على واتساب:\n\nربط {$code}", + 'bot_number' => env('WHATSAPP_BOT_NUMBER', '+962XXXXXXXXX'), + ], 'تم إنشاء كود الربط'); + +} catch (\Exception $e) { + safe_error($e, 'whatsapp/link-code', 'حدث خطأ في إنشاء كود الربط.'); +} diff --git a/app/modules_app/whatsapp/webhook.php b/app/modules_app/whatsapp/webhook.php new file mode 100644 index 0000000..7d19fc9 --- /dev/null +++ b/app/modules_app/whatsapp/webhook.php @@ -0,0 +1,259 @@ +prepare("SELECT u.id, u.tenant_id, u.name, u.role FROM users u WHERE u.phone_hash = ? AND u.is_active = 1 LIMIT 1"); + $stmt->execute([$phoneHash]); + $user = $stmt->fetch(); + + // 2. Handle commands + $textLower = mb_strtolower(trim($text)); + + // === LINK COMMAND === + if (str_starts_with($textLower, 'ربط ') || str_starts_with($textLower, 'link ')) { + $code = trim(str_replace(['ربط', 'link'], '', $text)); + handleLinkCommand($db, $wa, $from, $phoneHash, $code); + exit; + } + + // === HELP COMMAND === + if (in_array($textLower, ['مساعدة', 'help', '؟', '?'])) { + $wa->sendMessage($from, "🤖 *أوامر مُصادَق:*\n\n" + . "📸 أرسل صورة فاتورة → نستخرج البيانات بالـ AI\n" + . "🔗 ربط [الكود] → لربط رقمك بحسابك\n" + . "📊 حالتي → ملخص حسابك\n" + . "❓ مساعدة → هذه الرسالة\n\n" + . "للتسجيل: musadaq.intaleqapp.com"); + json_success(null, 'Help sent'); + exit; + } + + // === ACCOUNT NOT LINKED === + if (!$user) { + $wa->sendMessage($from, "👋 مرحباً!\n\n" + . "رقمك غير مربوط بحساب مُصادَق.\n" + . "لربط حسابك، أرسل: *ربط [الكود]*\n\n" + . "للحصول على الكود، افتح تطبيق مُصادَق → الإعدادات → ربط واتساب.\n\n" + . "أو سجّل حساب جديد: musadaq.intaleqapp.com"); + json_success(null, 'Unlinked user guided'); + exit; + } + + $userName = Encryption::decrypt($user['name']) ?: 'المستخدم'; + + // === STATUS COMMAND === + if (in_array($textLower, ['حالتي', 'status', 'حالة'])) { + handleStatusCommand($db, $wa, $from, $user, $userName); + exit; + } + + // === IMAGE/INVOICE PROCESSING === + if ($imageData || $imageUrl) { + handleInvoiceImage($db, $wa, $from, $user, $userName, $imageData, $imageUrl, $mimeType); + exit; + } + + // === DEFAULT: Unknown text === + $wa->sendMessage($from, "مرحباً {$userName} 👋\n\n" + . "لم أفهم طلبك. يمكنك:\n" + . "📸 إرسال صورة فاتورة لاستخراج البيانات\n" + . "📊 كتابة *حالتي* لملخص حسابك\n" + . "❓ كتابة *مساعدة* لقائمة الأوامر"); + + json_success(null, 'Default response sent'); + +} catch (\Throwable $e) { + error_log("[whatsapp/webhook] Error: " . $e->getMessage()); + try { + $wa->sendMessage($from, "⚠️ حدث خطأ أثناء المعالجة. يرجى المحاولة مرة أخرى."); + } catch (\Throwable $ignore) {} + json_success(null, 'Error handled'); // Return 200 so the bot doesn't retry +} + +// ═══════════════════════════════════════════ +// HANDLER FUNCTIONS +// ═══════════════════════════════════════════ + +function handleLinkCommand($db, $wa, string $from, string $phoneHash, string $code): void +{ + if (empty($code)) { + $wa->sendMessage($from, "❌ يرجى إرسال الكود. مثال: *ربط ABC123*"); + json_success(null, 'Empty code'); + return; + } + + // Find user by link code + $stmt = $db->prepare("SELECT id, tenant_id FROM users WHERE whatsapp_link_code = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([strtoupper(trim($code))]); + $targetUser = $stmt->fetch(); + + if (!$targetUser) { + $wa->sendMessage($from, "❌ الكود غير صحيح. تأكد من الكود في تطبيق مُصادَق → الإعدادات → ربط واتساب."); + json_success(null, 'Invalid code'); + return; + } + + // Update user's phone hash + $updateStmt = $db->prepare("UPDATE users SET phone_hash = ?, whatsapp_linked = 1, whatsapp_link_code = NULL WHERE id = ?"); + $updateStmt->execute([$phoneHash, $targetUser['id']]); + + $wa->sendMessage($from, "✅ تم ربط رقمك بحسابك بنجاح! 🎉\n\n" + . "الآن يمكنك إرسال صور الفواتير مباشرة هنا وسنستخرج البيانات تلقائياً."); + + json_success(null, 'Account linked'); +} + +function handleStatusCommand($db, $wa, string $from, array $user, string $userName): void +{ + $tenantId = $user['tenant_id']; + + // Get stats + $invoiceStmt = $db->prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) as pending FROM invoices WHERE tenant_id = ?"); + $invoiceStmt->execute([$tenantId]); + $stats = $invoiceStmt->fetch(); + + $subStmt = $db->prepare("SELECT plan_slug, invoices_used_this_month, max_invoices_per_month FROM subscriptions WHERE tenant_id = ?"); + $subStmt->execute([$tenantId]); + $sub = $subStmt->fetch(); + + $plan = $sub['plan_slug'] ?? 'free'; + $used = $sub['invoices_used_this_month'] ?? 0; + $max = $sub['max_invoices_per_month'] ?? 15; + + $msg = "📊 *ملخص حسابك، {$userName}:*\n\n" + . "📋 إجمالي الفواتير: {$stats['total']}\n" + . "⏳ بانتظار المراجعة: {$stats['pending']}\n" + . "📦 الباقة: {$plan}\n" + . "🔢 الاستخدام: {$used}/{$max} فاتورة هذا الشهر\n\n" + . "🌐 لوحة التحكم: musadaq.intaleqapp.com"; + + $wa->sendMessage($from, $msg); + json_success(null, 'Status sent'); +} + +function handleInvoiceImage($db, $wa, string $from, array $user, string $userName, ?string $imageData, ?string $imageUrl, string $mimeType): void +{ + $wa->sendMessage($from, "📸 استلمت الصورة! جارٍ استخراج البيانات بالذكاء الاصطناعي... ⏳"); + + // Get image data + if (!$imageData && $imageUrl) { + $imageContent = @file_get_contents($imageUrl); + if (!$imageContent) { + $wa->sendMessage($from, "❌ فشل تحميل الصورة. يرجى إرسالها مرة أخرى."); + json_success(null, 'Image download failed'); + return; + } + $imageData = base64_encode($imageContent); + } + + if (!$imageData) { + $wa->sendMessage($from, "❌ لم أتمكن من قراءة الصورة."); + json_success(null, 'No image data'); + return; + } + + // Run AI extraction + $extracted = AI::extractInvoiceData($imageData, $mimeType); + + if (!$extracted) { + $wa->sendMessage($from, "⚠️ لم أتمكن من استخراج البيانات. تأكد أن الصورة واضحة وتحتوي على فاتورة."); + json_success(null, 'AI extraction failed'); + return; + } + + // Format response + $supplierName = $extracted['supplier']['name'] ?? 'غير محدد'; + $invoiceNum = $extracted['invoice_number'] ?? '-'; + $invoiceDate = $extracted['invoice_date'] ?? '-'; + $subtotal = number_format((float)($extracted['subtotal'] ?? 0), 2); + $tax = number_format((float)($extracted['tax_amount'] ?? 0), 2); + $total = number_format((float)($extracted['grand_total'] ?? 0), 2); + $linesCount = count($extracted['lines'] ?? []); + + $msg = "✅ *تم استخراج بيانات الفاتورة:*\n\n" + . "🏢 المورد: {$supplierName}\n" + . "🔢 رقم الفاتورة: {$invoiceNum}\n" + . "📅 التاريخ: {$invoiceDate}\n" + . "📦 البنود: {$linesCount}\n" + . "───────────────\n" + . "💰 المبلغ قبل الضريبة: {$subtotal} دينار\n" + . "🏛️ الضريبة: {$tax} دينار\n" + . "📊 *الإجمالي: {$total} دينار*\n\n"; + + // Add warnings if any + if (!empty($extracted['validation_warnings'])) { + $msg .= "⚠️ *تحذيرات:*\n"; + foreach ($extracted['validation_warnings'] as $w) { + $msg .= "• {$w}\n"; + } + $msg .= "\n"; + } + + $msg .= "💡 لحفظ هذه الفاتورة رسمياً، ارفعها من تطبيق مُصادَق."; + + $wa->sendMessage($from, $msg); + + // Log the interaction + try { + AuditLogger::log('whatsapp.invoice_extracted', 'whatsapp', null, null, [ + 'from' => substr($from, 0, 6) . '****', + 'invoice_number' => $invoiceNum, + 'total' => $total, + ], ['user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role']]); + } catch (\Throwable $e) { + // Non-critical + } + + json_success(null, 'Invoice extracted via WhatsApp'); +} diff --git a/database/migrations/004_gamification_whatsapp.sql b/database/migrations/004_gamification_whatsapp.sql new file mode 100644 index 0000000..eadd298 --- /dev/null +++ b/database/migrations/004_gamification_whatsapp.sql @@ -0,0 +1,31 @@ +-- Gamification Tables for Musadaq +-- Run this migration on your production database + +-- Points tracking +CREATE TABLE IF NOT EXISTS user_points ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + action VARCHAR(50) NOT NULL, + points INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_points_user (user_id), + INDEX idx_user_points_tenant (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Badge tracking +CREATE TABLE IF NOT EXISTS user_badges ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + badge_key VARCHAR(50) NOT NULL, + badge_name VARCHAR(100) NOT NULL, + badge_icon VARCHAR(10) NOT NULL DEFAULT '🏅', + earned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_badge (user_id, badge_key), + INDEX idx_user_badges_tenant (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- WhatsApp link code column (add to users table) +ALTER TABLE users ADD COLUMN IF NOT EXISTS whatsapp_link_code VARCHAR(10) DEFAULT NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS whatsapp_linked TINYINT(1) DEFAULT 0; diff --git a/database/migrations/005_chatbot.sql b/database/migrations/005_chatbot.sql new file mode 100644 index 0000000..73fb035 --- /dev/null +++ b/database/migrations/005_chatbot.sql @@ -0,0 +1,14 @@ +-- Chatbot History Table +-- Run this migration on your production database + +CREATE TABLE IF NOT EXISTS chatbot_history ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_chatbot_user (user_id), + INDEX idx_chatbot_tenant (tenant_id), + INDEX idx_chatbot_date (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/database/migrations/006_sms_bank.sql b/database/migrations/006_sms_bank.sql new file mode 100644 index 0000000..54c8fd6 --- /dev/null +++ b/database/migrations/006_sms_bank.sql @@ -0,0 +1,21 @@ +-- SMS Bank Integration Tables +-- Run this migration on your production database + +CREATE TABLE IF NOT EXISTS raw_sms_log ( + id VARCHAR(36) PRIMARY KEY, + sender VARCHAR(100) NOT NULL, + message_body TEXT NOT NULL, + status ENUM('pending', 'matched', 'unmatched', 'error') DEFAULT 'pending', + payment_request_id VARCHAR(36) DEFAULT NULL, + extracted_ref VARCHAR(50) DEFAULT NULL, + extracted_amount DECIMAL(12,3) DEFAULT NULL, + received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME DEFAULT NULL, + INDEX idx_sms_status (status), + INDEX idx_sms_payment (payment_request_id), + INDEX idx_sms_date (received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Add sms_log_id to payment_requests if not exists +ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS sms_log_id VARCHAR(36) DEFAULT NULL; +ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS confirmed_at DATETIME DEFAULT NULL; diff --git a/database/migrations/007_marketplace.sql b/database/migrations/007_marketplace.sql new file mode 100644 index 0000000..0c77278 --- /dev/null +++ b/database/migrations/007_marketplace.sql @@ -0,0 +1,20 @@ +-- Marketplace Tables +CREATE TABLE IF NOT EXISTS marketplace_listings ( + id VARCHAR(36) PRIMARY KEY, + tenant_id VARCHAR(36) NOT NULL, + office_name VARCHAR(200) NOT NULL, + city VARCHAR(50) NOT NULL DEFAULT 'amman', + specialty VARCHAR(50) NOT NULL DEFAULT 'general', + description TEXT DEFAULT NULL, + contact_phone VARCHAR(50) DEFAULT NULL, + contact_email VARCHAR(100) DEFAULT NULL, + rating DECIMAL(2,1) DEFAULT 0, + is_featured TINYINT(1) DEFAULT 0, + is_active TINYINT(1) DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT NULL, + UNIQUE KEY uk_tenant (tenant_id), + INDEX idx_city (city), + INDEX idx_specialty (specialty), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/musadaq-app/ios/Podfile.lock b/musadaq-app/ios/Podfile.lock index 03bb441..fea05d3 100644 --- a/musadaq-app/ios/Podfile.lock +++ b/musadaq-app/ios/Podfile.lock @@ -134,6 +134,8 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: - camerawesome (from `.symlinks/plugins/camerawesome/ios`) @@ -156,6 +158,7 @@ DEPENDENCIES: - share_plus (from `.symlinks/plugins/share_plus/ios`) - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: @@ -217,6 +220,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/speech_to_text/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: camerawesome: a961fa32dafc00d2f093d824311c84f849586b58 @@ -255,6 +260,7 @@ SPEC CHECKSUMS: share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d PODFILE CHECKSUM: a409a572b05f394ce1fca5d08bea69ffac194079 diff --git a/public/index.php b/public/index.php index 4758131..ade5f9c 100644 --- a/public/index.php +++ b/public/index.php @@ -82,6 +82,39 @@ $routes = [ 'v1/voice/transcribe' => ['POST', 'voice/transcribe.php'], 'v1/voice/parse-intent' => ['POST', 'voice/parse_intent.php'], 'v1/voice/parse-intent-grok' => ['POST', 'voice/grok_intent.php'], + + // Referral System + 'v1/referral/apply' => ['POST', 'referral/apply.php'], + + // AI Reports + 'v1/reports/company-health' => ['GET', 'reports/company_health.php'], + + // Payment Upload + 'v1/payments/upload-receipt' => ['POST', 'payments/upload_receipt.php'], + + // WhatsApp Bot + 'v1/whatsapp/webhook' => ['POST', 'whatsapp/webhook.php'], + 'v1/whatsapp/link-code' => ['GET', 'whatsapp/link_code.php'], + + // Gamification + 'v1/gamification/profile' => ['GET', 'gamification/profile.php'], + + // AI Chatbot + 'v1/chatbot/ask' => ['POST', 'chatbot/ask.php'], + 'v1/chatbot/history' => ['GET', 'chatbot/history.php'], + + // Academy + 'v1/academy/articles' => ['GET', 'academy/articles.php'], + + // Excel Import (was missing!) + 'v1/excel/import' => ['POST', 'excel/import.php'], + + // SMS Bank Integration + 'v1/sms/receive' => ['POST', 'sms/receive.php'], + + // Marketplace + 'v1/marketplace/listings' => ['GET', 'marketplace/listings.php'], + 'v1/marketplace/my-listing' => ['POST', 'marketplace/my_listing.php'], ]; if (isset($routes[$route])) { @@ -97,11 +130,12 @@ if (isset($routes[$route])) { if (file_exists($file)) { require_once $file; } else { - json_error("Endpoint file missing: {$route}", 500); + error_log("Router: Missing module file for route '{$route}': {$moduleFile}"); + json_error('خدمة غير متوفرة حالياً', 500); } } else { if (str_starts_with($route, 'v1/')) { - json_error("Not Found: {$route}", 404); + json_error('المسار المطلوب غير موجود', 404); } else { include __DIR__ . '/shell.php'; exit; diff --git a/public/landing.php b/public/landing.php new file mode 100644 index 0000000..612116f --- /dev/null +++ b/public/landing.php @@ -0,0 +1,321 @@ + + + + + + مُصادَق — أتمتة الفواتير الضريبية بالذكاء الاصطناعي + + + + + + + + + + + +
+
+
🚀 متوافق 100% مع منظومة جوفوترا الأردنية
+

صوّر الفاتورة
والذكاء الاصطناعي يكمل الباقي

+

منصة أتمتة الفواتير الإلكترونية للمحاسبين ومكاتب المحاسبة. استخراج البيانات تلقائياً، تدقيق ضريبي ذكي، وإرسال مباشر لجوفوترا — من صورة واحدة.

+ +
+
3 ثوانٍ
لاستخراج بيانات الفاتورة
+
99%
دقة الذكاء الاصطناعي
+
0 دينار
للبدء — بدون بطاقة
+
+
+
+ + +
+
+
+

ليش مُصادَق مختلف؟

+

مش مجرد برنامج فوترة — مساعدك المالي بالذكاء الاصطناعي

+
+
+
+
🤖
+

استخراج ذكي بالـ AI

+

صوّر الفاتورة أو ارفع PDF — الذكاء الاصطناعي يستخرج كل البيانات تلقائياً: اسم المورد، المبلغ، الضريبة، وبنود الفاتورة.

+
+
+
🔗
+

ربط مباشر بجوفوترا

+

إرسال الفاتورة بصيغة UBL 2.1 لمنظومة جوفوترا الأردنية بضغطة واحدة، مع رمز QR تلقائي.

+
+
+
🛡️
+

تدقيق ضريبي ذكي

+

يراجع نسب الضريبة تلقائياً حسب جدول الضرائب الأردني ويُنبّهك قبل الإرسال إذا وجد خطأ.

+
+
+
🏢
+

إدارة شركات متعددة

+

أدِر حتى 25 شركة من حساب واحد. لكل شركة إعدادات مستقلة وربط منفصل بجوفوترا.

+
+
+
📱
+

تطبيق هاتف ذكي

+

صوّر فواتير في الميدان بدون إنترنت. التطبيق يخزّنها ويرفعها تلقائياً عند توفر الاتصال.

+
+
+
🔒
+

أمان بمعايير بنكية

+

تشفير AES-256-GCM لكل البيانات، مصادقة ثنائية، وفصل كامل لبيانات كل مكتب محاسبي.

+
+
+
+
+ + +
+
+
+

كيف يعمل مُصادَق؟

+

من الصورة للإرسال الرسمي — بـ 3 خطوات

+
+
+
+
1
+

صوّر أو ارفع

+

صوّر الفاتورة من الهاتف أو ارفع ملف PDF/صورة من الكمبيوتر

+
+
+
2
+

الـ AI يستخرج ويدقق

+

الذكاء الاصطناعي يستخرج البيانات ويتحقق من صحة الضرائب تلقائياً

+
+
+
3
+

اعتمد وأرسل

+

راجع البيانات واضغط إرسال — الفاتورة تصل جوفوترا بصيغة UBL 2.1

+
+
+
+
+ + +
+
+
+

باقات تناسب كل حجم

+

ابدأ مجاناً — وترقّى مع نمو عملك

+
+
+
+
مجانية
+
0 دينار/شهر
+
    +
  • شركة واحدة
  • +
  • 15 فاتورة شهرياً
  • +
  • استخراج بالذكاء الاصطناعي
  • +
  • ربط مع جوفوترا
  • +
+ ابدأ مجاناً +
+
+
أساسية
+
15 دينار/شهر
+
    +
  • حتى 3 شركات
  • +
  • 100 فاتورة شهرياً
  • +
  • 3 مستخدمين
  • +
  • تقارير شهرية
  • +
+ اشترك الآن +
+ +
+
احترافية
+
99 دينار/شهر
+
    +
  • حتى 25 شركة
  • +
  • 2,000 فاتورة شهرياً
  • +
  • تدقيق ذكي بالـ AI
  • +
  • API كامل
  • +
  • مدير حساب مخصص
  • +
+ اشترك الآن +
+
+
+
+ + +
+
+
+

أسئلة شائعة

+
+
+
+
هل مُصادَق متوافق مع منظومة جوفوترا؟
+
نعم، مُصادَق يولّد فواتير بصيغة UBL 2.1 المتوافقة كلياً مع متطلبات دائرة ضريبة الدخل والمبيعات الأردنية، ويرسلها مباشرة عبر API جوفوترا.
+
+
+
هل يمكنني استخدامه بدون إنترنت؟
+
نعم، تطبيق الهاتف يعمل بدون إنترنت. يمكنك تصوير الفواتير وتخزينها محلياً، وعند توفر الاتصال يتم رفعها تلقائياً.
+
+
+
كيف يعمل الذكاء الاصطناعي في استخراج البيانات؟
+
نستخدم نماذج Gemini المتقدمة من Google لتحليل صور الفواتير واستخراج كل البيانات (اسم المورد، الرقم الضريبي، المبالغ، بنود الفاتورة) بدقة تصل لـ 99%.
+
+
+
هل بياناتي آمنة؟
+
نستخدم تشفير AES-256-GCM لكل البيانات الحساسة، مع فصل كامل لبيانات كل مكتب محاسبي (Multi-Tenancy). بياناتك لا يراها أحد غيرك.
+
+
+
هل يمكنني إلغاء اشتراكي في أي وقت؟
+
نعم، يمكنك إلغاء أو تغيير باقتك في أي وقت. بياناتك تبقى محفوظة لمدة 90 يوماً بعد الإلغاء.
+
+
+
+
+ + +
+
+
+

جاهز تختصر ساعات من العمل اليدوي؟

+

ابدأ مجاناً الآن — بدون بطاقة ائتمانية، بدون التزام.

+ ابدأ تجربتك المجانية ← +
+
+
+ + +
+
+

مُصادَق

+

منصة أتمتة الفواتير الإلكترونية — صُنع في الأردن 🇯🇴

+

© 2026 مُصادَق. جميع الحقوق محفوظة.

+
+
+ + + diff --git a/public/tool-encrypt.php b/public/tool-encrypt.php index 21eeb27..5dfdc80 100644 --- a/public/tool-encrypt.php +++ b/public/tool-encrypt.php @@ -1,11 +1,33 @@