From 7cd2d9157616acf9982e5cdd780153859e436ba5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 3 May 2026 15:11:34 +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=2015?= =?UTF-8?q?:11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 +- .gitignore | 8 + app/Middleware/AuthMiddleware.php | 16 + app/Modules/Admin/AdminController.php | 134 ++- app/Modules/ApiKeys/ApiKeyController.php | 33 +- app/Modules/Auth/AuthService.php | 10 + app/Modules/Dashboard/DashboardController.php | 52 +- app/Modules/Invoices/InvoiceController.php | 224 +++- app/Modules/Users/UserController.php | 75 -- app/Modules/Users/UsersController.php | 154 +++ app/Services/AuditService.php | 43 +- app/Services/JoFotara/UBLGeneratorService.php | 263 +++-- app/Services/Security/EncryptionService.php | 58 +- app/Services/Security/HmacService.php | 44 +- app/Services/TaxValidationService.php | 17 +- config/secrets.php | 14 +- database/migrations/005_notifications.sql | 12 + database/seed.sql | 26 - public/index.php | 57 +- public/shell.php | 966 ++++++++++-------- queue/Jobs/ExtractInvoiceJob.php | 49 +- queue/Jobs/RiskAnalysisJob.php | 33 +- queue/worker.php | 4 + 23 files changed, 1418 insertions(+), 879 deletions(-) delete mode 100644 app/Modules/Users/UserController.php create mode 100644 app/Modules/Users/UsersController.php create mode 100644 database/migrations/005_notifications.sql delete mode 100644 database/seed.sql diff --git a/.env b/.env index d1975fe..b8efb4d 100644 --- a/.env +++ b/.env @@ -17,7 +17,7 @@ REDIS_PORT=6379 REDIS_PASSWORD= # JWT -JWT_SECRET=super-secret-change-me-in-production +JWT_SECRET=d1351a319dd1843036095c632daee0a44f620355e3b1407cade0614fdcbd7c4c JWT_ACCESS_EXPIRY=900 JWT_REFRESH_EXPIRY=604800 @@ -42,3 +42,6 @@ MAIL_FROM_NAME="مُصادَق" # Storage STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage UPLOAD_MAX_SIZE=20971520 + +# Encryption +ENCRYPTION_KEY_B64=b++0FeJhnogqslt5OnOq633gduJzDb3itankz/UH++E= diff --git a/.gitignore b/.gitignore index e69de29..66e06b1 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +config/secrets.php +storage/invoices/ +storage/logs/ +storage/exports/ +vendor/ +scratch.js +describe.php diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php index cf9da3e..3eb4aee 100644 --- a/app/Middleware/AuthMiddleware.php +++ b/app/Middleware/AuthMiddleware.php @@ -25,6 +25,22 @@ final class AuthMiddleware try { $decoded = $this->jwtService->verifyToken($token); + + // Check if JTI is blacklisted + $jti = $decoded['jti'] ?? null; + if ($jti) { + try { + $redis = \App\Core\Redis::getInstance(); + if ($redis->exists('jwt_blacklist:' . $jti)) { + Response::error('الجلسة منتهية، يرجى تسجيل الدخول من جديد', 'TOKEN_REVOKED', 401); + return null; + } + } catch (\Throwable $e) { + // Redis down — allow (fail open, log security event) + error_log('[AUTH] JWT blacklist check failed: ' . $e->getMessage()); + } + } + $request->user = (object) $decoded; $request->tenantId = $decoded['tenant_id'] ?? null; } catch (Exception $e) { diff --git a/app/Modules/Admin/AdminController.php b/app/Modules/Admin/AdminController.php index b80f473..2e98ca8 100644 --- a/app/Modules/Admin/AdminController.php +++ b/app/Modules/Admin/AdminController.php @@ -1,49 +1,131 @@ user->role ?? '') !== 'super_admin') { - Response::error('غير مصرح', 'FORBIDDEN', 403); + $db = Database::getInstance(); + $page = max(1, (int)$request->input('page', 1)); + $limit = 20; + $offset = ($page - 1) * $limit; + + $stmt = $db->prepare("SELECT t.*, + (SELECT COUNT(*) FROM invoices i WHERE i.tenant_id = t.id) as invoices_count, + (SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) as users_count + FROM tenants t ORDER BY t.created_at DESC LIMIT {$limit} OFFSET {$offset}"); + $stmt->execute(); + $tenants = $stmt->fetchAll(); + + $total = (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn(); + + Response::json([ + 'success' => true, + 'data' => $tenants, + 'meta' => ['total' => $total, 'page' => $page, 'per_page' => $limit] + ]); + } + + public function getTenant(Request $request, string $id): void + { + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT t.*, s.plan, s.max_invoices_per_month, s.invoices_used_this_month + FROM tenants t + LEFT JOIN subscriptions s ON t.id = s.tenant_id + WHERE t.id = ?"); + $stmt->execute([$id]); + $tenant = $stmt->fetch(); + + if (!$tenant) { + Response::error('المستأجر غير موجود', 'NOT_FOUND', 404); + return; + } + + Response::json(['success' => true, 'data' => $tenant]); + } + + public function updateTenant(Request $request, string $id): void + { + $data = $request->getBody(); + $status = $data['status'] ?? null; + + if (!in_array($status, ['active', 'suspended', 'trial'])) { + Response::error('حالة غير صالحة', 'VALIDATION_ERROR', 422); return; } + $db = Database::getInstance(); + $stmt = $db->prepare("UPDATE tenants SET status = ? WHERE id = ?"); + $stmt->execute([$status, $id]); + + Response::json(['success' => true, 'message' => 'تم تحديث حالة المستأجر بنجاح']); + } + + public function getSystemStats(Request $request): void + { $db = Database::getInstance(); - $stmt = $db->prepare("SELECT COUNT(*) as count FROM tenants"); - $stmt->execute(); - $totalTenants = $stmt->fetch()['count']; + $stats = [ + 'tenants' => (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn(), + 'invoices' => (int)$db->query("SELECT COUNT(*) FROM invoices")->fetchColumn(), + 'users' => (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn(), + 'queue_depth' => (int)$db->query("SELECT COUNT(*) FROM queue_jobs WHERE status = 'pending'")->fetchColumn() + ]; - $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices"); - $stmt->execute(); - $totalInvoices = $stmt->fetch()['count']; + Response::json(['success' => true, 'data' => $stats]); + } - // Simple Health Check - $redisHealth = 'ok'; - try { - $redis = \App\Core\Redis::getInstance(); - $redis->ping(); - } catch (\Throwable $e) { - $redisHealth = 'failed'; - } + public function getQueueStatus(Request $request): void + { + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM queue_jobs GROUP BY status"); + $stmt->execute(); + $counts = $stmt->fetchAll(); + + $stmt = $db->prepare("SELECT * FROM queue_jobs WHERE status IN ('failed', 'dead') ORDER BY created_at DESC LIMIT 50"); + $stmt->execute(); + $failedJobs = $stmt->fetchAll(); + + Response::json([ + 'success' => true, + 'data' => [ + 'counts' => $counts, + 'failed_jobs' => $failedJobs + ] + ]); + } + + public function retryJob(Request $request, string $id): void + { + $db = Database::getInstance(); + $stmt = $db->prepare("UPDATE queue_jobs SET status = 'pending', attempts = 0 WHERE id = ? AND status = 'dead'"); + $stmt->execute([$id]); + + Response::json(['success' => true, 'message' => 'تم إعادة محاولة الوظيفة بنجاح']); + } + + public function health(Request $request): void + { + $dbStatus = 'ok'; + try { Database::getInstance()->query("SELECT 1"); } catch (\Throwable $e) { $dbStatus = 'error'; } + + $redisStatus = 'ok'; + try { \App\Core\Redis::getInstance()->ping(); } catch (\Throwable $e) { $redisStatus = 'error'; } + + $db = Database::getInstance(); + $queuePending = (int)$db->query("SELECT COUNT(*) FROM queue_jobs WHERE status = 'pending'")->fetchColumn(); + $queueDead = (int)$db->query("SELECT COUNT(*) FROM queue_jobs WHERE status = 'dead'")->fetchColumn(); Response::json([ 'success' => true, 'data' => [ - 'total_tenants' => $totalTenants, - 'total_invoices' => $totalInvoices, - 'system_health' => [ - 'database' => 'ok', - 'redis' => $redisHealth - ] + 'db' => $dbStatus, + 'redis' => $redisStatus, + 'queue_pending' => $queuePending, + 'queue_dead' => $queueDead ] ]); } diff --git a/app/Modules/ApiKeys/ApiKeyController.php b/app/Modules/ApiKeys/ApiKeyController.php index 73feb46..fd69f97 100644 --- a/app/Modules/ApiKeys/ApiKeyController.php +++ b/app/Modules/ApiKeys/ApiKeyController.php @@ -1,7 +1,5 @@ tenantId; $db = Database::getInstance(); @@ -25,18 +23,13 @@ final class ApiKeyController public function create(Request $request): void { $tenantId = $request->tenantId; - $userId = $request->user->user_id; - $name = $request->input('name'); - - if (!$name) { - Response::error('يرجى إدخال اسم المفتاح', 'VALIDATION_ERROR', 422); - return; - } + $userId = $request->user->user_id ?? $request->user->id; + $name = $request->input('name') ?: 'Default Key'; $id = Uuid::uuid4()->toString(); $publicKey = bin2hex(random_bytes(16)); - $secretKey = bin2hex(random_bytes(32)); - $secretHash = password_hash($secretKey, PASSWORD_BCRYPT); + $secret = bin2hex(random_bytes(32)); + $secretHash = password_hash($secret, PASSWORD_BCRYPT); $db = Database::getInstance(); $stmt = $db->prepare("INSERT INTO api_keys (id, tenant_id, user_id, name, public_key, secret_hash, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)"); @@ -47,8 +40,22 @@ final class ApiKeyController 'message' => 'تم إنشاء مفتاح API بنجاح. يرجى حفظ السر لأنه لن يظهر مرة أخرى.', 'data' => [ 'id' => $id, - 'key' => "msq_{$publicKey}.{$secretKey}" + 'public_key' => $publicKey, + 'secret' => $secret ] ], 201); } + + public function revoke(Request $request, string $id): void + { + $tenantId = $request->tenantId; + $db = Database::getInstance(); + $stmt = $db->prepare("UPDATE api_keys SET is_active = 0 WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$id, $tenantId]); + + Response::json([ + 'success' => true, + 'message' => 'تم إلغاء مفتاح API بنجاح' + ]); + } } diff --git a/app/Modules/Auth/AuthService.php b/app/Modules/Auth/AuthService.php index 6e8c823..e3121df 100644 --- a/app/Modules/Auth/AuthService.php +++ b/app/Modules/Auth/AuthService.php @@ -144,4 +144,14 @@ final class AuthService return $this->login($data['email'], $data['password']); } + public function logout(string $jti, int $remaining): void + { + // Blacklist the JTI for its remaining lifetime + try { + $redis = \App\Core\Redis::getInstance(); + $redis->setex('jwt_blacklist:' . $jti, max($remaining, 1), '1'); + } catch (\Throwable $e) { + error_log('[AUTH] Could not blacklist JTI: ' . $e->getMessage()); + } + } } diff --git a/app/Modules/Dashboard/DashboardController.php b/app/Modules/Dashboard/DashboardController.php index cb41827..8e2a556 100644 --- a/app/Modules/Dashboard/DashboardController.php +++ b/app/Modules/Dashboard/DashboardController.php @@ -1,7 +1,5 @@ user->assigned_company_id ?? null; $db = Database::getInstance(); - $where = "WHERE tenant_id = ?"; + $companyScope = ''; $params = [$tenantId]; - - // Fix: Only accountants should be restricted to a single company if assigned. - // Admins and Super Admins should see all companies in their tenant. if ($role === 'accountant' && $assignedCompanyId) { - $where .= " AND company_id = ?"; + $companyScope = ' AND i.company_id = ?'; $params[] = $assignedCompanyId; } - // 1. Total Invoices this month - $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices {$where} AND MONTH(created_at) = MONTH(CURRENT_DATE)"); + // Total this month + $stmt = $db->prepare("SELECT COUNT(*) FROM invoices i + WHERE i.tenant_id = ? {$companyScope} AND MONTH(i.created_at) = MONTH(CURDATE()) AND YEAR(i.created_at) = YEAR(CURDATE()) AND i.deleted_at IS NULL"); $stmt->execute($params); - $thisMonth = (int) $stmt->fetch()['count']; + $thisMonth = (int)$stmt->fetchColumn(); - // 2. Approved vs Rejected - $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices {$where} GROUP BY status"); + // Status distribution + $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices i + WHERE i.tenant_id = ? {$companyScope} AND i.deleted_at IS NULL GROUP BY status"); $stmt->execute($params); - $statusCounts = $stmt->fetchAll(); + $statusDistribution = $stmt->fetchAll(); - // 3. Recent Activity - Fixed ambiguity - $stmt = $db->prepare("SELECT i.*, c.name as company_name FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.tenant_id = ? " . ($role === 'accountant' && $assignedCompanyId ? " AND i.company_id = ?" : "") . " ORDER BY i.created_at DESC LIMIT 5"); + // Subscription usage + $stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + $usagePct = $sub && $sub['max_invoices_per_month'] > 0 + ? round(($sub['invoices_used_this_month'] / $sub['max_invoices_per_month']) * 100) + : 0; + + // Recent invoices + $stmt = $db->prepare("SELECT i.id, i.invoice_number, i.invoice_date, i.grand_total, i.status, i.created_at, c.name as company_name, i.ai_confidence_score + FROM invoices i + JOIN companies c ON i.company_id = c.id + WHERE i.tenant_id = ? {$companyScope} AND i.deleted_at IS NULL + ORDER BY i.created_at DESC LIMIT 10"); $stmt->execute($params); $recent = $stmt->fetchAll(); - // 4. Calculate Subscription Usage - $stmt = $db->prepare("SELECT max_invoices_per_month FROM subscriptions WHERE tenant_id = ?"); + // Pending extraction (from queue) + $stmt = $db->prepare("SELECT COUNT(*) FROM queue_jobs WHERE tenant_id = ? AND status = 'pending' AND job_type = 'ExtractInvoiceJob'"); $stmt->execute([$tenantId]); - $sub = $stmt->fetch(); - $maxInvoices = (int) ($sub['max_invoices_per_month'] ?? 100); - $usage = $maxInvoices > 0 ? round(($thisMonth / $maxInvoices) * 100, 1) : 0; + $pendingExtraction = (int)$stmt->fetchColumn(); Response::json([ 'success' => true, 'data' => [ 'total_this_month' => $thisMonth, - 'status_distribution' => $statusCounts, + 'subscription_usage' => $usagePct, + 'status_distribution' => $statusDistribution, 'recent_invoices' => $recent, - 'subscription_usage' => $usage + 'pending_extraction' => $pendingExtraction ] ]); } diff --git a/app/Modules/Invoices/InvoiceController.php b/app/Modules/Invoices/InvoiceController.php index ac91e34..1606737 100644 --- a/app/Modules/Invoices/InvoiceController.php +++ b/app/Modules/Invoices/InvoiceController.php @@ -1,19 +1,9 @@ execute($request->tenantId, $request->user); - Response::json(['success' => true, 'data' => $invoices]); - } catch (Throwable $e) { - Response::error($e->getMessage(), 'LIST_ERROR', (int)($e->getCode() ?: 500)); + $tenantId = $request->tenantId; + $role = $request->user->role ?? 'viewer'; + $assignedCompanyId = $request->user->assigned_company_id ?? null; + $db = Database::getInstance(); + + $page = max(1, (int)$request->input('page', 1)); + $limit = min(50, max(10, (int)$request->input('per_page', 20))); + $offset = ($page - 1) * $limit; + + $companyFilter = $request->input('company_id'); + $statusFilter = $request->input('status'); + $dateFrom = $request->input('date_from'); + $dateTo = $request->input('date_to'); + + $where = 'WHERE i.tenant_id = ? AND i.deleted_at IS NULL'; + $params = [$tenantId]; + + if ($role === 'accountant' && $assignedCompanyId) { + $where .= ' AND i.company_id = ?'; + $params[] = $assignedCompanyId; + } elseif ($companyFilter) { + $where .= ' AND i.company_id = ?'; + $params[] = $companyFilter; } + if ($statusFilter) { $where .= ' AND i.status = ?'; $params[] = $statusFilter; } + if ($dateFrom) { $where .= ' AND i.invoice_date >= ?'; $params[] = $dateFrom; } + if ($dateTo) { $where .= ' AND i.invoice_date <= ?'; $params[] = $dateTo; } + + $stmt = $db->prepare("SELECT COUNT(*) FROM invoices i {$where}"); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + $stmt = $db->prepare("SELECT i.id, i.invoice_number, i.invoice_date, i.grand_total, i.tax_amount, + i.status, i.ai_confidence_score, i.created_at, c.name as company_name + FROM invoices i JOIN companies c ON i.company_id = c.id + {$where} ORDER BY i.created_at DESC LIMIT {$limit} OFFSET {$offset}"); + $stmt->execute($params); + $invoices = $stmt->fetchAll(); + + Response::json([ + 'success' => true, + 'data' => $invoices, + 'meta' => ['total' => $total, 'page' => $page, 'per_page' => $limit, 'last_page' => ceil($total / $limit)] + ]); } public function upload(Request $request): void { try { - $action = new UploadInvoiceAction($this->storage, $this->invoiceModel); - $invoiceId = $action->execute( - $request->getFiles(), - (string)$request->input('company_id'), - $request->tenantId, - $request->user - ); + $files = $request->getFiles(); + if (empty($files['file'])) { + throw new \Exception('يرجى اختيار ملف للفاتورة'); + } + + $companyId = (string)$request->input('company_id'); + if (empty($companyId)) { + throw new \Exception('يرجى اختيار الشركة'); + } + + $file = $files['file']; + $invoiceId = \Ramsey\Uuid\Uuid::uuid4()->toString(); + + // Store file + $path = $this->storage->store($file, "invoices/{$request->tenantId}/{$invoiceId}"); + + // Create record + $this->invoiceModel->create([ + 'id' => $invoiceId, + 'tenant_id' => $request->tenantId, + 'company_id' => $companyId, + 'original_file_path' => $path, + 'status' => 'uploaded' + ]); + + // Queue extraction and risk analysis + \App\Services\QueueService::push(\queue\Jobs\ExtractInvoiceJob::class, ['invoice_id' => $invoiceId]); Response::json([ 'success' => true, @@ -55,41 +102,124 @@ final class InvoiceController } } - public function detail(Request $request, string $id): void + public function show(Request $request, string $id): void { - try { - $action = new GetInvoiceDetailAction(); - $invoice = $action->execute($id, $request->tenantId, $request->user); - Response::json(['success' => true, 'data' => $invoice]); - } catch (Throwable $e) { - Response::error($e->getMessage(), 'DETAIL_ERROR', (int)($e->getCode() ?: 500)); + $tenantId = $request->tenantId; + $db = Database::getInstance(); + + // Fetch invoice with company name (tenant-scoped) + $stmt = $db->prepare("SELECT i.*, c.name as company_name, c.tax_identification_number as company_tin + FROM invoices i + JOIN companies c ON i.company_id = c.id + WHERE i.id = ? AND i.tenant_id = ? AND i.deleted_at IS NULL"); + $stmt->execute([$id, $tenantId]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + Response::error('الفاتورة غير موجودة', 'NOT_FOUND', 404); + return; } + + // Fetch lines + $stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY line_number ASC"); + $stmt->execute([$id]); + $invoice['lines'] = $stmt->fetchAll(); + + // Parse JSON fields + if ($invoice['validation_errors']) { + $invoice['validation_errors'] = json_decode($invoice['validation_errors'], true); + } + if ($invoice['jofotara_response']) { + $invoice['jofotara_response'] = json_decode($invoice['jofotara_response'], true); + } + + Response::json(['success' => true, 'data' => $invoice]); } public function submit(Request $request, string $id): void { try { - $action = new SubmitInvoiceAction(); - $action->execute($id, $request->tenantId); - Response::json(['success' => true, 'message' => 'Invoice submission queued.']); + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT status FROM invoices WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$id, $request->tenantId]); + $invoice = $stmt->fetch(); + + if (!$invoice) { + Response::error('الفاتورة غير موجودة', 'NOT_FOUND', 404); + return; + } + + // Update status to submitting + $this->invoiceModel->update($id, ['status' => 'submitting']); + + // Queue JoFotara submission + \App\Services\QueueService::push(\queue\Jobs\SubmitJoFotaraJob::class, ['invoice_id' => $id]); + + Response::json(['success' => true, 'message' => 'جاري إرسال الفاتورة لنظام فوترة...']); } catch (Throwable $e) { Response::error($e->getMessage(), 'SUBMIT_ERROR', (int)($e->getCode() ?: 500)); } } - public function downloadFile(Request $request, string $id): void + public function serveFile(Request $request, string $id): void { - try { - $action = new DownloadInvoiceFileAction(); - $file = $action->execute($id, $request->tenantId, $request->user); - - header("Content-Type: {$file['mime']}"); - header("Content-Disposition: inline; filename=\"{$file['name']}\""); - header("Content-Length: " . filesize($file['path'])); - readfile($file['path']); - exit; - } catch (Throwable $e) { - Response::error($e->getMessage(), 'DOWNLOAD_ERROR', (int)($e->getCode() ?: 500)); + $tenantId = $request->tenantId; + $db = Database::getInstance(); + + $stmt = $db->prepare("SELECT original_file_path FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL"); + $stmt->execute([$id, $tenantId]); + $invoice = $stmt->fetch(); + + if (!$invoice || !$invoice['original_file_path']) { + Response::error('الملف غير موجود', 'NOT_FOUND', 404); + return; } + + $filePath = $invoice['original_file_path']; + + if (!file_exists($filePath)) { + Response::error('الملف غير موجود على الخادم', 'FILE_NOT_FOUND', 404); + return; + } + + // Validate path is within storage directory (security) + $storagePath = realpath($_ENV['STORAGE_PATH'] ?? '/home/intaleqapp-musadeq/storage'); + $realPath = realpath($filePath); + if (!$realPath || !str_starts_with($realPath, $storagePath)) { + Response::error('وصول غير مصرح', 'FORBIDDEN', 403); + return; + } + + $mimeType = mime_content_type($filePath); + $filename = basename($filePath); + + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . filesize($filePath)); + header('Content-Disposition: inline; filename="' . $filename . '"'); + header('X-Content-Type-Options: nosniff'); + readfile($filePath); + exit; + } + + public function status(Request $request, string $id): void + { + $stmt = Database::getInstance()->prepare("SELECT id, status, ai_confidence_score, validation_errors FROM invoices WHERE id = ? AND tenant_id = ?"); + $stmt->execute([$id, $request->tenantId]); + $invoice = $stmt->fetch(); + Response::json(['success' => true, 'data' => $invoice]); + } + + public function update(Request $request, string $id): void + { + // Implementation for PUT /api/v1/invoices/{id} + $data = $request->getBody(); + $this->invoiceModel->update($id, $data); + Response::json(['success' => true, 'message' => 'تم تحديث الفاتورة بنجاح']); + } + + public function destroy(Request $request, string $id): void + { + $this->invoiceModel->delete($id); + Response::json(['success' => true, 'message' => 'تم حذف الفاتورة بنجاح']); } } diff --git a/app/Modules/Users/UserController.php b/app/Modules/Users/UserController.php deleted file mode 100644 index 5cd3282..0000000 --- a/app/Modules/Users/UserController.php +++ /dev/null @@ -1,75 +0,0 @@ -tenantId; - $users = $this->userModel->findAllByTenant($tenantId); - - Response::json([ - 'success' => true, - 'data' => $users - ]); - } - - public function detail(Request $request, string $id): void - { - $tenantId = $request->tenantId; - $user = $this->userModel->findById($id, $tenantId); - - if (!$user) { - Response::error('المستخدم غير موجود', 'NOT_FOUND', 404); - return; - } - - Response::json([ - '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/Modules/Users/UsersController.php b/app/Modules/Users/UsersController.php new file mode 100644 index 0000000..d1ab647 --- /dev/null +++ b/app/Modules/Users/UsersController.php @@ -0,0 +1,154 @@ +tenantId; + $users = $this->userModel->findAllByTenant($tenantId); + + Response::json([ + 'success' => true, + 'data' => $users + ]); + } + + 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 = 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); + } + + public function update(Request $request, string $id): void + { + $tenantId = $request->tenantId; + $user = $this->userModel->findById($id, $tenantId); + if (!$user) { + Response::error('المستخدم غير موجود', 'NOT_FOUND', 404); + return; + } + + $data = $request->getBody(); + $updateData = []; + if (isset($data['name'])) $updateData['name'] = $data['name']; + if (isset($data['role'])) $updateData['role'] = $data['role']; + if (isset($data['is_active'])) $updateData['is_active'] = $data['is_active']; + if (isset($data['assigned_company_id'])) $updateData['assigned_company_id'] = $data['assigned_company_id']; + + if (empty($updateData)) { + Response::error('لا توجد بيانات لتحديثها', 'VALIDATION_ERROR', 422); + return; + } + + $this->userModel->update($id, $updateData); + + Response::json([ + 'success' => true, + 'message' => 'تم تحديث بيانات المستخدم بنجاح' + ]); + } + + public function destroy(Request $request, string $id): void + { + $tenantId = $request->tenantId; + $user = $this->userModel->findById($id, $tenantId); + if (!$user) { + Response::error('المستخدم غير موجود', 'NOT_FOUND', 404); + return; + } + + if ($user['id'] === $request->user->user_id) { + Response::error('لا يمكنك حذف حسابك الشخصي', 'FORBIDDEN', 403); + return; + } + + $this->userModel->delete($id); + + Response::json([ + 'success' => true, + 'message' => 'تم حذف المستخدم بنجاح' + ]); + } + + public function updateProfile(Request $request): void + { + $userId = $request->user->user_id; + $data = $request->getBody(); + + if (empty($data['name'])) { + Response::error('الاسم مطلوب', 'VALIDATION_ERROR', 422); + return; + } + + $this->userModel->update($userId, [ + 'name' => $data['name'] + ]); + + Response::json([ + 'success' => true, + 'message' => 'تم تحديث الملف الشخصي بنجاح' + ]); + } + + public function changePassword(Request $request): void + { + $userId = $request->user->user_id; + $data = $request->getBody(); + + if (empty($data['current_password']) || empty($data['new_password'])) { + Response::error('كلمة المرور الحالية والجديدة مطلوبة', 'VALIDATION_ERROR', 422); + return; + } + + $user = $this->userModel->find($userId); + if (!password_verify($data['current_password'], $user['password_hash'])) { + Response::error('كلمة المرور الحالية غير صحيحة', 'UNAUTHORIZED', 401); + return; + } + + $this->userModel->update($userId, [ + 'password_hash' => password_hash($data['new_password'], PASSWORD_ARGON2ID) + ]); + + Response::json([ + 'success' => true, + 'message' => 'تم تغيير كلمة المرور بنجاح' + ]); + } +} diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php index 329d48d..86b72f0 100644 --- a/app/Services/AuditService.php +++ b/app/Services/AuditService.php @@ -1,7 +1,5 @@ prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); - - // This would be populated from the global Request context - $tenantId = $GLOBALS['current_tenant_id'] ?? null; - $userId = $GLOBALS['current_user_id'] ?? null; - - $stmt->execute([ - $tenantId, - $userId, - $action, - $entityType, - $entityId, - $oldData ? json_encode($oldData) : null, - $newData ? json_encode($newData) : null, - $_SERVER['REMOTE_ADDR'] ?? null, - $_SERVER['HTTP_USER_AGENT'] ?? null, - $metadata ? json_encode($metadata) : null - ]); + try { + $db = Database::getInstance(); + $stmt = $db->prepare("INSERT INTO audit_logs + (tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"); + $stmt->execute([ + $tenantId, + $userId, + $action, + $entityType, + $entityId, + $oldData ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null, + $newData ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null, + $_SERVER['REMOTE_ADDR'] ?? null, + $_SERVER['HTTP_USER_AGENT'] ?? null, + $metadata ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null, + ]); + } catch (\Throwable $e) { + error_log('[Audit] Failed: ' . $e->getMessage()); + } } } diff --git a/app/Services/JoFotara/UBLGeneratorService.php b/app/Services/JoFotara/UBLGeneratorService.php index 569e677..52bad36 100644 --- a/app/Services/JoFotara/UBLGeneratorService.php +++ b/app/Services/JoFotara/UBLGeneratorService.php @@ -1,112 +1,201 @@ '); + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $root = $dom->createElementNS('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', 'Invoice'); + $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cac', 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'); + $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'); + $dom->appendChild($root); // 1. Basic Information - $xml->addChild('cbc:UBLVersionID', '2.1'); - $xml->addChild('cbc:CustomizationID', 'TRADACO-2.1'); - $xml->addChild('cbc:ProfileID', 'reporting:1.0'); - $xml->addChild('cbc:ID', $invoice['invoice_number']); - $xml->addChild('cbc:IssueDate', $invoice['invoice_date']); - $xml->addChild('cbc:InvoiceTypeCode', $invoice['ubl_type_code'] ?? '388')->addAttribute('name', $invoice['invoice_category'] ?? '01'); - $xml->addChild('cbc:DocumentCurrencyCode', 'JOD'); - $xml->addChild('cbc:TaxCurrencyCode', 'JOD'); - - // 2. AccountingSupplierParty (The Seller/Company) - $supplier = $xml->addChild('cac:AccountingSupplierParty'); - $sParty = $supplier->addChild('cac:Party'); - $sParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $company['tax_identification_number'])->addAttribute('schemeID', 'TN'); - $sName = $sParty->addChild('cac:PartyName'); - $sName->addChild('cbc:Name', $company['name']); + $root->appendChild($dom->createElement('cbc:UBLVersionID', '2.1')); + $root->appendChild($dom->createElement('cbc:CustomizationID', 'TRADACO-2.1')); + $root->appendChild($dom->createElement('cbc:ProfileID', 'reporting:1.0')); + $root->appendChild($dom->createElement('cbc:ID', $invoice['invoice_number'])); + $root->appendChild($dom->createElement('cbc:IssueDate', $invoice['invoice_date'])); - $sAddr = $sParty->addChild('cac:PostalAddress'); - $sAddr->addChild('cbc:CityName', $company['city'] ?? 'Amman'); - $sAddr->addChild('cac:Country')->addChild('cbc:IdentificationCode', 'JO'); - - $sTaxScheme = $sParty->addChild('cac:PartyTaxScheme'); - $sTaxScheme->addChild('cbc:RegistrationName', $company['name']); - $sTaxScheme->addChild('cbc:CompanyID', $company['tax_identification_number']); - $sTaxScheme->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT'); - - $sLegalEntity = $sParty->addChild('cac:PartyLegalEntity'); - $sLegalEntity->addChild('cbc:RegistrationName', $company['name']); - $sLegalEntity->addChild('cbc:CompanyID', $company['tax_identification_number']); - - // 3. AccountingCustomerParty (The Buyer) - $customer = $xml->addChild('cac:AccountingCustomerParty'); - $cParty = $customer->addChild('cac:Party'); + $typeCode = $dom->createElement('cbc:InvoiceTypeCode', $invoice['ubl_type_code'] ?? '388'); + $typeCode->setAttribute('name', $invoice['invoice_category'] ?? '01'); + $root->appendChild($typeCode); - if (!empty($invoice['buyer_tin'])) { - $cParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $invoice['buyer_tin'])->addAttribute('schemeID', 'TN'); - } elseif (!empty($invoice['buyer_national_id'])) { - $cParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $invoice['buyer_national_id'])->addAttribute('schemeID', 'NID'); + $root->appendChild($dom->createElement('cbc:DocumentCurrencyCode', 'JOD')); + $root->appendChild($dom->createElement('cbc:TaxCurrencyCode', 'JOD')); + + // 2. AccountingSupplierParty + $supplierParty = $dom->createElement('cac:AccountingSupplierParty'); + $party = $dom->createElement('cac:Party'); + + $partyId = $dom->createElement('cac:PartyIdentification'); + $id = $dom->createElement('cbc:ID', $company['tax_identification_number']); + $id->setAttribute('schemeID', 'TN'); + $partyId->appendChild($id); + $party->appendChild($partyId); + + $partyName = $dom->createElement('cac:PartyName'); + $partyName->appendChild($dom->createElement('cbc:Name', $company['name'])); + $party->appendChild($partyName); + + $postalAddr = $dom->createElement('cac:PostalAddress'); + $postalAddr->appendChild($dom->createElement('cbc:CityName', $company['city'] ?? 'Amman')); + $country = $dom->createElement('cac:Country'); + $country->appendChild($dom->createElement('cbc:IdentificationCode', 'JO')); + $postalAddr->appendChild($country); + $party->appendChild($postalAddr); + + $taxScheme = $dom->createElement('cac:PartyTaxScheme'); + $taxScheme->appendChild($dom->createElement('cbc:RegistrationName', $company['name'])); + $taxScheme->appendChild($dom->createElement('cbc:CompanyID', $company['tax_identification_number'])); + $ts = $dom->createElement('cac:TaxScheme'); + $ts->appendChild($dom->createElement('cbc:ID', 'VAT')); + $taxScheme->appendChild($ts); + $party->appendChild($taxScheme); + + $legalEntity = $dom->createElement('cac:PartyLegalEntity'); + $legalEntity->appendChild($dom->createElement('cbc:RegistrationName', $company['name'])); + $legalEntity->appendChild($dom->createElement('cbc:CompanyID', $company['tax_identification_number'])); + $party->appendChild($legalEntity); + + $supplierParty->appendChild($party); + $root->appendChild($supplierParty); + + // 3. AccountingCustomerParty + $customerParty = $dom->createElement('cac:AccountingCustomerParty'); + $cparty = $dom->createElement('cac:Party'); + + if (!empty($invoice['buyer_tin']) || !empty($invoice['buyer_national_id'])) { + $cpartyId = $dom->createElement('cac:PartyIdentification'); + $cid = $dom->createElement('cbc:ID', $invoice['buyer_tin'] ?: $invoice['buyer_national_id']); + $cid->setAttribute('schemeID', $invoice['buyer_tin'] ? 'TN' : 'NID'); + $cpartyId->appendChild($cid); + $cparty->appendChild($cpartyId); } - $cName = $cParty->addChild('cac:PartyName'); - $cName->addChild('cbc:Name', $invoice['buyer_name'] ?? 'General Customer'); - - $cTaxScheme = $cParty->addChild('cac:PartyTaxScheme'); - $cTaxScheme->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT'); + $cpartyName = $dom->createElement('cac:PartyName'); + $cpartyName->appendChild($dom->createElement('cbc:Name', $invoice['buyer_name'] ?? 'General Customer')); + $cparty->appendChild($cpartyName); + + $ctaxScheme = $dom->createElement('cac:PartyTaxScheme'); + $cts = $dom->createElement('cac:TaxScheme'); + $cts->appendChild($dom->createElement('cbc:ID', 'VAT')); + $ctaxScheme->appendChild($cts); + $cparty->appendChild($ctaxScheme); + + $customerParty->appendChild($cparty); + $root->appendChild($customerParty); // 4. PaymentMeans - $payment = $xml->addChild('cac:PaymentMeans'); - $payment->addChild('cbc:PaymentMeansCode', $invoice['payment_method_code'] ?? '10'); + $paymentMeans = $dom->createElement('cac:PaymentMeans'); + $paymentMeans->appendChild($dom->createElement('cbc:PaymentMeansCode', $invoice['payment_method_code'] ?? '013')); + $root->appendChild($paymentMeans); // 5. TaxTotal - $taxTotal = $xml->addChild('cac:TaxTotal'); - $taxTotal->addChild('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); + $taxTotal = $dom->createElement('cac:TaxTotal'); + $taxAmt = $dom->createElement('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', '')); + $taxAmt->setAttribute('currencyID', 'JOD'); + $taxTotal->appendChild($taxAmt); - $taxSubtotal = $taxTotal->addChild('cac:TaxSubtotal'); - $taxSubtotal->addChild('cbc:TaxableAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $taxSubtotal->addChild('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $taxCategory = $taxSubtotal->addChild('cac:TaxCategory'); - $taxCategory->addChild('cbc:ID', 'S'); - $taxCategory->addChild('cbc:Percent', '16.00'); // Default Jordan VAT - $taxCategory->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT'); - - // 6. LegalMonetaryTotal - $legalMonetaryTotal = $xml->addChild('cac:LegalMonetaryTotal'); - $legalMonetaryTotal->addChild('cbc:LineExtensionAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $legalMonetaryTotal->addChild('cbc:TaxExclusiveAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $legalMonetaryTotal->addChild('cbc:TaxInclusiveAmount', number_format((float)$invoice['grand_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $legalMonetaryTotal->addChild('cbc:AllowanceTotalAmount', number_format((float)($invoice['discount_total'] ?? 0), 3, '.', ''))->addAttribute('currencyID', 'JOD'); - $legalMonetaryTotal->addChild('cbc:PayableAmount', number_format((float)$invoice['grand_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - - // 7. Invoice Lines + // Group lines by tax rate + $taxGroups = []; foreach ($lines as $line) { - $invoiceLine = $xml->addChild('cac:InvoiceLine'); - $invoiceLine->addChild('cbc:ID', (string)$line['line_number']); - $invoiceLine->addChild('cbc:InvoicedQuantity', number_format((float)$line['quantity'], 3, '.', ''))->addAttribute('unitCode', 'PCE'); - $invoiceLine->addChild('cbc:LineExtensionAmount', number_format((float)$line['line_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); - - $item = $invoiceLine->addChild('cac:Item'); - $item->addChild('cbc:Description', $line['description']); - $itemTaxCategory = $item->addChild('cac:TaxCategory'); - $itemTaxCategory->addChild('cbc:ID', 'S'); - $itemTaxCategory->addChild('cbc:Percent', '16.00'); - $itemTaxCategory->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT'); - - $price = $invoiceLine->addChild('cac:Price'); - $price->addChild('cbc:PriceAmount', number_format((float)$line['unit_price'], 3, '.', ''))->addAttribute('currencyID', 'JOD'); + $rate = number_format((float)$line['tax_rate'], 2, '.', ''); + if (!isset($taxGroups[$rate])) { + $taxGroups[$rate] = ['taxable' => 0, 'tax' => 0]; + } + $taxGroups[$rate]['taxable'] += ($line['quantity'] * $line['unit_price']) - $line['discount']; + $taxGroups[$rate]['tax'] += $line['tax_amount']; + } + + foreach ($taxGroups as $rate => $data) { + $taxSubtotal = $dom->createElement('cac:TaxSubtotal'); + + $subtaxable = $dom->createElement('cbc:TaxableAmount', number_format($data['taxable'], 3, '.', '')); + $subtaxable->setAttribute('currencyID', 'JOD'); + $taxSubtotal->appendChild($subtaxable); + + $subtaxamt = $dom->createElement('cbc:TaxAmount', number_format($data['tax'], 3, '.', '')); + $subtaxamt->setAttribute('currencyID', 'JOD'); + $taxSubtotal->appendChild($subtaxamt); + + $taxCategory = $dom->createElement('cac:TaxCategory'); + $taxCategory->appendChild($dom->createElement('cbc:ID', 'S')); + $taxCategory->appendChild($dom->createElement('cbc:Percent', number_format((float)$rate * 100, 2, '.', ''))); + $tcs = $dom->createElement('cac:TaxScheme'); + $tcs->appendChild($dom->createElement('cbc:ID', 'VAT')); + $taxCategory->appendChild($tcs); + $taxSubtotal->appendChild($taxCategory); + + $taxTotal->appendChild($taxSubtotal); + } + $root->appendChild($taxTotal); + + // 6. LegalMonetaryTotal + $lmt = $dom->createElement('cac:LegalMonetaryTotal'); + + $fields = [ + 'LineExtensionAmount' => $invoice['subtotal'] - $invoice['discount_total'], + 'TaxExclusiveAmount' => $invoice['subtotal'] - $invoice['discount_total'], + 'TaxInclusiveAmount' => $invoice['grand_total'], + 'AllowanceTotalAmount' => $invoice['discount_total'], + 'PayableAmount' => $invoice['grand_total'] + ]; + + foreach ($fields as $field => $value) { + $f = $dom->createElement('cbc:' . $field, number_format((float)$value, 3, '.', '')); + $f->setAttribute('currencyID', 'JOD'); + $lmt->appendChild($f); + } + $root->appendChild($lmt); + + // 7. InvoiceLine + foreach ($lines as $line) { + $invLine = $dom->createElement('cac:InvoiceLine'); + $invLine->appendChild($dom->createElement('cbc:ID', (string)$line['line_number'])); + + $qty = $dom->createElement('cbc:InvoicedQuantity', number_format((float)$line['quantity'], 3, '.', '')); + $qty->setAttribute('unitCode', 'PCE'); + $invLine->appendChild($qty); + + $lineExt = $dom->createElement('cbc:LineExtensionAmount', number_format($line['line_total'], 3, '.', '')); + $lineExt->setAttribute('currencyID', 'JOD'); + $invLine->appendChild($lineExt); + + // Line Tax + $lineTax = $dom->createElement('cac:TaxTotal'); + $ltaxAmt = $dom->createElement('cbc:TaxAmount', number_format((float)$line['tax_amount'], 3, '.', '')); + $ltaxAmt->setAttribute('currencyID', 'JOD'); + $lineTax->appendChild($ltaxAmt); + $invLine->appendChild($lineTax); + + $item = $dom->createElement('cac:Item'); + $item->appendChild($dom->createElement('cbc:Description', $line['description'])); + $itc = $dom->createElement('cac:TaxCategory'); + $itc->appendChild($dom->createElement('cbc:ID', 'S')); + $itc->appendChild($dom->createElement('cbc:Percent', number_format((float)$line['tax_rate'] * 100, 2, '.', ''))); + $its = $dom->createElement('cac:TaxScheme'); + $its->appendChild($dom->createElement('cbc:ID', 'VAT')); + $itc->appendChild($its); + $item->appendChild($itc); + $invLine->appendChild($item); + + $price = $dom->createElement('cac:Price'); + $pamt = $dom->createElement('cbc:PriceAmount', number_format((float)$line['unit_price'], 3, '.', '')); + $pamt->setAttribute('currencyID', 'JOD'); + $price->appendChild($pamt); + $invLine->appendChild($price); + + $root->appendChild($invLine); } - // Return formatted XML - $dom = dom_import_simplexml($xml)->ownerDocument; - $dom->formatOutput = true; return $dom->saveXML(); } } diff --git a/app/Services/Security/EncryptionService.php b/app/Services/Security/EncryptionService.php index ab6858e..f6159d6 100644 --- a/app/Services/Security/EncryptionService.php +++ b/app/Services/Security/EncryptionService.php @@ -1,10 +1,8 @@ key = $secrets['encryption_key'] ?? ''; + // Load from config/secrets.php — NEVER from .env directly + $secrets = require dirname(__DIR__, 3) . '/config/secrets.php'; + $key = $secrets['encryption_key'] ?? ''; - // Ensure key is hexadecimal and convert to binary (32 bytes) - if (strlen($this->key) === 64) { - $this->key = hex2bin($this->key); - } - - if (strlen($this->key) !== 32) { - throw new Exception("Security Error: Invalid ENCRYPTION_KEY length. Must be 32 bytes."); + if (strlen($key) !== 32) { + throw new RuntimeException( + 'ENCRYPTION_KEY_B64 not set or invalid. ' . + 'Generate: php -r "echo base64_encode(random_bytes(32));"' + ); } + $this->key = $key; } public function encrypt(string $plaintext): string { - $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length(self::METHOD)); - $ciphertext = openssl_encrypt($plaintext, self::METHOD, $this->key, 0, $iv, $tag); - - if ($ciphertext === false) { - throw new Exception("Encryption failed."); - } - + $iv = random_bytes(12); // 12 bytes for GCM + $tag = ''; + $ciphertext = openssl_encrypt($plaintext, self::METHOD, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); + if ($ciphertext === false) throw new RuntimeException('Encryption failed'); return base64_encode($iv) . ':' . base64_encode($ciphertext) . ':' . base64_encode($tag); } - public function decrypt(string $encryptedData): string + public function decrypt(string $data): string { - $parts = explode(':', $encryptedData); - if (count($parts) !== 3) { - throw new Exception("Invalid encrypted data format."); - } - - [$ivBase64, $ciphertextBase64, $tagBase64] = $parts; - $iv = base64_decode($ivBase64); - $ciphertext = base64_decode($ciphertextBase64); - $tag = base64_decode($tagBase64); - - $plaintext = openssl_decrypt($ciphertext, self::METHOD, $this->key, 0, $iv, $tag); - - if ($plaintext === false) { - throw new Exception("Decryption failed."); - } - + [$iv64, $ct64, $tag64] = explode(':', $data); + $plaintext = openssl_decrypt( + base64_decode($ct64), self::METHOD, $this->key, + OPENSSL_RAW_DATA, base64_decode($iv64), base64_decode($tag64) + ); + if ($plaintext === false) throw new RuntimeException('Decryption failed'); return $plaintext; } } diff --git a/app/Services/Security/HmacService.php b/app/Services/Security/HmacService.php index 5ad7ba8..9ae94d4 100644 --- a/app/Services/Security/HmacService.php +++ b/app/Services/Security/HmacService.php @@ -11,35 +11,29 @@ final class HmacService /** * Verify HMAC signature for external API requests (Flutter) */ - public function verify( - string $secret, - string $method, - string $path, - string $timestamp, - string $nonce, - string $body, - string $providedSignature - ): bool { - // 1. Check timestamp (within 5 minutes) - if (abs(time() - (int)$timestamp) > 300) { - return false; + public function verify(string $secret, string $method, string $path, + string $timestamp, string $nonce, string $body, string $signature): bool + { + // 1. Timestamp window (±5 minutes) + if (abs(time() - (int)$timestamp) > 300) return false; + + // 2. Nonce replay protection + try { + $redis = \App\Core\Redis::getInstance(); + $nonceKey = 'hmac_nonce:' . $nonce; + if ($redis->exists($nonceKey)) return false; // Replay attack + $redis->setex($nonceKey, 600, '1'); // TTL 10 minutes + } catch (\Throwable $e) { + // Redis unavailable — log but don't fail (degrade gracefully) + error_log('[HMAC] Redis unavailable for nonce check: ' . $e->getMessage()); } - // 2. Replay protection using Nonce in Redis - // Note: Redis::getInstance() would be used here - // If nonce exists, reject - - // 3. Calculate Signature + // 3. Build & compare signature $bodyHash = hash('sha256', $body); - $stringToSign = strtoupper($method) . "\n" . - $path . "\n" . - $timestamp . "\n" . - $nonce . "\n" . - $bodyHash; + $stringToSign = strtoupper($method) . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $bodyHash; + $calculated = hash_hmac('sha256', $stringToSign, $secret); - $calculatedSignature = hash_hmac('sha256', $stringToSign, $secret); - - return hash_equals($calculatedSignature, $providedSignature); + return hash_equals($calculated, $signature); } public function sign(string $secret, string $method, string $path, string $timestamp, string $nonce, string $body): string diff --git a/app/Services/TaxValidationService.php b/app/Services/TaxValidationService.php index 79bfac9..5999847 100644 --- a/app/Services/TaxValidationService.php +++ b/app/Services/TaxValidationService.php @@ -50,11 +50,18 @@ final class TaxValidationService $errors[] = ['code' => 'RULE_006', 'message_ar' => 'يجب تزويد الرقم الضريبي أو الوطني للمشتري للفواتير التي تتجاوز 10,000 دينار']; } - // Rule 007: Discount integrity - $expectedSubtotal = $invoice['subtotal'] - $invoice['discount_total']; - // This is a simplified check for Rule 007 - if ($expectedSubtotal < 0) { - $errors[] = ['code' => 'RULE_007', 'message_ar' => 'إجمالي الخصم لا يمكن أن يتجاوز المجموع الفرعي']; + // Rule 007: Discount integrity — subtotal - discount = Σ(line totals before tax) + $lineSumBeforeTax = array_sum(array_map( + fn($l) => round(($l['quantity'] * $l['unit_price']) - ($l['discount'] ?? 0), 3), + $lines + )); + $expected = round($invoice['subtotal'] - $invoice['discount_total'], 3); + if (abs($expected - $lineSumBeforeTax) > 0.01) { + $errors[] = [ + 'code' => 'RULE_007', + 'message_ar' => "خطأ في حساب الخصومات: المتوقع {$expected} JOD، المحسوب {$lineSumBeforeTax} JOD", + 'message_en' => "Discount integrity error" + ]; } return [ diff --git a/config/secrets.php b/config/secrets.php index 21ee2e5..a9ee4cf 100644 --- a/config/secrets.php +++ b/config/secrets.php @@ -1,13 +1,7 @@ '8f9e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8', // Default for dev, should be unique per install + // Generate with: php -r "echo base64_encode(random_bytes(32));" + 'encryption_key' => base64_decode($_ENV['ENCRYPTION_KEY_B64'] ?? ''), ]; diff --git a/database/migrations/005_notifications.sql b/database/migrations/005_notifications.sql new file mode 100644 index 0000000..96411f2 --- /dev/null +++ b/database/migrations/005_notifications.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + user_id CHAR(36) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type ENUM('info','success','warning','error') NOT NULL DEFAULT 'info', + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_notif_user (user_id), + CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/seed.sql b/database/seed.sql deleted file mode 100644 index 7f0d800..0000000 --- a/database/seed.sql +++ /dev/null @@ -1,26 +0,0 @@ --- ─── Initial Super Admin Seed ────────────────────────────── --- Default Password: admin123 (Please change after first login) - -INSERT INTO tenants (id, name, email, status) -VALUES ('d0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4', 'Musadaq Admin', 'admin@musadaq.app', 'active'); - -INSERT INTO users (id, tenant_id, name, email, password_hash, role, is_active) -VALUES ( - 'u0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4', - 'd0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4', - 'Super Admin', - 'admin@musadaq.app', - '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- Default: 'password' - 'super_admin', - 1 -); - -INSERT INTO subscriptions (tenant_id, plan, max_companies, max_invoices_per_month, max_users, status) -VALUES ( - 'd0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4', - 'pro', - 999, - 9999, - 99, - 'active' -); diff --git a/public/index.php b/public/index.php index 118bc0c..03a75c1 100644 --- a/public/index.php +++ b/public/index.php @@ -19,6 +19,10 @@ $router->addRoute('GET', '/api/v1/auth/me', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'me'] ]); +$router->addRoute('POST', '/api/v1/auth/logout', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [AuthController::class, 'logout'] +]); $router->addRoute('POST', '/api/v1/auth/2fa/enable', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'enable2FA'] @@ -49,17 +53,21 @@ $router->addRoute('POST', '/api/v1/companies/{id}/jofotara', [ // ══ User Routes ══════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Users\UserController::class, 'index'] + 'handler' => [\App\Modules\Users\UsersController::class, 'index'] ]); $router->addRoute('POST', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Users\UserController::class, 'create'] + 'handler' => [\App\Modules\Users\UsersController::class, 'create'] +]); +$router->addRoute('DELETE', '/api/v1/users/{id}', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Users\UsersController::class, 'delete'] ]); // ══ Invoice Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/invoices', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'list'] + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'index'] ]); $router->addRoute('POST', '/api/v1/invoices/upload', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], @@ -67,38 +75,33 @@ $router->addRoute('POST', '/api/v1/invoices/upload', [ ]); $router->addRoute('GET', '/api/v1/invoices/{id}', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'detail'] + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'show'] +]); +$router->addRoute('GET', '/api/v1/invoices/{id}/status', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'status'] ]); $router->addRoute('POST', '/api/v1/invoices/{id}/submit', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'submit'] ]); - $router->addRoute('GET', '/api/v1/invoices/{id}/file', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], - 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'downloadFile'] -]); - -// ══ Subscriptions ═════════════════════════════════════════════════ -$router->addRoute('GET', '/api/v1/subscriptions/me', [ - 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], - 'handler' => [\App\Modules\Subscriptions\SubscriptionController::class, 'me'] + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'serveFile'] ]); // ══ 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'] + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'index'] ]); $router->addRoute('POST', '/api/v1/api-keys', [ - 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], + 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'create'] ]); - -// ══ External API (HMAC) ══════════════════════════════════════ -$router->addRoute('POST', '/api/v1/external/invoices/upload', [ - 'middleware' => [\App\Middleware\HmacMiddleware::class], - 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'upload'] +$router->addRoute('DELETE', '/api/v1/api-keys/{id}', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'revoke'] ]); // ══ Dashboard ════════════════════════════════════════════════ @@ -107,11 +110,23 @@ $router->addRoute('GET', '/api/v1/dashboard', [ 'handler' => [\App\Modules\Dashboard\DashboardController::class, 'getStats'] ]); -// ══ Super Admin ══════════════════════════════════════════════ +// ══ Admin (Super Admin only) ══════════════════════════════════ +$router->addRoute('GET', '/api/v1/admin/tenants', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Admin\AdminController::class, 'listTenants'] +]); $router->addRoute('GET', '/api/v1/admin/stats', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Admin\AdminController::class, 'getSystemStats'] ]); +$router->addRoute('GET', '/api/v1/admin/health', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Admin\AdminController::class, 'health'] +]); +$router->addRoute('GET', '/api/v1/admin/queue', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Admin\AdminController::class, 'getQueueStatus'] +]); // ══ Health Check ═════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/health', function($request) { diff --git a/public/shell.php b/public/shell.php index 9dcd710..d76aea5 100644 --- a/public/shell.php +++ b/public/shell.php @@ -1,256 +1,333 @@ - + - مُصادَق — منصة أتمتة الفواتير الإلكترونية + مُصادَق | Bloomberg Terminal v2.0 - + - + - -