From ea415e3a113f1c7eab79d1edd29ec6abb1fbd732 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 3 May 2026 13:39:05 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20=D9=85=D9=8F=D8=B5=D8=A7=D8=AF?= =?UTF-8?q?=D9=8E=D9=82:=20=D8=AA=D8=AD=D8=AF=D9=8A=D8=AB=20=D8=A8=D8=B1?= =?UTF-8?q?=D9=85=D8=AC=D9=8A=20=D8=AC=D8=AF=D9=8A=D8=AF=202026-05-03=2013?= =?UTF-8?q?:39?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Modules/Admin/AdminController.php | 50 ++++++ app/Modules/ApiKeys/ApiKeyController.php | 60 +++++++ app/Modules/ApiKeys/ApiKeyModel.php | 19 +++ app/Modules/Auth/AuthController.php | 53 ++++++ app/Modules/Invoices/InvoiceController.php | 49 ++++++ app/Modules/Users/UserController.php | 33 ++++ app/Services/AI/OpenAIProvider.php | 84 +++++++++ app/Services/RiskAnalysisService.php | 77 +++++++++ app/Services/TotpService.php | 83 +++++++++ phpunit.xml | 18 ++ public/index.html | 2 +- public/index.php | 36 +++- public/shell.php | 187 ++++++++++++++++++++- queue/Jobs/RiskAnalysisJob.php | 56 ++++++ queue/Jobs/SendNotificationJob.php | 37 ++++ queue/Jobs/SubmitJoFotaraJob.php | 81 +++++++++ queue/worker.php | 15 ++ supervisor.conf | 9 + tests/Unit/TotpServiceTest.php | 30 ++++ 19 files changed, 972 insertions(+), 7 deletions(-) create mode 100644 app/Modules/Admin/AdminController.php create mode 100644 app/Modules/ApiKeys/ApiKeyController.php create mode 100644 app/Modules/ApiKeys/ApiKeyModel.php create mode 100644 app/Services/AI/OpenAIProvider.php create mode 100644 app/Services/RiskAnalysisService.php create mode 100644 app/Services/TotpService.php create mode 100644 phpunit.xml create mode 100644 queue/Jobs/RiskAnalysisJob.php create mode 100644 queue/Jobs/SendNotificationJob.php create mode 100644 queue/Jobs/SubmitJoFotaraJob.php create mode 100644 supervisor.conf create mode 100644 tests/Unit/TotpServiceTest.php diff --git a/app/Modules/Admin/AdminController.php b/app/Modules/Admin/AdminController.php new file mode 100644 index 0000000..b80f473 --- /dev/null +++ b/app/Modules/Admin/AdminController.php @@ -0,0 +1,50 @@ +user->role ?? '') !== 'super_admin') { + Response::error('غير مصرح', 'FORBIDDEN', 403); + return; + } + + $db = Database::getInstance(); + + $stmt = $db->prepare("SELECT COUNT(*) as count FROM tenants"); + $stmt->execute(); + $totalTenants = $stmt->fetch()['count']; + + $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices"); + $stmt->execute(); + $totalInvoices = $stmt->fetch()['count']; + + // Simple Health Check + $redisHealth = 'ok'; + try { + $redis = \App\Core\Redis::getInstance(); + $redis->ping(); + } catch (\Throwable $e) { + $redisHealth = 'failed'; + } + + Response::json([ + 'success' => true, + 'data' => [ + 'total_tenants' => $totalTenants, + 'total_invoices' => $totalInvoices, + 'system_health' => [ + 'database' => 'ok', + 'redis' => $redisHealth + ] + ] + ]); + } +} diff --git a/app/Modules/ApiKeys/ApiKeyController.php b/app/Modules/ApiKeys/ApiKeyController.php new file mode 100644 index 0000000..8f4c480 --- /dev/null +++ b/app/Modules/ApiKeys/ApiKeyController.php @@ -0,0 +1,60 @@ +tenantId; + $keys = $this->apiKeyModel->findAllByTenant($tenantId); + + Response::json([ + 'success' => true, + 'data' => $keys + ]); + } + + public function create(Request $request): void + { + $tenantId = $request->tenantId; + $data = $request->getBody(); + + if (empty($data['name'])) { + Response::error('اسم المفتاح مطلوب', 'VALIDATION_ERROR', 422); + return; + } + + $id = \Ramsey\Uuid\Uuid::uuid4()->toString(); + // Generate a random key + $rawKey = bin2hex(random_bytes(32)); + $prefix = substr($rawKey, 0, 8); + $hashedKey = hash('sha256', $rawKey); + + $this->apiKeyModel->create([ + 'id' => $id, + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'key_hash' => $hashedKey, + 'prefix' => $prefix, + 'is_active' => 1 + ]); + + Response::json([ + 'success' => true, + 'message' => 'تم إنشاء مفتاح API بنجاح', + 'data' => [ + 'id' => $id, + 'name' => $data['name'], + 'key' => $rawKey // Only shown once! + ] + ], 201); + } +} diff --git a/app/Modules/ApiKeys/ApiKeyModel.php b/app/Modules/ApiKeys/ApiKeyModel.php new file mode 100644 index 0000000..d987511 --- /dev/null +++ b/app/Modules/ApiKeys/ApiKeyModel.php @@ -0,0 +1,19 @@ +db()->prepare("SELECT id, name, prefix, expires_at, last_used_at, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL"); + $stmt->execute([$tenantId]); + return $stmt->fetchAll(); + } +} diff --git a/app/Modules/Auth/AuthController.php b/app/Modules/Auth/AuthController.php index e088643..62f0fe9 100644 --- a/app/Modules/Auth/AuthController.php +++ b/app/Modules/Auth/AuthController.php @@ -25,6 +25,16 @@ final class AuthController try { $result = $this->authService->login($email, $password); + // 2FA Check + if ($result['user']->totp_enabled) { + Response::json([ + 'success' => true, + 'requires_2fa' => true, + 'temp_token' => $result['access_token'] + ]); + return; + } + // Set refresh token in HttpOnly cookie setcookie('refresh_token', $result['refresh_token'], [ 'expires' => time() + (60 * 60 * 24 * 7), @@ -128,4 +138,47 @@ final class AuthController Response::error($e->getMessage(), 'REGISTRATION_FAILED', 400); } } + + public function enable2FA(Request $request): void + { + $user = $request->user; + $totpService = new \App\Services\TotpService(); + $secret = $totpService->generateSecret(); + $qrUrl = $totpService->getQrCodeUrl($user->email, $secret); + + Response::json([ + 'success' => true, + 'data' => [ + 'secret' => $secret, + 'qr_url' => $qrUrl + ] + ]); + } + + public function verify2FA(Request $request): void + { + $data = $request->getBody(); + $code = $data['code'] ?? ''; + $secret = $data['secret'] ?? ''; + + $totpService = new \App\Services\TotpService(); + if ($totpService->verify($secret, $code)) { + $db = \App\Core\Database::getInstance(); + $stmt = $db->prepare("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?"); + $stmt->execute([$secret, $request->user->user_id]); + + Response::json(['success' => true, 'message' => 'تم تفعيل التحقق الثنائي بنجاح']); + } else { + Response::error('رمز التحقق غير صحيح', 'INVALID_CODE', 400); + } + } + + public function disable2FA(Request $request): void + { + $db = \App\Core\Database::getInstance(); + $stmt = $db->prepare("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?"); + $stmt->execute([$request->user->user_id]); + + Response::json(['success' => true, 'message' => 'تم تعطيل التحقق الثنائي']); + } } diff --git a/app/Modules/Invoices/InvoiceController.php b/app/Modules/Invoices/InvoiceController.php index 5e6d720..8ce1114 100644 --- a/app/Modules/Invoices/InvoiceController.php +++ b/app/Modules/Invoices/InvoiceController.php @@ -89,4 +89,53 @@ final class InvoiceController Response::error($e->getMessage(), 'UPLOAD_FAILED', 500); } } + + public function detail(Request $request, array $vars): void + { + $tenantId = $request->tenantId; + $invoiceId = $vars['id'] ?? null; + + $db = \App\Core\Database::getInstance(); + $stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->execute([$invoiceId, $tenantId]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + Response::error('الفاتورة غير موجودة', 'NOT_FOUND', 404); + return; + } + + // Additional authorization check based on assigned company if needed + $role = $request->user->role ?? 'viewer'; + if ($role !== 'super_admin' && $invoice['company_id'] !== $request->user->assigned_company_id) { + Response::error('غير مصرح لك بمشاهدة هذه الفاتورة', 'FORBIDDEN', 403); + return; + } + + // Fetch lines + $stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY id ASC"); + $stmt->execute([$invoiceId]); + $invoice['lines'] = $stmt->fetchAll(); + + Response::json([ + 'success' => true, + 'data' => $invoice + ]); + } + + public function submit(Request $request, array $vars): void + { + $tenantId = $request->tenantId; + $invoiceId = $vars['id']; + + // Push to Queue for JoFotara Submission + \App\Services\QueueService::push('submit_jofotara', [ + 'invoice_id' => $invoiceId + ]); + + Response::json([ + 'success' => true, + 'message' => 'Invoice submission queued.' + ]); + } } diff --git a/app/Modules/Users/UserController.php b/app/Modules/Users/UserController.php index cf89589..9031d78 100644 --- a/app/Modules/Users/UserController.php +++ b/app/Modules/Users/UserController.php @@ -38,5 +38,38 @@ final class UserController 'success' => true, 'data' => $user ]); + public function create(Request $request): void + { + $tenantId = $request->tenantId; + $data = $request->getBody(); + + if (empty($data['email']) || empty($data['password']) || empty($data['name']) || empty($data['role'])) { + Response::error('جميع الحقول مطلوبة', 'VALIDATION_ERROR', 422); + return; + } + + if ($this->userModel->findByEmail($data['email'])) { + Response::error('البريد الإلكتروني مستخدم مسبقاً', 'DUPLICATE_EMAIL', 409); + return; + } + + $userId = \Ramsey\Uuid\Uuid::uuid4()->toString(); + + $this->userModel->create([ + 'id' => $userId, + 'tenant_id' => $tenantId, + 'name' => $data['name'], + 'email' => $data['email'], + 'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID), + 'role' => $data['role'], + 'assigned_company_id' => $data['assigned_company_id'] ?? null, + 'is_active' => 1 + ]); + + Response::json([ + 'success' => true, + 'message' => 'تم إضافة المستخدم بنجاح', + 'data' => ['id' => $userId] + ], 201); } } diff --git a/app/Services/AI/OpenAIProvider.php b/app/Services/AI/OpenAIProvider.php new file mode 100644 index 0000000..b4ed669 --- /dev/null +++ b/app/Services/AI/OpenAIProvider.php @@ -0,0 +1,84 @@ +apiKey = $_ENV['OPENAI_API_KEY'] ?? ''; + $this->model = $_ENV['OPENAI_MODEL'] ?? 'gpt-4o-mini'; + } + + public function isConfigured(): bool + { + return !empty($this->apiKey); + } + + public function extractInvoiceData(string $fileContent, string $mimeType, string $prompt): array + { + if (!$this->isConfigured()) { + throw new Exception("OpenAI API Key is missing. Please configure it in .env"); + } + + $base64Data = base64_encode($fileContent); + + $payload = [ + 'model' => $this->model, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'text', + 'text' => $prompt + ], + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => "data:{$mimeType};base64,{$base64Data}" + ] + ] + ] + ] + ], + 'response_format' => ['type' => 'json_object'], + 'temperature' => 0.1 + ]; + + $ch = curl_init('https://api.openai.com/v1/chat/completions'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + "Authorization: Bearer {$this->apiKey}" + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + throw new Exception("OpenAI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}"); + } + + $result = json_decode($response, true); + $text = $result['choices'][0]['message']['content'] ?? '{}'; + + $data = json_decode($text, true); + if (!is_array($data)) { + throw new Exception("Failed to parse OpenAI output as JSON: {$text}"); + } + + return $data; + } +} diff --git a/app/Services/RiskAnalysisService.php b/app/Services/RiskAnalysisService.php new file mode 100644 index 0000000..5b67272 --- /dev/null +++ b/app/Services/RiskAnalysisService.php @@ -0,0 +1,77 @@ +prepare("SELECT status, COUNT(*) as count FROM invoices WHERE company_id = ? GROUP BY status"); + $stmt->execute([$companyId]); + $stats = $stmt->fetchAll(); + + $total = 0; + $rejected = 0; + foreach ($stats as $stat) { + $total += $stat['count']; + if ($stat['status'] === 'rejected' || $stat['status'] === 'validation_failed') { + $rejected += $stat['count']; + } + } + + if ($total > 0) { + $rejectionRate = $rejected / $total; + if ($rejectionRate > 0.10) { // More than 10% rejections + $penalty = min(30, (int)(($rejectionRate - 0.10) * 100)); + $score -= $penalty; + $factors[] = "نسبة رفض عالية: " . round($rejectionRate * 100, 1) . "% (خصم {$penalty} نقطة)"; + } + } + + // 2. High Value Cash Invoices + $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND invoice_type = 'cash' AND grand_total > 5000"); + $stmt->execute([$companyId]); + $highValueCash = $stmt->fetch()['count']; + + if ($highValueCash > 0) { + $penalty = min(20, $highValueCash * 2); + $score -= $penalty; + $factors[] = "وجود فواتير نقدية بقيم عالية: {$highValueCash} فاتورة (خصم {$penalty} نقطة)"; + } + + // 3. Late submissions (invoice_date is much older than created_at) + $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND DATEDIFF(created_at, invoice_date) > 7"); + $stmt->execute([$companyId]); + $lateInvoices = $stmt->fetch()['count']; + + if ($lateInvoices > 0) { + $penalty = min(15, $lateInvoices * 1); + $score -= $penalty; + $factors[] = "تأخير في رفع الفواتير: {$lateInvoices} فاتورة متأخرة بأكثر من 7 أيام (خصم {$penalty} نقطة)"; + } + + // Determine Risk Level + $riskLevel = 'low'; + if ($score < 50) { + $riskLevel = 'high'; + } elseif ($score < 80) { + $riskLevel = 'medium'; + } + + return [ + 'score' => max(0, $score), + 'level' => $riskLevel, + 'factors' => $factors, + 'calculated_at' => date('Y-m-d H:i:s') + ]; + } +} diff --git a/app/Services/TotpService.php b/app/Services/TotpService.php new file mode 100644 index 0000000..70754e4 --- /dev/null +++ b/app/Services/TotpService.php @@ -0,0 +1,83 @@ +calculateCode($secret, (int)($currentTime + $i)) === $code) { + return true; + } + } + + return false; + } + + private function calculateCode(string $secret, int $time): string + { + $key = $this->base32Decode($secret); + $timeHex = str_pad(dechex($time), 16, '0', STR_PAD_LEFT); + $timeBin = pack('H*', $timeHex); + + $hash = hash_hmac('sha1', $timeBin, $key, true); + $offset = ord($hash[19]) & 0xf; + + $otp = ( + ((ord($hash[$offset]) & 0x7f) << 24) | + ((ord($hash[$offset + 1]) & 0xff) << 16) | + ((ord($hash[$offset + 2]) & 0xff) << 8) | + (ord($hash[$offset + 3]) & 0xff) + ) % 1000000; + + return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); + } + + private function base32Decode(string $base32): string + { + $base32 = strtoupper($base32); + $buffer = 0; + $bufferSize = 0; + $decoded = ''; + + for ($i = 0; $i < strlen($base32); $i++) { + $char = $base32[$i]; + $pos = strpos(self::ALPHABET, $char); + if ($pos === false) continue; + + $buffer = ($buffer << 5) | $pos; + $bufferSize += 5; + + if ($bufferSize >= 8) { + $bufferSize -= 8; + $decoded .= chr(($buffer >> $bufferSize) & 0xff); + } + } + + return $decoded; + } + + public function getQrCodeUrl(string $userEmail, string $secret, string $issuer = 'Musadaq'): string + { + $label = urlencode($issuer . ':' . $userEmail); + $otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . urlencode($issuer); + return "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=" . urlencode($otpauth); + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..96fd541 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests/Unit + + + tests/Feature + + + + + + + diff --git a/public/index.html b/public/index.html index 2fbecea..8eefcfa 100644 --- a/public/index.html +++ b/public/index.html @@ -47,7 +47,7 @@ - دخول + دخول diff --git a/public/index.php b/public/index.php index 3414a62..72214c7 100644 --- a/public/index.php +++ b/public/index.php @@ -15,6 +15,18 @@ $router = $app->getRouter(); // ══ Auth Routes ══════════════════════════════════════════════ $router->addRoute('POST', '/api/v1/auth/login', [AuthController::class, 'login']); $router->addRoute('POST', '/api/v1/auth/register', [AuthController::class, 'register']); +$router->addRoute('POST', '/api/v1/auth/2fa/enable', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [AuthController::class, 'enable2FA'] +]); +$router->addRoute('POST', '/api/v1/auth/2fa/verify', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [AuthController::class, 'verify2FA'] +]); +$router->addRoute('POST', '/api/v1/auth/2fa/disable', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [AuthController::class, 'disable2FA'] +]); // ══ Company Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/companies', [ @@ -33,11 +45,11 @@ $router->addRoute('PUT', '/api/v1/companies/{id}/jofotara', [ // ══ User Routes ══════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Users\UsersController::class, 'list'] + 'handler' => [\App\Modules\Users\UserController::class, 'index'] ]); $router->addRoute('POST', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Users\UsersController::class, 'create'] + 'handler' => [\App\Modules\Users\UserController::class, 'create'] ]); // ══ Invoice Routes ═══════════════════════════════════════════ @@ -53,6 +65,10 @@ $router->addRoute('GET', '/api/v1/invoices/{id}', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'detail'] ]); +$router->addRoute('POST', '/api/v1/invoices/{id}/submit', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'submit'] +]); // ══ Subscriptions ═════════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/subscriptions/me', [ @@ -60,6 +76,16 @@ $router->addRoute('GET', '/api/v1/subscriptions/me', [ 'handler' => [\App\Modules\Subscriptions\SubscriptionController::class, 'me'] ]); +// ══ API Keys ═══════════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/api-keys', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], + 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'list'] +]); +$router->addRoute('POST', '/api/v1/api-keys', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], + 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'create'] +]); + // ══ External API (HMAC) ══════════════════════════════════════ $router->addRoute('POST', '/api/v1/external/invoices/upload', [ 'middleware' => [\App\Middleware\HmacMiddleware::class], @@ -72,6 +98,12 @@ $router->addRoute('GET', '/api/v1/dashboard', [ 'handler' => [\App\Modules\Dashboard\DashboardController::class, 'getStats'] ]); +// ══ Super Admin ══════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/admin/stats', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Admin\AdminController::class, 'getSystemStats'] +]); + // ══ Health Check ═════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/health', function($request) { \App\Core\Response::json([ diff --git a/public/shell.php b/public/shell.php index 918fb0a..e4adfb6 100644 --- a/public/shell.php +++ b/public/shell.php @@ -326,6 +326,7 @@ +

ليس لديك حساب؟ سجل شركتك الآن

`; @@ -354,6 +355,62 @@ }; } + // ── Register View ─────────────────────────────────────────── + function renderRegister() { + document.getElementById('sidebar').classList.add('hidden'); + document.getElementById('header').classList.add('hidden'); + document.getElementById('ai-container').classList.add('hidden'); + + contentDiv.innerHTML = ` +
+
+
+ +
+

التسجيل في مُصادَق

+

ابدأ تجربتك المجانية واربط مع جو-فواتير بثوانٍ

+ +
+ + + + + + +
+

لديك حساب بالفعل؟ سجل الدخول

+
+
+ `; + + document.getElementById('register-form').onsubmit = async (e) => { + e.preventDefault(); + const btn = e.target.querySelector('button'); + btn.innerHTML = 'جاري إنشاء الحساب...'; + btn.disabled = true; + + try { + const data = { + tenant_name: document.getElementById('reg-tenant').value, + user_name: document.getElementById('reg-user').value, + email: document.getElementById('reg-email').value, + password: document.getElementById('reg-password').value + }; + const res = await API.post('/auth/register', data); + localStorage.setItem('access_token', res.data.access_token); + localStorage.setItem('user_role', res.data.user.role); + API.accessToken = res.data.access_token; + initApp(); + } catch (err) { + const errEl = document.getElementById('register-error'); + errEl.textContent = err.error?.message_ar || err.error?.details?.message || err.message || 'خطأ في التسجيل'; + errEl.classList.remove('hidden'); + btn.innerHTML = 'إنشاء حساب جديد'; + btn.disabled = false; + } + }; + } + // ── Dashboard View ─────────────────────────────────────── async function renderDashboard() { document.getElementById('page-title').textContent = 'لوحة التحكم السريعة'; @@ -400,7 +457,7 @@ html += `

لا توجد فواتير بعد

`; } else { stats.recent_invoices.forEach(inv => { - const statusColor = inv.status === 'APPROVED' ? 'text-primary' : (inv.status === 'REJECTED' ? 'text-red-400' : 'text-yellow-400'); + const statusColor = inv.status === 'approved' ? 'text-primary' : (inv.status === 'rejected' ? 'text-red-400' : 'text-yellow-400'); html += `
@@ -535,10 +592,10 @@ html += `لا توجد فواتير.`; } else { invoices.forEach(inv => { - const statusColor = inv.status === 'APPROVED' ? 'text-primary' : (inv.status === 'REJECTED' ? 'text-red-400' : 'text-yellow-400'); + const statusColor = inv.status === 'approved' ? 'text-primary' : (inv.status === 'rejected' ? 'text-red-400' : 'text-yellow-400'); html += ` - - ${inv.id} + + ${inv.id} ${inv.company_id} ${new Date(inv.created_at).toLocaleDateString('ar-JO')} ${inv.status} @@ -554,6 +611,128 @@ } } + async function renderInvoiceDetail(id) { + document.getElementById('page-title').textContent = 'تفاصيل الفاتورة'; + contentDiv.innerHTML = `
`; + + try { + const res = await API.get(`/invoices/${id}`); + const inv = res.data; + + const statusColor = inv.status === 'approved' ? 'text-primary' : (inv.status === 'rejected' ? 'text-red-400' : 'text-yellow-400'); + + let linesHtml = ''; + if (inv.lines && inv.lines.length > 0) { + inv.lines.forEach(line => { + linesHtml += ` + + ${line.description || 'بدون وصف'} + ${line.quantity} + ${line.unit_price} + ${line.line_total} + + `; + }); + } else { + linesHtml = 'لا توجد بنود مستخرجة بعد'; + } + + contentDiv.innerHTML = ` +
+ +
+
+

المستند الأصلي

+ فتح في نافذة جديدة +
+
+ ${inv.original_file_path.endsWith('.pdf') + ? `` + : `Invoice Image` + } +
+
+ + +
+
+
+
+

${inv.supplier_name || 'مورد غير معروف'}

+

رقم الفاتورة: ${inv.invoice_number || '---'}

+
+ + ${inv.status.toUpperCase()} + +
+ +
+
+

تاريخ الفاتورة

+

${inv.invoice_date || '---'}

+
+
+

الرقم الضريبي (المورد)

+

${inv.supplier_tin || '---'}

+
+
+ +
بنود الفاتورة
+ + + + + + + + + + + ${linesHtml} + +
الوصفالكميةالسعرالمجموع
+ +
+
+ المجموع الفرعي + ${inv.subtotal} JOD +
+
+ الضريبة + ${inv.tax_amount} JOD +
+
+ الإجمالي + ${inv.grand_total} JOD +
+
+
+ +
+ + ${inv.status === 'extracted' ? ` + + ` : ''} +
+
+
+ `; + } catch (err) { + contentDiv.innerHTML = `
خطأ في تحميل تفاصيل الفاتورة: ${err.message}
`; + } + } + + async function submitToJoFotara(id) { + try { + // We'll need a POST /api/v1/invoices/{id}/submit endpoint + const res = await API.post(`/invoices/${id}/submit`, {}); + alert('تم إرسال الفاتورة للطابور بنجاح. سيتم تحديث الحالة تلقائياً.'); + renderInvoiceDetail(id); + } catch (err) { + alert(err.error?.message_ar || 'فشل الإرسال: تأكد من إعدادات الربط للشركة.'); + } + } + // ── Modals & Actions ───────────────────────────────────── function showAddCompanyModal() { const modals = document.getElementById('modals'); diff --git a/queue/Jobs/RiskAnalysisJob.php b/queue/Jobs/RiskAnalysisJob.php new file mode 100644 index 0000000..047cb2b --- /dev/null +++ b/queue/Jobs/RiskAnalysisJob.php @@ -0,0 +1,56 @@ +riskService->calculateCompanyRiskScore($companyId); + + // Store or update risk score + $db = Database::getInstance(); + + $stmt = $db->prepare("SELECT id FROM risk_scores WHERE company_id = ? LIMIT 1"); + $stmt->execute([$companyId]); + $existing = $stmt->fetch(); + + if ($existing) { + $stmt = $db->prepare("UPDATE risk_scores SET risk_level = ?, score = ?, factors = ?, calculated_at = NOW() WHERE company_id = ?"); + $stmt->execute([ + $analysis['level'], + $analysis['score'], + json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE), + $companyId + ]); + } else { + $stmt = $db->prepare("INSERT INTO risk_scores (id, tenant_id, company_id, risk_level, score, factors, calculated_at) VALUES (?, ?, ?, ?, ?, ?, NOW())"); + $stmt->execute([ + \Ramsey\Uuid\Uuid::uuid4()->toString(), + $tenantId, + $companyId, + $analysis['level'], + $analysis['score'], + json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE) + ]); + } + } catch (Throwable $e) { + echo "[!] Risk Analysis failed for company {$companyId}: " . $e->getMessage() . "\n"; + throw $e; + } + } +} diff --git a/queue/Jobs/SendNotificationJob.php b/queue/Jobs/SendNotificationJob.php new file mode 100644 index 0000000..341ffa5 --- /dev/null +++ b/queue/Jobs/SendNotificationJob.php @@ -0,0 +1,37 @@ +prepare("INSERT INTO notifications (id, user_id, title, message, type, is_read, created_at) VALUES (?, ?, ?, ?, ?, 0, NOW())"); + $stmt->execute([ + \Ramsey\Uuid\Uuid::uuid4()->toString(), + $userId, + $title, + $message, + $type + ]); + + // Here we could also trigger WebSockets or push notifications if implemented + + } catch (Throwable $e) { + echo "[!] Notification failed for user {$userId}: " . $e->getMessage() . "\n"; + throw $e; + } + } +} diff --git a/queue/Jobs/SubmitJoFotaraJob.php b/queue/Jobs/SubmitJoFotaraJob.php new file mode 100644 index 0000000..0e7ef0c --- /dev/null +++ b/queue/Jobs/SubmitJoFotaraJob.php @@ -0,0 +1,81 @@ +invoiceModel->update($invoiceId, ['status' => 'submitting']); + + // 2. Fetch Invoice + $db = \App\Core\Database::getInstance(); + $stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? LIMIT 1"); + $stmt->execute([$invoiceId]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + throw new \Exception("Invoice not found."); + } + + // 3. Fetch Company Credentials + $credentials = $this->companyService->getJoFotaraCredentials($invoice['company_id']); + if (empty($credentials['clientId']) || empty($credentials['secretKey'])) { + throw new \Exception("Company is not linked to JoFotara."); + } + + // 4. Fetch Invoice Lines + $stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?"); + $stmt->execute([$invoiceId]); + $lines = $stmt->fetchAll(); + + // 5. Generate UBL XML + $xmlString = $this->ublGenerator->generate($invoice, $lines); + $xmlBase64 = base64_encode($xmlString); + + // 6. Submit to JoFotara + $response = $this->jofotaraGateway->submitInvoice($invoice['company_id'], $xmlBase64, $credentials); + + // 7. Process Response + // Assuming response contains a success boolean and possibly qr_code + if (isset($response['success']) && $response['success']) { + $this->invoiceModel->update($invoiceId, [ + 'status' => 'approved', + 'qr_code' => $response['qr_code'] ?? null, + 'jofotara_response' => json_encode($response, JSON_UNESCAPED_UNICODE) + ]); + } else { + $this->invoiceModel->update($invoiceId, [ + 'status' => 'rejected', + 'jofotara_response' => json_encode($response, JSON_UNESCAPED_UNICODE) + ]); + } + + } catch (Throwable $e) { + $this->invoiceModel->update($invoiceId, [ + 'status' => 'validation_failed', + 'validation_errors' => json_encode([['message_ar' => 'فشل الإرسال: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE) + ]); + throw $e; + } + } +} diff --git a/queue/worker.php b/queue/worker.php index d94090c..f9e6669 100644 --- a/queue/worker.php +++ b/queue/worker.php @@ -34,6 +34,21 @@ while ($keepRunning) { $handler->handle($job['payload']); break; + case 'submit_jofotara': + $handler = $container->get(\Queue\Jobs\SubmitJoFotaraJob::class); + $handler->handle($job['payload']); + break; + + case 'risk_analysis': + $handler = $container->get(\Queue\Jobs\RiskAnalysisJob::class); + $handler->handle($job['payload']); + break; + + case 'send_notification': + $handler = $container->get(\Queue\Jobs\SendNotificationJob::class); + $handler->handle($job['payload']); + break; + default: echo "[!] Unknown job type: {$job['type']}\n"; } diff --git a/supervisor.conf b/supervisor.conf new file mode 100644 index 0000000..5ce6663 --- /dev/null +++ b/supervisor.conf @@ -0,0 +1,9 @@ +[program:musadaq-worker] +command=php /var/www/musadeq/queue/worker.php +user=www-data +autostart=true +autorestart=true +stderr_logfile=/var/log/musadaq-worker.err.log +stdout_logfile=/var/log/musadaq-worker.out.log +numprocs=2 +process_name=%(program_name)s_%(process_num)02d diff --git a/tests/Unit/TotpServiceTest.php b/tests/Unit/TotpServiceTest.php new file mode 100644 index 0000000..a030f82 --- /dev/null +++ b/tests/Unit/TotpServiceTest.php @@ -0,0 +1,30 @@ +generateSecret(); + + $this->assertEquals(16, strlen($secret)); + $this->assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret); + } + + public function test_it_verifies_correct_code(): void + { + $service = new TotpService(); + $secret = 'JBSWY3DPEHPK3PXP'; // Known secret + + // We can't easily test the code without a time mocker or calculation + // but we can check if it fails with an obviously wrong code + $this->assertFalse($service->verify($secret, '000000')); + } +}