From d0e538408dc1d0029e05891f4a8e89a26d202713 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 3 May 2026 00:59:39 +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=A7=D9=84=D8=A5=D8=B7=D9=84=D8=A7=D9=82=20?= =?UTF-8?q?=D8=A7=D9=84=D8=A3=D9=88=D9=84=D9=8A=20=D9=84=D9=84=D9=86=D8=B8?= =?UTF-8?q?=D8=A7=D9=85=20=D8=A7=D9=84=D9=85=D8=AA=D9=83=D8=A7=D9=85=D9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 44 ++++ .gitignore | 0 app/Core/Application.php | 58 +++++ app/Core/Container.php | 72 ++++++ app/Core/Database.php | 41 +++ app/Core/Redis.php | 33 +++ app/Core/Request.php | 56 ++++ app/Core/Response.php | 45 ++++ app/Core/Router.php | 90 +++++++ app/Middleware/AuthMiddleware.php | 37 +++ app/Middleware/HmacMiddleware.php | 60 +++++ app/Middleware/RateLimitMiddleware.php | 36 +++ app/Models/BaseModel.php | 66 +++++ app/Modules/AI/AIController.php | 83 ++++++ app/Modules/Auth/AuthController.php | 56 ++++ app/Modules/Auth/AuthService.php | 56 ++++ app/Modules/Companies/CompanyController.php | 62 +++++ app/Modules/Companies/CompanyModel.php | 19 ++ app/Modules/Companies/CompanyService.php | 43 ++++ app/Modules/Dashboard/DashboardController.php | 41 +++ app/Modules/Invoices/InvoiceController.php | 61 +++++ app/Modules/Invoices/InvoiceModel.php | 27 ++ app/Modules/Users/UserModel.php | 27 ++ .../AI/Contracts/AIProviderInterface.php | 31 +++ app/Services/AI/GeminiProvider.php | 77 ++++++ app/Services/AuditService.php | 39 +++ app/Services/FileStorageService.php | 51 ++++ app/Services/JoFotara/JoFotaraGateway.php | 74 ++++++ app/Services/JoFotara/UBLGeneratorService.php | 46 ++++ app/Services/QueueService.php | 83 ++++++ app/Services/Security/EncryptionService.php | 57 ++++ app/Services/Security/HmacService.php | 56 ++++ app/Services/Security/JwtService.php | 48 ++++ app/Services/SubscriptionService.php | 47 ++++ app/Services/TaxValidationService.php | 65 +++++ composer.json | 38 +++ database/schema.sql | 243 ++++++++++++++++++ public/api.php | 0 public/assets/css/app.css | 85 ++++++ public/assets/js/api.js | 76 ++++++ public/index.php | 74 ++++++ public/shell.php | 208 +++++++++++++++ queue/worker.php | 43 ++++ 43 files changed, 2554 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 app/Core/Application.php create mode 100644 app/Core/Container.php create mode 100644 app/Core/Database.php create mode 100644 app/Core/Redis.php create mode 100644 app/Core/Request.php create mode 100644 app/Core/Response.php create mode 100644 app/Core/Router.php create mode 100644 app/Middleware/AuthMiddleware.php create mode 100644 app/Middleware/HmacMiddleware.php create mode 100644 app/Middleware/RateLimitMiddleware.php create mode 100644 app/Models/BaseModel.php create mode 100644 app/Modules/AI/AIController.php create mode 100644 app/Modules/Auth/AuthController.php create mode 100644 app/Modules/Auth/AuthService.php create mode 100644 app/Modules/Companies/CompanyController.php create mode 100644 app/Modules/Companies/CompanyModel.php create mode 100644 app/Modules/Companies/CompanyService.php create mode 100644 app/Modules/Dashboard/DashboardController.php create mode 100644 app/Modules/Invoices/InvoiceController.php create mode 100644 app/Modules/Invoices/InvoiceModel.php create mode 100644 app/Modules/Users/UserModel.php create mode 100644 app/Services/AI/Contracts/AIProviderInterface.php create mode 100644 app/Services/AI/GeminiProvider.php create mode 100644 app/Services/AuditService.php create mode 100644 app/Services/FileStorageService.php create mode 100644 app/Services/JoFotara/JoFotaraGateway.php create mode 100644 app/Services/JoFotara/UBLGeneratorService.php create mode 100644 app/Services/QueueService.php create mode 100644 app/Services/Security/EncryptionService.php create mode 100644 app/Services/Security/HmacService.php create mode 100644 app/Services/Security/JwtService.php create mode 100644 app/Services/SubscriptionService.php create mode 100644 app/Services/TaxValidationService.php create mode 100644 composer.json create mode 100644 database/schema.sql create mode 100644 public/api.php create mode 100644 public/assets/css/app.css create mode 100644 public/assets/js/api.js create mode 100644 public/index.php create mode 100644 public/shell.php create mode 100644 queue/worker.php diff --git a/.env b/.env new file mode 100644 index 0000000..d1975fe --- /dev/null +++ b/.env @@ -0,0 +1,44 @@ +APP_NAME="مُصادَق" +APP_ENV=development +APP_URL=http://localhost:8000 +APP_TIMEZONE=Asia/Amman + +# MySQL (CloudPanel managed) +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=musadaqDb +DB_USERNAME=musadaqUser +DB_PASSWORD=FWVG3vx2fhrwUULXa6E4 +DB_CHARSET=utf8mb4 + +# Redis (system service) +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT +JWT_SECRET=super-secret-change-me-in-production +JWT_ACCESS_EXPIRY=900 +JWT_REFRESH_EXPIRY=604800 + +# AI Providers +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.0-flash +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o + +# JoFotara +JOFOTARA_BASE_URL=https://backend.jofotara.gov.jo/core/invoices +JOFOTARA_ENV=sandbox + +# Email +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=noreply@musadaq.app +MAIL_FROM_NAME="مُصادَق" + +# Storage +STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage +UPLOAD_MAX_SIZE=20971520 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/app/Core/Application.php b/app/Core/Application.php new file mode 100644 index 0000000..3357ac7 --- /dev/null +++ b/app/Core/Application.php @@ -0,0 +1,58 @@ +load(); + + // 2. Set Timezone + date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Asia/Amman'); + + // 3. Initialize Core Components + $this->container = new Container(); + $this->router = new Router($this->container); + + // Register core services in container + $this->container->set(Container::class, $this->container); + $this->container->set(Router::class, $this->router); + } + + public function getRouter(): Router + { + return $this->router; + } + + public function run(): void + { + try { + $request = new Request(); + $this->router->dispatch($request, $this->container); + } catch (\Throwable $e) { + // Global Exception Handler + Response::error( + 'حدث خطأ غير متوقع في النظام', + 'INTERNAL_SERVER_ERROR', + 500, + $_ENV['APP_ENV'] === 'development' ? [ + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString() + ] : null + ); + } + } +} diff --git a/app/Core/Container.php b/app/Core/Container.php new file mode 100644 index 0000000..28c62ab --- /dev/null +++ b/app/Core/Container.php @@ -0,0 +1,72 @@ +instances[$id] = $concrete; + } + + public function get(string $id): mixed + { + if (isset($this->instances[$id])) { + if ($this->instances[$id] instanceof \Closure) { + $this->instances[$id] = ($this->instances[$id])($this); + } + return $this->instances[$id]; + } + + return $this->resolve($id); + } + + public function resolve(string $id): mixed + { + if (!class_exists($id)) { + throw new Exception("Class {$id} cannot be resolved."); + } + + $reflection = new ReflectionClass($id); + + if (!$reflection->isInstantiable()) { + throw new Exception("Class {$id} is not instantiable."); + } + + $constructor = $reflection->getConstructor(); + + if (is_null($constructor)) { + return new $id(); + } + + $parameters = $constructor->getParameters(); + $dependencies = []; + + foreach ($parameters as $parameter) { + $type = $parameter->getType(); + + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + if ($parameter->isDefaultValueAvailable()) { + $dependencies[] = $parameter->getDefaultValue(); + continue; + } + throw new Exception("Unable to resolve parameter '{$parameter->getName()}' in class {$id}"); + } + + $dependencies[] = $this->get($type->getName()); + } + + $instance = $reflection->newInstanceArgs($dependencies); + $this->instances[$id] = $instance; + + return $instance; + } +} diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..93c8c76 --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,41 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + try { + self::$instance = new PDO($dsn, $user, $pass, $options); + } catch (PDOException $e) { + throw new Exception("Database Connection Error: " . $e->getMessage()); + } + } + + return self::$instance; + } +} diff --git a/app/Core/Redis.php b/app/Core/Redis.php new file mode 100644 index 0000000..7f6f224 --- /dev/null +++ b/app/Core/Redis.php @@ -0,0 +1,33 @@ + 'tcp', + 'host' => $_ENV['REDIS_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['REDIS_PORT'] ?? 6379, + 'password' => $_ENV['REDIS_PASSWORD'] ?: null, + ]); + } catch (Exception $e) { + // If Redis fails, we might want to log it or handle gracefully + // depending on how critical it is. + throw new Exception("Redis Connection Error: " . $e->getMessage()); + } + } + + return self::$instance; + } +} diff --git a/app/Core/Request.php b/app/Core/Request.php new file mode 100644 index 0000000..49cef51 --- /dev/null +++ b/app/Core/Request.php @@ -0,0 +1,56 @@ +method = $_SERVER['REQUEST_METHOD']; + $this->path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $this->headers = getallheaders(); + $this->queryParams = $_GET; + $this->files = $_FILES; + + $contentType = $this->getHeader('Content-Type'); + if ($contentType && str_contains($contentType, 'application/json')) { + $this->body = json_decode(file_get_contents('php://input'), true) ?? []; + } else { + $this->body = $_POST; + } + } + + public function getMethod(): string { return $this->method; } + public function getPath(): string { return $this->path; } + public function getHeaders(): array { return $this->headers; } + public function getQueryParams(): array { return $this->queryParams; } + public function getBody(): array { return $this->body; } + public function getFiles(): array { return $this->files; } + + public function getHeader(string $name): ?string + { + $name = strtolower($name); + foreach ($this->headers as $key => $value) { + if (strtolower($key) === $name) { + return $value; + } + } + return null; + } + + public function input(string $key, mixed $default = null): mixed + { + return $this->body[$key] ?? $this->queryParams[$key] ?? $default; + } +} diff --git a/app/Core/Response.php b/app/Core/Response.php new file mode 100644 index 0000000..1fc90d9 --- /dev/null +++ b/app/Core/Response.php @@ -0,0 +1,45 @@ + 'application/json; charset=utf-8'], $headers)); + } + + public static function error(string $messageAr, string $code, int $status = 400, ?array $details = null): void + { + $data = [ + 'success' => false, + 'error' => [ + 'message_ar' => $messageAr, + 'code' => $code, + 'details' => $details + ] + ]; + self::json($data, $status); + } + + private static function send(mixed $data, int $status, array $headers): void + { + http_response_code($status); + + foreach ($headers as $name => $value) { + header("$name: $value"); + } + + // Apply Security Headers + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('X-XSS-Protection: 1; mode=block'); + header('Referrer-Policy: strict-origin-when-cross-origin'); + header_remove('X-Powered-By'); + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + exit; + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..15c6b22 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,90 @@ +container = $container; + } + + public function addRoute(string $method, string $path, array|callable $handler): void + { + $this->routes[] = [$method, $path, $handler]; + } + + public function dispatch(Request $request): void + { + $dispatcher = simpleDispatcher(function (RouteCollector $r) { + foreach ($this->routes as $route) { + $r->addRoute($route[0], $route[1], $route[2]); + } + }); + + $routeInfo = $dispatcher->dispatch($request->getMethod(), $request->getPath()); + + switch ($routeInfo[0]) { + case \FastRoute\Dispatcher::NOT_FOUND: + Response::error('المسار غير موجود', 'NOT_FOUND', 404); + break; + case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: + Response::error('الطريقة غير مسموح بها', 'METHOD_NOT_ALLOWED', 405); + break; + case \FastRoute\Dispatcher::FOUND: + $handler = $routeInfo[1]; + $vars = $routeInfo[2]; + + $this->executeHandler($handler, $request, $container, $vars); + break; + } + } + + private function executeHandler(mixed $handler, Request $request, Container $container, array $vars): void + { + if (is_array($handler) && isset($handler['middleware'])) { + $middlewares = (array) $handler['middleware']; + $finalHandler = $handler['handler']; + + $pipeline = $this->createPipeline($middlewares, $finalHandler, $container, $vars); + $pipeline($request); + } else { + $this->callHandler($handler, $request, $container, $vars); + } + } + + private function createPipeline(array $middlewares, mixed $handler, Container $container, array $vars): callable + { + return array_reduce( + array_reverse($middlewares), + function ($next, $middleware) use ($container) { + return function ($request) use ($next, $middleware, $container) { + $instance = $container->get($middleware); + return $instance->handle($request, $next); + }; + }, + function ($request) use ($handler, $container, $vars) { + $this->callHandler($handler, $request, $container, $vars); + } + ); + } + + private function callHandler(mixed $handler, Request $request, Container $container, array $vars): void + { + if (is_array($handler)) { + [$controllerClass, $method] = $handler; + $controller = $container->get($controllerClass); + $controller->$method($request, ...array_values($vars)); + } else { + $handler($request, ...array_values($vars)); + } + } +} diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..cf9da3e --- /dev/null +++ b/app/Middleware/AuthMiddleware.php @@ -0,0 +1,37 @@ +getHeader('Authorization'); + + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401); + return null; + } + + $token = substr($authHeader, 7); + + try { + $decoded = $this->jwtService->verifyToken($token); + $request->user = (object) $decoded; + $request->tenantId = $decoded['tenant_id'] ?? null; + } catch (Exception $e) { + Response::error('جلسة العمل منتهية أو غير صالحة', 'UNAUTHORIZED', 401); + return null; + } + + return $next($request); + } +} diff --git a/app/Middleware/HmacMiddleware.php b/app/Middleware/HmacMiddleware.php new file mode 100644 index 0000000..78c674d --- /dev/null +++ b/app/Middleware/HmacMiddleware.php @@ -0,0 +1,60 @@ +getHeader('X-Api-Key'); + $signature = $request->getHeader('X-Signature'); + $timestamp = $request->getHeader('X-Timestamp'); + $nonce = $request->getHeader('X-Nonce'); + + if (!$publicKey || !$signature || !$timestamp || !$nonce) { + Response::error('بيانات التوقيع (HMAC) ناقصة', 'HMAC_MISSING', 401); + return null; + } + + // 1. Lookup Secret by Public Key + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT secret_hash, tenant_id FROM api_keys WHERE public_key = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$publicKey]); + $apiKey = $stmt->fetch(); + + if (!$apiKey) { + Response::error('مفتاح API غير صالح', 'HMAC_INVALID_KEY', 401); + return null; + } + + // 2. Verify Signature + // Note: secret_hash in DB is the actual secret for signing + $isValid = $this->hmac->verify( + $apiKey['secret_hash'], + $request->getMethod(), + $request->getPath(), + $timestamp, + $nonce, + json_encode($request->getBody()), + $signature + ); + + if (!$isValid) { + Response::error('توقيع الطلب غير صحيح', 'HMAC_INVALID_SIGNATURE', 401); + return null; + } + + // 3. Set context + $request->tenantId = $apiKey['tenant_id']; + + return $next($request); + } +} diff --git a/app/Middleware/RateLimitMiddleware.php b/app/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..7f1c37d --- /dev/null +++ b/app/Middleware/RateLimitMiddleware.php @@ -0,0 +1,36 @@ +getPath() . "|" . $ip); + + $current = $redis->get($key); + + if ($current && (int)$current >= $limit) { + Response::error('لقد تجاوزت الحد المسموح من الطلبات، يرجى المحاولة لاحقاً', 'RATE_LIMIT_EXCEEDED', 429); + return null; + } + + if (!$current) { + $redis->setex($key, $window, 1); + } else { + $redis->incr($key); + } + + return $next($request); + } +} diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php new file mode 100644 index 0000000..f4bba1e --- /dev/null +++ b/app/Models/BaseModel.php @@ -0,0 +1,66 @@ +db()->prepare("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; + } + + public function create(array $data): string|bool + { + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; + $stmt = $this->db()->prepare($sql); + + if ($stmt->execute(array_values($data))) { + return $data[$this->primaryKey] ?? $this->db()->lastInsertId(); + } + + return false; + } + + public function update(string $id, array $data): bool + { + $sets = []; + foreach (array_keys($data) as $column) { + $sets[] = "{$column} = ?"; + } + $setString = implode(', ', $sets); + + $sql = "UPDATE {$this->table} SET {$setString} WHERE {$this->primaryKey} = ?"; + $stmt = $this->db()->prepare($sql); + + $params = array_values($data); + $params[] = $id; + + return $stmt->execute($params); + } + + public function delete(string $id): bool + { + $sql = "UPDATE {$this->table} SET deleted_at = NOW() WHERE {$this->primaryKey} = ?"; + $stmt = $this->db()->prepare($sql); + return $stmt->execute([$id]); + } +} diff --git a/app/Modules/AI/AIController.php b/app/Modules/AI/AIController.php new file mode 100644 index 0000000..d7d38ad --- /dev/null +++ b/app/Modules/AI/AIController.php @@ -0,0 +1,83 @@ +httpClient = new Client(); + $this->apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; + $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; + } + + public function query(Request $request): void + { + $userQuery = $request->input('query'); + if (!$userQuery) { + Response::error('يرجى تقديم استفسار', 'MISSING_QUERY', 422); + return; + } + + try { + // 1. Fetch current context data (Summary of stats) + $stats = $this->getQuickStats($request->tenantId); + + // 2. Ask Gemini to interpret and answer + $prompt = "You are Musadaq AI Assistant for a Jordanian E-Invoicing SaaS. " . + "The user is asking: \"{$userQuery}\". " . + "Current User Context: Tenant ID {$request->tenantId}. " . + "Current Data Summary: " . json_encode($stats) . ". " . + "Answer the user in a friendly Arabic tone (Jordanian dialect is okay). " . + "Keep it professional and concise. If you don't have the specific data, say so politely."; + + $response = $this->httpClient->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [ + 'json' => [ + 'contents' => [['parts' => [['text' => $prompt]]]] + ] + ]); + + $data = json_decode($response->getBody()->getContents(), true); + $answer = $data['candidates'][0]['content']['parts'][0]['text'] ?? 'عذراً، لم أستطع فهم الاستفسار حالياً.'; + + Response::json([ + 'success' => true, + 'data' => [ + 'answer' => $answer + ] + ]); + + } catch (Throwable $e) { + Response::error('فشل معالجة الاستعلام الذكي', 'AI_QUERY_FAILED', 500, [ + 'error' => $e->getMessage() + ]); + } + } + + private function getQuickStats(string $tenantId): array + { + $db = Database::getInstance(); + + $totalInvoices = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ?"); + $totalInvoices->execute([$tenantId]); + + $approvedCount = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ? AND status = 'approved'"); + $approvedCount->execute([$tenantId]); + + return [ + 'total_invoices' => $totalInvoices->fetch()['total'], + 'approved_invoices' => $approvedCount->fetch()['total'], + 'current_month' => date('F Y') + ]; + } +} diff --git a/app/Modules/Auth/AuthController.php b/app/Modules/Auth/AuthController.php new file mode 100644 index 0000000..24cb7c9 --- /dev/null +++ b/app/Modules/Auth/AuthController.php @@ -0,0 +1,56 @@ +input('email'); + $password = $request->input('password'); + + if (!$email || !$password) { + Response::error('يرجى إدخال البريد الإلكتروني وكلمة المرور', 'VALIDATION_ERROR', 422); + return; + } + + try { + $result = $this->authService->login($email, $password); + + // Set refresh token in HttpOnly cookie + setcookie('refresh_token', $result['refresh_token'], [ + 'expires' => time() + (60 * 60 * 24 * 7), + 'path' => '/api/v1/auth/refresh', + 'httponly' => true, + 'samesite' => 'Strict', + 'secure' => true + ]); + + unset($result['refresh_token']); + + Response::json([ + 'success' => true, + 'data' => $result, + 'message' => 'تم تسجيل الدخول بنجاح' + ]); + } catch (Throwable $e) { + Response::error($e->getMessage(), 'AUTH_FAILED', 401); + } + } + + public function me(Request $request): void + { + Response::json([ + 'success' => true, + 'data' => $request->user + ]); + } +} diff --git a/app/Modules/Auth/AuthService.php b/app/Modules/Auth/AuthService.php new file mode 100644 index 0000000..6a8fc11 --- /dev/null +++ b/app/Modules/Auth/AuthService.php @@ -0,0 +1,56 @@ +userModel->findByEmail($email); + + if (!$user || !password_verify($password, $user['password_hash'])) { + throw new Exception("البريد الإلكتروني أو كلمة المرور غير صحيحة"); + } + + if (!$user['is_active']) { + throw new Exception("هذا الحساب معطل حالياً"); + } + + $accessToken = $this->jwtService->issueAccessToken([ + 'user_id' => $user['id'], + 'tenant_id' => $user['tenant_id'], + 'role' => $user['role'] + ]); + + $refreshToken = $this->jwtService->issueRefreshToken($user['id']); + + // Update refresh token hash in DB + $this->userModel->update($user['id'], [ + 'refresh_token_hash' => password_hash($refreshToken, PASSWORD_BCRYPT), + 'last_login_at' => date('Y-m-d H:i:s'), + 'last_login_ip' => $_SERVER['REMOTE_ADDR'] ?? null + ]); + + return [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'user' => [ + 'id' => $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + 'role' => $user['role'] + ] + ]; + } +} diff --git a/app/Modules/Companies/CompanyController.php b/app/Modules/Companies/CompanyController.php new file mode 100644 index 0000000..52d95e4 --- /dev/null +++ b/app/Modules/Companies/CompanyController.php @@ -0,0 +1,62 @@ +companyModel->findByTenant($request->tenantId); + Response::json([ + 'success' => true, + 'data' => $companies + ]); + } + + public function create(Request $request): void + { + $data = $request->getBody(); + $data['tenant_id'] = $request->tenantId; + + try { + $companyId = $this->companyService->createCompany($data); + Response::json([ + 'success' => true, + 'data' => ['id' => $companyId], + 'message' => 'تم إضافة الشركة بنجاح' + ], 201); + } catch (Throwable $e) { + Response::error('فشل إضافة الشركة', 'CREATE_FAILED', 500); + } + } + + public function updateJoFotara(Request $request, string $id): void + { + $data = [ + 'jofotara_client_id' => $request->input('client_id'), + 'jofotara_secret_key' => $request->input('secret_key'), + 'is_jofotara_linked' => 1 + ]; + + try { + $this->companyService->createCompany(array_merge($data, ['id' => $id])); // Reuses encryption logic + Response::json([ + 'success' => true, + 'message' => 'تم تحديث بيانات جو-فواتير بنجاح' + ]); + } catch (Throwable $e) { + Response::error('فشل تحديث البيانات', 'UPDATE_FAILED', 500); + } + } +} diff --git a/app/Modules/Companies/CompanyModel.php b/app/Modules/Companies/CompanyModel.php new file mode 100644 index 0000000..181a259 --- /dev/null +++ b/app/Modules/Companies/CompanyModel.php @@ -0,0 +1,19 @@ +db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL"); + $stmt->execute([$tenantId]); + return $stmt->fetchAll(); + } +} diff --git a/app/Modules/Companies/CompanyService.php b/app/Modules/Companies/CompanyService.php new file mode 100644 index 0000000..e5bfa14 --- /dev/null +++ b/app/Modules/Companies/CompanyService.php @@ -0,0 +1,43 @@ +encryption->encrypt($data['jofotara_client_id']); + unset($data['jofotara_client_id']); + } + + if (isset($data['jofotara_secret_key'])) { + $data['jofotara_secret_key_encrypted'] = $this->encryption->encrypt($data['jofotara_secret_key']); + unset($data['jofotara_secret_key']); + } + + return (string)$this->companyModel->create($data); + } + + public function getJoFotaraCredentials(string $companyId): array + { + $company = $this->companyModel->find($companyId); + if (!$company) return []; + + return [ + 'clientId' => $company['jofotara_client_id_encrypted'] ? $this->encryption->decrypt($company['jofotara_client_id_encrypted']) : null, + 'secretKey' => $company['jofotara_secret_key_encrypted'] ? $this->encryption->decrypt($company['jofotara_secret_key_encrypted']) : null, + ]; + } +} diff --git a/app/Modules/Dashboard/DashboardController.php b/app/Modules/Dashboard/DashboardController.php new file mode 100644 index 0000000..2307870 --- /dev/null +++ b/app/Modules/Dashboard/DashboardController.php @@ -0,0 +1,41 @@ +tenantId; + $db = Database::getInstance(); + + // 1. Total Invoices this month + $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE tenant_id = ? AND MONTH(created_at) = MONTH(CURRENT_DATE)"); + $stmt->execute([$tenantId]); + $thisMonth = $stmt->fetch()['count']; + + // 2. Approved vs Rejected + $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices WHERE tenant_id = ? GROUP BY status"); + $stmt->execute([$tenantId]); + $statusCounts = $stmt->fetchAll(); + + // 3. Recent Activity + $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 = ? ORDER BY i.created_at DESC LIMIT 5"); + $stmt->execute([$tenantId]); + $recent = $stmt->fetchAll(); + + Response::json([ + 'success' => true, + 'data' => [ + 'total_this_month' => $thisMonth, + 'status_distribution' => $statusCounts, + 'recent_invoices' => $recent, + 'subscription_usage' => 45 // Placeholder + ] + ]); + } +} diff --git a/app/Modules/Invoices/InvoiceController.php b/app/Modules/Invoices/InvoiceController.php new file mode 100644 index 0000000..2d975b9 --- /dev/null +++ b/app/Modules/Invoices/InvoiceController.php @@ -0,0 +1,61 @@ +getFiles(); + if (empty($files['invoice'])) { + Response::error('يرجى اختيار ملف الفاتورة', 'MISSING_FILE', 422); + return; + } + + $companyId = $request->input('company_id'); + if (!$companyId) { + Response::error('يرجى تحديد الشركة', 'MISSING_COMPANY', 422); + return; + } + + try { + $tenantId = $request->tenantId; + $filePath = $this->storage->store($files['invoice'], $tenantId, $companyId); + $fileHash = $this->storage->getHash($filePath); + + // Create invoice record + $invoiceId = $this->invoiceModel->create([ + 'tenant_id' => $tenantId, + 'company_id' => $companyId, + 'uploaded_by' => $request->user->user_id, + 'status' => 'uploaded', + 'original_file_path' => $filePath, + 'original_file_hash' => $fileHash, + 'idempotency_key' => bin2hex(random_bytes(16)) + ]); + + // TODO: Push to queue for AI extraction + // QueueService::push('extract_invoice', ['invoice_id' => $invoiceId]); + + Response::json([ + 'success' => true, + 'data' => ['invoice_id' => $invoiceId], + 'message' => 'تم رفع الفاتورة بنجاح وبدء المعالجة' + ]); + } catch (Throwable $e) { + Response::error($e->getMessage(), 'UPLOAD_FAILED', 500); + } + } +} diff --git a/app/Modules/Invoices/InvoiceModel.php b/app/Modules/Invoices/InvoiceModel.php new file mode 100644 index 0000000..3d8a436 --- /dev/null +++ b/app/Modules/Invoices/InvoiceModel.php @@ -0,0 +1,27 @@ +table} WHERE status = ? AND deleted_at IS NULL"; + $params = [$status]; + + if ($tenantId) { + $sql .= " AND tenant_id = ?"; + $params[] = $tenantId; + } + + $stmt = $this->db()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } +} diff --git a/app/Modules/Users/UserModel.php b/app/Modules/Users/UserModel.php new file mode 100644 index 0000000..450d82b --- /dev/null +++ b/app/Modules/Users/UserModel.php @@ -0,0 +1,27 @@ +table} WHERE email = ? AND deleted_at IS NULL"; + $params = [$email]; + + if ($tenantId) { + $sql .= " AND tenant_id = ?"; + $params[] = $tenantId; + } + + $stmt = $this->db()->prepare($sql); + $stmt->execute($params); + return $stmt->fetch() ?: null; + } +} diff --git a/app/Services/AI/Contracts/AIProviderInterface.php b/app/Services/AI/Contracts/AIProviderInterface.php new file mode 100644 index 0000000..680f045 --- /dev/null +++ b/app/Services/AI/Contracts/AIProviderInterface.php @@ -0,0 +1,31 @@ +client = new Client(); + $this->apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; + $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; + } + + public function extractFromFile(string $filePath, string $mimeType): ExtractionResultDTO + { + $fileData = base64_encode(file_get_contents($filePath)); + + $prompt = "Extract invoice data from this file. Return ONLY valid JSON (no markdown). " . + "Fields: invoice_number, invoice_date (YYYY-MM-DD), supplier_name, supplier_tin, supplier_address, " . + "buyer_name, buyer_tin, lines (description, quantity, unit_price, line_total, tax_rate), " . + "subtotal, tax_amount, grand_total, currency (JOD), confidence (0-1)."; + + $response = $this->client->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [ + 'json' => [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + [ + 'inline_data' => [ + 'mime_type' => $mimeType, + 'data' => $fileData + ] + ] + ] + ] + ], + 'generationConfig' => [ + 'response_mime_type' => 'application/json' + ] + ] + ]); + + $data = json_decode($response->getBody()->getContents(), true); + $jsonStr = $data['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; + $result = json_decode($jsonStr, true); + + return new ExtractionResultDTO( + $result['invoice_number'] ?? '', + $result['invoice_date'] ?? '', + $result['supplier_name'] ?? '', + $result['supplier_tin'] ?? null, + $result['supplier_address'] ?? '', + $result['buyer_name'] ?? null, + $result['buyer_tin'] ?? null, + $result['lines'] ?? [], + (float)($result['subtotal'] ?? 0), + (float)($result['tax_amount'] ?? 0), + (float)($result['grand_total'] ?? 0), + $result['currency'] ?? 'JOD', + (float)($result['confidence'] ?? 0), + $data['usageMetadata'] ?? [] + ); + } + + public function getProviderName(): string { return 'gemini'; } +} diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php new file mode 100644 index 0000000..329d48d --- /dev/null +++ b/app/Services/AuditService.php @@ -0,0 +1,39 @@ +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 + ]); + } +} diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php new file mode 100644 index 0000000..cc65392 --- /dev/null +++ b/app/Services/FileStorageService.php @@ -0,0 +1,51 @@ +storagePath = $_ENV['STORAGE_PATH'] ?? dirname(__DIR__, 2) . '/storage'; + } + + public function store(array $file, string $tenantId, string $companyId): string + { + // 1. Validate MIME + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + $allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']; + if (!in_array($mime, $allowedMimes)) { + throw new Exception("نوع الملف غير مسموح به"); + } + + // 2. Generate path + $dir = "{$this->storagePath}/invoices/{$tenantId}/{$companyId}"; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $extension = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = hash('sha256', $file['name'] . time() . uniqid()) . '.' . $extension; + $targetPath = "{$dir}/{$filename}"; + + if (!move_uploaded_file($file['tmp_name'], $targetPath)) { + throw new Exception("فشل رفع الملف"); + } + + return $targetPath; + } + + public function getHash(string $filePath): string + { + return hash_file('sha256', $filePath); + } +} diff --git a/app/Services/JoFotara/JoFotaraGateway.php b/app/Services/JoFotara/JoFotaraGateway.php new file mode 100644 index 0000000..3b58f00 --- /dev/null +++ b/app/Services/JoFotara/JoFotaraGateway.php @@ -0,0 +1,74 @@ +client = new Client(); + $this->baseUrl = $_ENV['JOFOTARA_BASE_URL'] ?? 'https://backend.jofotara.gov.jo/core/invoices'; + } + + /** + * Submit invoice to JoFotara with Circuit Breaker + */ + public function submitInvoice(string $companyId, string $xmlBase64, array $credentials): array + { + $cbKey = "cb:jofotara:{$companyId}"; + if ($this->isCircuitOpen($cbKey)) { + throw new Exception("بوابة جو-فواتير غير متاحة حالياً لهذه الشركة، يرجى المحاولة لاحقاً"); + } + + try { + $response = $this->client->post($this->baseUrl, [ + 'json' => [ + 'clientId' => $credentials['clientId'], + 'secretKey' => $credentials['secretKey'], + 'invoiceType' => 'invoice', + 'invoiceData' => $xmlBase64 + ], + 'timeout' => 30 + ]); + + $result = json_decode($response->getBody()->getContents(), true); + $this->resetFailures($cbKey); + + return $result; + } catch (\Throwable $e) { + $this->recordFailure($cbKey); + throw $e; + } + } + + private function isCircuitOpen(string $key): bool + { + $redis = Redis::getInstance(); + return (bool)$redis->get("{$key}:open"); + } + + private function recordFailure(string $key): void + { + $redis = Redis::getInstance(); + $failures = (int)$redis->incr("{$key}:failures"); + + if ($failures >= 5) { + $redis->setex("{$key}:open", 300, 1); // Open for 5 minutes + } + } + + private function resetFailures(string $key): void + { + $redis = Redis::getInstance(); + $redis->del(["{$key}:failures", "{$key}:open"]); + } +} diff --git a/app/Services/JoFotara/UBLGeneratorService.php b/app/Services/JoFotara/UBLGeneratorService.php new file mode 100644 index 0000000..88d9c0b --- /dev/null +++ b/app/Services/JoFotara/UBLGeneratorService.php @@ -0,0 +1,46 @@ +'); + + $xml->addChild('cbc:UBLVersionID', '2.1'); + $xml->addChild('cbc:ID', $invoice['invoice_number']); + $xml->addChild('cbc:IssueDate', $invoice['invoice_date']); + $xml->addChild('cbc:InvoiceTypeCode', $invoice['ubl_type_code']); // e.g. 388 + + // Supplier (AccountingSupplierParty) + $supplier = $xml->addChild('cac:AccountingSupplierParty'); + $party = $supplier->addChild('cac:Party'); + $party->addChild('cbc:EndpointID', $company['tax_identification_number'])->addAttribute('schemeID', 'TN'); + + // ... (Adding more UBL fields like totals, lines, etc.) + // Note: For brevity, this is a simplified structure. In production, + // we follow the exact ISTD XML Schema for Jordan. + + $legalMonetaryTotal = $xml->addChild('cac:LegalMonetaryTotal'); + $legalMonetaryTotal->addChild('cbc:LineExtensionAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); + $legalMonetaryTotal->addChild('cbc:TaxExclusiveAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); + $legalMonetaryTotal->addChild('cbc:TaxInclusiveAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); + $legalMonetaryTotal->addChild('cbc:PayableAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); + + foreach ($lines as $line) { + $invoiceLine = $xml->addChild('cac:InvoiceLine'); + $invoiceLine->addChild('cbc:ID', (string)$line['line_number']); + $invoiceLine->addChild('cbc:InvoicedQuantity', (string)$line['quantity']); + $price = $invoiceLine->addChild('cac:Price'); + $price->addChild('cbc:PriceAmount', (string)$line['unit_price'])->addAttribute('currencyID', 'JOD'); + } + + return $xml->asXML(); + } +} diff --git a/app/Services/QueueService.php b/app/Services/QueueService.php new file mode 100644 index 0000000..a3852c6 --- /dev/null +++ b/app/Services/QueueService.php @@ -0,0 +1,83 @@ + bin2hex(random_bytes(16)), + 'type' => $type, + 'payload' => $payload, + 'priority' => $priority, + 'attempts' => 0, + 'created_at' => time() + ]; + + try { + $redis = Redis::getInstance(); + $redis->lpush(self::REDIS_QUEUE, json_encode($job)); + } catch (\Throwable $e) { + // Fallback to MySQL + self::pushToDatabase($job); + } + } + + private static function pushToDatabase(array $job): void + { + $db = Database::getInstance(); + $stmt = $db->prepare("INSERT INTO queue_jobs (id, type, payload, priority, status) VALUES (?, ?, ?, ?, 'pending')"); + $stmt->execute([ + $job['id'], + $job['type'], + json_encode($job['payload']), + $job['priority'] + ]); + } + + public static function pop(): ?array + { + try { + $redis = Redis::getInstance(); + $data = $redis->rpop(self::REDIS_QUEUE); + return $data ? json_decode($data, true) : null; + } catch (\Throwable $e) { + // Fallback to MySQL + return self::popFromDatabase(); + } + } + + private static function popFromDatabase(): ?array + { + $db = Database::getInstance(); + $db->beginTransaction(); + try { + $stmt = $db->prepare("SELECT * FROM queue_jobs WHERE status = 'pending' ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE"); + $stmt->execute(); + $job = $stmt->fetch(); + + if ($job) { + $db->prepare("UPDATE queue_jobs SET status = 'processing', locked_at = NOW() WHERE id = ?")->execute([$job['id']]); + $db->commit(); + return [ + 'id' => $job['id'], + 'type' => $job['type'], + 'payload' => json_decode($job['payload'], true), + 'attempts' => $job['attempts'] + ]; + } + $db->commit(); + } catch (\Throwable $e) { + $db->rollBack(); + } + return null; + } +} diff --git a/app/Services/Security/EncryptionService.php b/app/Services/Security/EncryptionService.php new file mode 100644 index 0000000..8715555 --- /dev/null +++ b/app/Services/Security/EncryptionService.php @@ -0,0 +1,57 @@ +key = $_ENV['ENCRYPTION_KEY'] ?? ''; + if (strlen($this->key) !== 32) { + // In a real app, this would be in config/secrets.php + // For now, we use a fallback if not set, but warn in production + $this->key = hash('sha256', $_ENV['JWT_SECRET'] ?? 'fallback-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."); + } + + return base64_encode($iv) . ':' . base64_encode($ciphertext) . ':' . base64_encode($tag); + } + + public function decrypt(string $encryptedData): 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."); + } + + return $plaintext; + } +} diff --git a/app/Services/Security/HmacService.php b/app/Services/Security/HmacService.php new file mode 100644 index 0000000..5ad7ba8 --- /dev/null +++ b/app/Services/Security/HmacService.php @@ -0,0 +1,56 @@ + 300) { + return false; + } + + // 2. Replay protection using Nonce in Redis + // Note: Redis::getInstance() would be used here + // If nonce exists, reject + + // 3. Calculate Signature + $bodyHash = hash('sha256', $body); + $stringToSign = strtoupper($method) . "\n" . + $path . "\n" . + $timestamp . "\n" . + $nonce . "\n" . + $bodyHash; + + $calculatedSignature = hash_hmac('sha256', $stringToSign, $secret); + + return hash_equals($calculatedSignature, $providedSignature); + } + + public function sign(string $secret, string $method, string $path, string $timestamp, string $nonce, string $body): string + { + $bodyHash = hash('sha256', $body); + $stringToSign = strtoupper($method) . "\n" . + $path . "\n" . + $timestamp . "\n" . + $nonce . "\n" . + $bodyHash; + + return hash_hmac('sha256', $stringToSign, $secret); + } +} diff --git a/app/Services/Security/JwtService.php b/app/Services/Security/JwtService.php new file mode 100644 index 0000000..724dfc0 --- /dev/null +++ b/app/Services/Security/JwtService.php @@ -0,0 +1,48 @@ +secret = $_ENV['JWT_SECRET'] ?? 'change-me'; + $this->accessExpiry = (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900); + $this->refreshExpiry = (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800); + } + + public function issueAccessToken(array $payload): string + { + $payload['exp'] = time() + $this->accessExpiry; + $payload['iat'] = time(); + $payload['jti'] = bin2hex(random_bytes(16)); + + return JWT::encode($payload, $this->secret, 'HS256'); + } + + public function issueRefreshToken(string $userId): string + { + // Refresh token is a random string stored in DB (hashed) + return bin2hex(random_bytes(64)); + } + + public function verifyToken(string $token): array + { + try { + $decoded = JWT::decode($token, new Key($this->secret, 'HS256')); + return (array) $decoded; + } catch (Exception $e) { + throw new Exception("Invalid or expired token: " . $e->getMessage()); + } + } +} diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php new file mode 100644 index 0000000..88ef428 --- /dev/null +++ b/app/Services/SubscriptionService.php @@ -0,0 +1,47 @@ +prepare("SELECT * FROM subscriptions WHERE tenant_id = ? LIMIT 1"); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + + if (!$sub) throw new Exception("لا يوجد اشتراك فعال"); + + if ($type === 'invoices') { + if ($sub['invoices_used_this_month'] >= $sub['max_invoices_per_month']) { + throw new Exception("لقد وصلت للحد الأقصى من الفواتير المسموح بها في خطتك الحالية"); + } + } + + if ($type === 'companies') { + $countStmt = $db->prepare("SELECT COUNT(*) as total FROM companies WHERE tenant_id = ? AND deleted_at IS NULL"); + $countStmt->execute([$tenantId]); + $count = $countStmt->fetch()['total']; + + if ($count >= $sub['max_companies']) { + throw new Exception("لقد وصلت للحد الأقصى من الشركات المسموح بها في خطتك الحالية"); + } + } + } + + public function incrementUsage(string $tenantId, string $type): void + { + if ($type === 'invoices') { + $db = Database::getInstance(); + $stmt = $db->prepare("UPDATE subscriptions SET invoices_used_this_month = invoices_used_this_month + 1 WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + } + } +} diff --git a/app/Services/TaxValidationService.php b/app/Services/TaxValidationService.php new file mode 100644 index 0000000..79bfac9 --- /dev/null +++ b/app/Services/TaxValidationService.php @@ -0,0 +1,65 @@ + 0.01) { + $errors[] = ['code' => 'RULE_001', 'message_ar' => 'مجموع سطور الفاتورة لا يطابق المجموع الكلي']; + } + + // Rule 002: Tax integrity (tax_amount = subtotal × tax_rate) + foreach ($lines as $line) { + $expectedTax = round($line['quantity'] * $line['unit_price'] * $line['tax_rate'], 3); + if (abs($line['tax_amount'] - $expectedTax) > 0.01) { + $errors[] = ['code' => 'RULE_002', 'message_ar' => "خطأ في حساب الضريبة للسطر {$line['line_number']}"]; + } + } + + // Rule 003: Invoice number required + if (empty($invoice['invoice_number'])) { + $errors[] = ['code' => 'RULE_003', 'message_ar' => 'رقم الفاتورة مطلوب']; + } + + // Rule 004: No future dates + if (strtotime($invoice['invoice_date']) > time()) { + $errors[] = ['code' => 'RULE_004', 'message_ar' => 'تاريخ الفاتورة لا يمكن أن يكون في المستقبل']; + } + + // Rule 005: Valid JO Tax Rates + $validRates = [0.16, 0.10, 0.05, 0.04, 0.02, 0.00]; + foreach ($lines as $line) { + if (!in_array(round((float)$line['tax_rate'], 2), $validRates)) { + $errors[] = ['code' => 'RULE_005', 'message_ar' => "نسبة الضريبة ({$line['tax_rate']}) غير صالحة في الأردن"]; + } + } + + // Rule 006: Buyer ID for large invoices (> 10,000 JOD) + if ($invoice['grand_total'] > 10000 && empty($invoice['buyer_tin']) && empty($invoice['buyer_national_id'])) { + $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' => 'إجمالي الخصم لا يمكن أن يتجاوز المجموع الفرعي']; + } + + return [ + 'is_valid' => empty($errors), + 'errors' => $errors + ]; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..915e946 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "musadaq/platform", + "description": "Jordanian E-Invoicing Automation SaaS", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.4", + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-openssl": "*", + "ext-sodium": "*", + "ext-curl": "*", + "ext-mbstring": "*", + "ext-json": "*", + "vlucas/phpdotenv": "^5.6", + "monolog/monolog": "^3.5", + "firebase/php-jwt": "^6.10", + "ramsey/uuid": "^4.7", + "nikic/fast-route": "^1.3", + "predis/predis": "^2.2", + "guzzlehttp/guzzle": "^7.9", + "respect/validation": "^2.3", + "league/flysystem": "^3.28", + "symfony/mailer": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.12", + "squizlabs/php_codesniffer": "^3.10" + }, + "autoload": { + "psr-4": { "App\\": "app/" } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..fd1943d --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,243 @@ +SET NAMES utf8mb4; +SET CHARACTER SET utf8mb4; + +-- ─── Tenants ────────────────────────────────────────────── +CREATE TABLE tenants ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(20) NULL, + status ENUM('active','suspended','trial') NOT NULL DEFAULT 'trial', + trial_ends_at DATETIME NULL, + settings JSON DEFAULT (JSON_OBJECT()), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_tenants_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Users ──────────────────────────────────────────────── +CREATE TABLE users ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('super_admin','admin','accountant','employee','viewer') NOT NULL, + assigned_company_id CHAR(36) NULL, + refresh_token_hash VARCHAR(255) NULL, + totp_secret VARCHAR(64) NULL, + totp_enabled TINYINT(1) NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + email_verified_at DATETIME NULL, + last_login_at DATETIME NULL, + last_login_ip VARCHAR(45) NULL, + failed_login_count INT NOT NULL DEFAULT 0, + locked_until DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_tenant_email (tenant_id, email), + CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── API Keys ───────────────────────────────────────────── +CREATE TABLE api_keys ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + name VARCHAR(100) NOT NULL, + public_key VARCHAR(64) NOT NULL, + secret_hash VARCHAR(255) NOT NULL, + permissions JSON DEFAULT (JSON_ARRAY('invoices:read','invoices:upload')), + last_used_at DATETIME NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_api_public_key (public_key), + CONSTRAINT fk_apikeys_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_apikeys_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Companies ──────────────────────────────────────────── +CREATE TABLE companies ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + name_en VARCHAR(255) NULL, + tax_identification_number VARCHAR(20) NOT NULL, + commercial_registration_number VARCHAR(50) NULL, + address TEXT NULL, + city VARCHAR(100) NULL, + contact_email VARCHAR(255) NULL, + contact_phone VARCHAR(20) NULL, + jofotara_client_id_encrypted TEXT NULL, + jofotara_secret_key_encrypted TEXT NULL, + jofotara_income_source_sequence VARCHAR(50) NULL, + certificate_path VARCHAR(255) NULL, + certificate_password_encrypted TEXT NULL, + is_jofotara_linked TINYINT(1) NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + INDEX idx_companies_tenant (tenant_id), + INDEX idx_companies_tin (tax_identification_number), + CONSTRAINT fk_companies_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Subscriptions ──────────────────────────────────────── +CREATE TABLE subscriptions ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + plan ENUM('free','basic','office','pro','enterprise') NOT NULL DEFAULT 'basic', + max_companies INT NOT NULL DEFAULT 3, + max_invoices_per_month INT NOT NULL DEFAULT 50, + max_users INT NOT NULL DEFAULT 2, + price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, + invoices_used_this_month INT NOT NULL DEFAULT 0, + status ENUM('active','past_due','cancelled','trial') NOT NULL DEFAULT 'active', + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_sub_tenant (tenant_id), + CONSTRAINT fk_sub_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Invoices ───────────────────────────────────────────── +CREATE TABLE invoices ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + company_id CHAR(36) NOT NULL, + uploaded_by CHAR(36) NULL, + invoice_number VARCHAR(100) NULL, + invoice_date DATE NULL, + invoice_type ENUM('cash','credit') NOT NULL DEFAULT 'cash', + ubl_type_code CHAR(3) NOT NULL DEFAULT '388', + payment_method_code CHAR(3) NOT NULL DEFAULT '013', + supplier_tin VARCHAR(20) NULL, + supplier_name VARCHAR(255) NULL, + supplier_address TEXT NULL, + buyer_tin VARCHAR(20) NULL, + buyer_national_id VARCHAR(20) NULL, + buyer_name VARCHAR(255) NULL, + subtotal DECIMAL(15,3) NOT NULL DEFAULT 0, + discount_total DECIMAL(15,3) NOT NULL DEFAULT 0, + tax_amount DECIMAL(15,3) NOT NULL DEFAULT 0, + grand_total DECIMAL(15,3) NOT NULL DEFAULT 0, + currency_code CHAR(3) NOT NULL DEFAULT 'JOD', + status ENUM('uploaded','extracting','extracted','validated', + 'validation_failed','submitting','approved','rejected') + NOT NULL DEFAULT 'uploaded', + original_file_path TEXT NULL, + original_file_hash VARCHAR(64) NULL, + invoice_category VARCHAR(20) NOT NULL DEFAULT 'simplified', + validation_errors JSON NULL, + qr_code TEXT NULL, + jofotara_response JSON NULL, + ai_provider VARCHAR(20) NULL, + ai_confidence_score DECIMAL(4,3) NULL, + ai_prompt_tokens INT NOT NULL DEFAULT 0, + ai_completion_tokens INT NOT NULL DEFAULT 0, + ai_total_cost DECIMAL(10,6) NOT NULL DEFAULT 0, + ai_raw_response JSON NULL, + idempotency_key VARCHAR(64) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_idempotency (idempotency_key), + INDEX idx_invoices_tenant (tenant_id), + INDEX idx_invoices_company (company_id), + INDEX idx_invoices_status (status), + INDEX idx_invoices_date (invoice_date), + INDEX idx_invoices_file_hash (original_file_hash), + CONSTRAINT fk_inv_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_inv_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + CONSTRAINT fk_inv_user FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Invoice Lines ──────────────────────────────────────── +CREATE TABLE invoice_lines ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + invoice_id CHAR(36) NOT NULL, + line_number INT NOT NULL, + description TEXT NOT NULL, + quantity DECIMAL(15,3) NOT NULL, + unit_price DECIMAL(15,3) NOT NULL, + discount DECIMAL(15,3) NOT NULL DEFAULT 0, + tax_rate DECIMAL(5,4) NOT NULL, + tax_amount DECIMAL(15,3) NOT NULL DEFAULT 0, + line_total DECIMAL(15,3) NOT NULL, + PRIMARY KEY (id), + INDEX idx_lines_invoice (invoice_id), + CONSTRAINT fk_lines_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Audit Logs ─────────────────────────────────────────── +CREATE TABLE audit_logs ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NULL, + user_id CHAR(36) NULL, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50) NULL, + entity_id CHAR(36) NULL, + old_data JSON NULL, + new_data JSON NULL, + ip_address VARCHAR(45) NULL, + user_agent TEXT NULL, + metadata JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_audit_tenant (tenant_id), + INDEX idx_audit_action (action), + INDEX idx_audit_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Risk Scores ────────────────────────────────────────── +CREATE TABLE risk_scores ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + tenant_id CHAR(36) NOT NULL, + company_id CHAR(36) NOT NULL, + invoice_id CHAR(36) NULL, + risk_type VARCHAR(50) NOT NULL, + score TINYINT UNSIGNED NOT NULL, + reason TEXT NOT NULL, + is_resolved TINYINT(1) NOT NULL DEFAULT 0, + resolved_by CHAR(36) NULL, + resolved_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_risk_tenant (tenant_id), + INDEX idx_risk_unresolved (is_resolved), + CONSTRAINT fk_risk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_risk_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + CONSTRAINT fk_risk_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL, + CONSTRAINT fk_risk_resolver FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ─── Queue Jobs (MySQL fallback when Redis unavailable) ─── +CREATE TABLE queue_jobs ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + type VARCHAR(100) NOT NULL, + payload JSON NOT NULL, + priority INT NOT NULL DEFAULT 0, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + status ENUM('pending','processing','completed','failed','dead') + NOT NULL DEFAULT 'pending', + error TEXT NULL, + locked_at DATETIME NULL, + locked_by VARCHAR(100) NULL, + scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_queue_pending (status, priority DESC, scheduled_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/css/app.css b/public/assets/css/app.css new file mode 100644 index 0000000..bb805f9 --- /dev/null +++ b/public/assets/css/app.css @@ -0,0 +1,85 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=JetBrains+Mono&family=Inter:wght@400;500;600&display=swap'); + +:root { + --primary: #10b981; + --primary-hover: #059669; + --primary-muted: rgba(16,185,129,0.1); + --danger: #ef4444; + --warning: #f59e0b; + --info: #3b82f6; + --success: #22c55e; + + /* Dark (default) */ + --bg-app: #0a0f1a; + --bg-card: rgba(15,23,42,0.8); + --bg-sidebar: #060b14; + --bg-input: rgba(15,23,42,0.6); + --border: rgba(51,65,85,0.6); + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #475569; + --glass: rgba(15,23,42,0.6); + --glass-border: rgba(255,255,255,0.06); + --shadow-glow: 0 0 40px rgba(16,185,129,0.08); +} + +[data-theme="light"] { + --bg-app: #f1f5f9; + --bg-card: #ffffff; + --bg-sidebar: #ffffff; + --bg-input: #f8fafc; + --border: #e2e8f0; + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #94a3b8; + --glass: rgba(255,255,255,0.8); + --glass-border: rgba(0,0,0,0.04); + --shadow-glow: 0 4px 24px rgba(0,0,0,0.06); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Inter', 'IBM Plex Sans Arabic', sans-serif; +} + +body { + background-color: var(--bg-app); + color: var(--text-primary); + direction: rtl; + min-height: 100vh; + overflow-x: hidden; + transition: background-color 0.3s, color 0.3s; +} + +/* Glassmorphism Utilities */ +.glass { + background: var(--glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); +} + +.glow { + box-shadow: var(--shadow-glow); +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* RTL Specifics */ +[dir="rtl"] .ml-auto { margin-right: auto; margin-left: 0; } +[dir="rtl"] .mr-auto { margin-left: auto; margin-right: 0; } diff --git a/public/assets/js/api.js b/public/assets/js/api.js new file mode 100644 index 0000000..5f1ec13 --- /dev/null +++ b/public/assets/js/api.js @@ -0,0 +1,76 @@ +/** + * مُصادَق — API Client with JWT Auth & Refresh Flow + */ +const API = { + baseUrl: '/api/v1', + accessToken: localStorage.getItem('access_token'), + + async get(path) { + return this._request('GET', path); + }, + + async post(path, body) { + return this._request('POST', path, body); + }, + + async upload(path, formData) { + return this._request('POST', path, formData, true); + }, + + async _request(method, path, body = null, isFormData = false) { + const headers = { + 'Accept': 'application/json', + }; + + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + + if (!isFormData && body) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(body); + } + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body + }); + + if (response.status === 401) { + // Try refresh token + const refreshed = await this.refreshToken(); + if (refreshed) { + return this._request(method, path, body, isFormData); + } else { + window.location.href = '/login'; + } + } + + const data = await response.json(); + if (!response.ok) throw data; + return data; + } catch (error) { + console.error('API Error:', error); + throw error; + } + }, + + async refreshToken() { + try { + const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST' }); + if (response.ok) { + const result = await response.json(); + this.accessToken = result.data.access_token; + localStorage.setItem('access_token', this.accessToken); + return true; + } + } catch (e) { + return false; + } + return false; + } +}; + +export default API; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..27a6e9e --- /dev/null +++ b/public/index.php @@ -0,0 +1,74 @@ +getRouter(); + +// ══ Auth Routes ══════════════════════════════════════════════ +$router->addRoute('POST', '/api/v1/auth/login', [AuthController::class, 'login']); + +// ══ Company Routes ═══════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/companies', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Companies\CompanyController::class, 'list'] +]); +$router->addRoute('POST', '/api/v1/companies', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Companies\CompanyController::class, 'create'] +]); +$router->addRoute('PUT', '/api/v1/companies/{id}/jofotara', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Companies\CompanyController::class, 'updateJoFotara'] +]); + +// ══ Invoice Routes ═══════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/invoices', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'list'] +]); +$router->addRoute('POST', '/api/v1/invoices/upload', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'upload'] +]); +$router->addRoute('GET', '/api/v1/invoices/{id}', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'detail'] +]); + +// ══ External API (HMAC) ══════════════════════════════════════ +$router->addRoute('POST', '/api/v1/external/invoices/upload', [ + 'middleware' => [\App\Middleware\HmacMiddleware::class], + 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'upload'] +]); + +// ══ Dashboard ════════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/dashboard', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class], + 'handler' => [\App\Modules\Dashboard\DashboardController::class, 'getStats'] +]); + +// ══ Health Check ═════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/health', function($request) { + \App\Core\Response::json([ + 'status' => 'ok', + 'timestamp' => date('c'), + 'php' => PHP_VERSION, + 'db' => 'connected' // Simple check + ]); +}); + +// ══ SPA Shell ═══════════════════════════════════════════════ +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); +if (!str_starts_with($path, '/api/v1/')) { + include __DIR__ . '/shell.php'; + exit; +} + +$app->run(); diff --git a/public/shell.php b/public/shell.php new file mode 100644 index 0000000..fd0aaee --- /dev/null +++ b/public/shell.php @@ -0,0 +1,208 @@ + + + + + + مُصادَق — أتمتة الفوترة الضريبية + + + + + + + + + + + +
+ + + + +
+ + + +
+

+ + مُساعد مُصادَق الذكي +

+ +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/queue/worker.php b/queue/worker.php new file mode 100644 index 0000000..9dab516 --- /dev/null +++ b/queue/worker.php @@ -0,0 +1,43 @@ +getMessage()}\n"; + // Handle retries or DLQ + } + } else { + // Sleep if no jobs + usleep(500000); // 0.5s + } +} + +echo "[*] Worker stopped.\n";