diff --git a/app/Core/Application.php b/app/Core/Application.php index fb82df1..c6f7ace 100644 --- a/app/Core/Application.php +++ b/app/Core/Application.php @@ -11,14 +11,13 @@ final class Application { private Container $container; private Router $router; + public static ?array $config = null; public function __construct(string $basePath) { - // 1. Load Environment Variables (Point to the specific 'env' folder outside htdocs) - // Path: /home/intaleqapp-musadaq/env/ - $envPath = dirname(dirname(dirname($basePath))) . '/env'; - - $dotenv = Dotenv::createImmutable($envPath); + // 1. Load Environment Variables + // In local dev, .env is in the project root. In production, it might be moved. + $dotenv = Dotenv::createImmutable($basePath); $dotenv->load(); // 2. Set Timezone @@ -26,6 +25,10 @@ final class Application // 3. Initialize Core Components $this->container = new Container(); + + // 4. Load Configurations + $this->loadConfigs($basePath); + $this->router = new Router($this->container); // Register core services in container @@ -33,6 +36,20 @@ final class Application $this->container->set(Router::class, $this->router); } + private function loadConfigs(string $basePath): void + { + $configPath = $basePath . '/config'; + $configs = []; + + foreach (glob($configPath . '/*.php') as $file) { + $key = basename($file, '.php'); + $configs[$key] = require $file; + } + + self::$config = $configs; + $this->container->set('config', $configs); + } + public function getRouter(): Router { return $this->router; @@ -40,6 +57,16 @@ final class Application public function run(): void { + // 1. Security Headers + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('X-XSS-Protection: 1; mode=block'); + header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'); + header('Referrer-Policy: strict-origin-when-cross-origin'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); + header('Content-Security-Policy: default-src \'self\'; script-src \'self\' cdn.tailwindcss.com unpkg.com; style-src \'self\' \'unsafe-inline\' fonts.googleapis.com; font-src fonts.gstatic.com'); + header_remove('X-Powered-By'); + try { $request = new Request(); $this->router->dispatch($request, $this->container); diff --git a/app/Core/Router.php b/app/Core/Router.php index c98ce0f..3a5b2c0 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -67,8 +67,12 @@ final class Router array_reverse($middlewares), function ($next, $middleware) use ($container) { return function ($request) use ($next, $middleware, $container) { - $instance = $container->get($middleware); - return $instance->handle($request, $next); + $parts = explode(':', $middleware); + $className = $parts[0]; + $args = isset($parts[1]) ? explode(',', $parts[1]) : []; + + $instance = $container->get($className); + return $instance->handle($request, $next, ...$args); }; }, function ($request) use ($handler, $container, $vars) { diff --git a/app/Core/helpers.php b/app/Core/helpers.php new file mode 100644 index 0000000..5d29393 --- /dev/null +++ b/app/Core/helpers.php @@ -0,0 +1,37 @@ +getMethod(), ['GET', 'HEAD', 'OPTIONS'])) { + return $next($request); + } + + // For APIs, we often use a custom header or check origin + // If we use sessions for tokens: + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + + $token = $request->getHeader('X-CSRF-TOKEN') ?: ($request->getBody()['_csrf'] ?? null); + $sessionToken = $_SESSION['csrf_token'] ?? null; + + if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) { + // For now, if we are purely API with Bearer token, we might skip this. + // But if the request has a session or cookie, it's mandatory. + + // If the Authorization header is present, we might assume it's an API call + // that is naturally protected against CSRF if not using cookies for Auth. + if ($request->getHeader('Authorization')) { + return $next($request); + } + + Response::error('رمز الحماية (CSRF) غير صالح أو مفقود', 'CSRF_INVALID', 403); + return null; + } + + return $next($request); + } +} diff --git a/app/Middleware/RoleMiddleware.php b/app/Middleware/RoleMiddleware.php new file mode 100644 index 0000000..24f1512 --- /dev/null +++ b/app/Middleware/RoleMiddleware.php @@ -0,0 +1,37 @@ +user ?? null; + + if (!$user) { + Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401); + return null; + } + + // Check if user role is in the allowed roles + // $user->role is an object property since we cast it in AuthMiddleware + if (!in_array($user->role, $roles)) { + Response::error('غير مسموح لك بالقيام بهذا الإجراء', 'FORBIDDEN', 403); + return null; + } + + return $next($request); + } +} diff --git a/app/Middleware/TenantMiddleware.php b/app/Middleware/TenantMiddleware.php new file mode 100644 index 0000000..7ec756b --- /dev/null +++ b/app/Middleware/TenantMiddleware.php @@ -0,0 +1,43 @@ +tenantId ?? null; + + if (!$tenantId) { + Response::error('المستأجر غير معروف', 'TENANT_NOT_FOUND', 400); + return null; + } + + // Check if tenant exists and is active + try { + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT status FROM tenants WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$tenantId]); + $tenant = $stmt->fetch(); + + if (!$tenant) { + Response::error('المستأجر غير موجود', 'TENANT_NOT_FOUND', 404); + return null; + } + + if ($tenant['status'] === 'suspended') { + Response::error('تم إيقاف حساب المستأجر', 'TENANT_SUSPENDED', 403); + return null; + } + } catch (\Exception $e) { + Response::error('خطأ في الاتصال بقاعدة البيانات', 'DATABASE_ERROR', 500); + return null; + } + + return $next($request); + } +} diff --git a/app/Modules/Auth/AuthController.php b/app/Modules/Auth/AuthController.php index 24cb7c9..e088643 100644 --- a/app/Modules/Auth/AuthController.php +++ b/app/Modules/Auth/AuthController.php @@ -53,4 +53,79 @@ final class AuthController 'data' => $request->user ]); } + + public function logout(Request $request): void + { + // Clear refresh token cookie + setcookie('refresh_token', '', [ + 'expires' => time() - 3600, + 'path' => '/api/v1/auth/refresh', + 'httponly' => true, + 'samesite' => 'Strict', + 'secure' => true + ]); + + Response::json([ + 'success' => true, + 'message' => 'تم تسجيل الخروج بنجاح' + ]); + } + + public function refresh(Request $request): void + { + $refreshToken = $_COOKIE['refresh_token'] ?? null; + + if (!$refreshToken) { + Response::error('رمز التجديد مفقود', 'UNAUTHORIZED', 401); + return; + } + + try { + $result = $this->authService->refresh($refreshToken); + + // Set new 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(), 'REFRESH_FAILED', 401); + } + } + public function register(Request $request): void + { + try { + $result = $this->authService->register($request->getBody()); + + // 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(), 'REGISTRATION_FAILED', 400); + } + } } diff --git a/app/Modules/Auth/AuthService.php b/app/Modules/Auth/AuthService.php index 73385eb..6e8c823 100644 --- a/app/Modules/Auth/AuthService.php +++ b/app/Modules/Auth/AuthService.php @@ -5,14 +5,19 @@ declare(strict_types=1); namespace App\Modules\Auth; use App\Modules\Users\UserModel; +use App\Modules\Tenants\TenantModel; +use App\Modules\Subscriptions\SubscriptionModel; use App\Services\Security\JwtService; +use Ramsey\Uuid\Uuid; use Exception; final class AuthService { public function __construct( private readonly UserModel $userModel, - private readonly JwtService $jwtService + private readonly JwtService $jwtService, + private readonly TenantModel $tenantModel, + private readonly SubscriptionModel $subscriptionModel ) {} public function login(string $email, string $password): array @@ -55,4 +60,88 @@ final class AuthService ] ]; } + + public function refresh(string $refreshToken): array + { + $parts = explode('.', $refreshToken); + if (count($parts) !== 2) { + throw new Exception("رمز التجديد غير صالحة"); + } + + [$userId, $random] = $parts; + $user = $this->userModel->find($userId); + + if (!$user || !$user['is_active']) { + throw new Exception("المستخدم غير موجود أو معطل"); + } + + if (!$user['refresh_token_hash'] || !password_verify($refreshToken, $user['refresh_token_hash'])) { + throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى"); + } + + $accessToken = $this->jwtService->issueAccessToken([ + 'user_id' => $user['id'], + 'tenant_id' => $user['tenant_id'], + 'role' => $user['role'], + 'assigned_company_id' => $user['assigned_company_id'] + ]); + + $newRefreshToken = $this->jwtService->issueRefreshToken($user['id']); + + $this->userModel->update($user['id'], [ + 'refresh_token_hash' => password_hash($newRefreshToken, PASSWORD_BCRYPT) + ]); + + return [ + 'access_token' => $accessToken, + 'refresh_token' => $newRefreshToken, + 'user' => [ + 'id' => $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + 'role' => $user['role'], + 'assigned_company_id' => $user['assigned_company_id'] + ] + ]; + } + + public function register(array $data): array + { + // 1. Check if tenant already exists + if ($this->tenantModel->findByEmail($data['email'])) { + throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً"); + } + + $tenantId = Uuid::uuid4()->toString(); + $userId = Uuid::uuid4()->toString(); + + // 2. Create Tenant + $this->tenantModel->create([ + 'id' => $tenantId, + 'name' => $data['tenant_name'], + 'email' => $data['email'], + 'status' => 'trial', + 'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days')) + ]); + + // 3. Create Subscription + $this->subscriptionModel->create([ + 'tenant_id' => $tenantId, + 'plan' => 'basic', + 'status' => 'trial' + ]); + + // 4. Create User + $this->userModel->create([ + 'id' => $userId, + 'tenant_id' => $tenantId, + 'name' => $data['user_name'], + 'email' => $data['email'], + 'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID), + 'role' => 'admin', + 'is_active' => 1 + ]); + + return $this->login($data['email'], $data['password']); + } } diff --git a/app/Modules/Invoices/InvoiceController.php b/app/Modules/Invoices/InvoiceController.php index a8abc73..5e6d720 100644 --- a/app/Modules/Invoices/InvoiceController.php +++ b/app/Modules/Invoices/InvoiceController.php @@ -72,38 +72,18 @@ final class InvoiceController 'idempotency_key' => bin2hex(random_bytes(16)) ]); - // Attempt AI Extraction - try { - $mimeType = mime_content_type($filePath); - $extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType); - - // Update Invoice with extracted data - $this->invoiceModel->update($invoiceId, [ - 'status' => 'extracted', // Match schema ENUM - 'ai_raw_response' => json_encode($extractedData, JSON_UNESCAPED_UNICODE) - ]); + // Push to Queue for AI Extraction + \App\Services\QueueService::push('invoice_extraction', [ + 'invoice_id' => $invoiceId, + 'file_path' => $filePath, + 'mime_type' => mime_content_type($filePath) + ]); - Response::json([ - 'success' => true, - 'data' => [ - 'invoice_id' => $invoiceId, - 'extracted_data' => $extractedData - ], - 'message' => 'تم رفع الفاتورة واستخراج البيانات بنجاح بالذكاء الاصطناعي' - ]); - - } catch (Throwable $aiError) { - // Keep it uploaded, maybe manual retry later - $this->invoiceModel->update($invoiceId, [ - 'status' => 'validation_failed' // Match schema fallback - ]); - - Response::json([ - 'success' => true, - 'data' => ['invoice_id' => $invoiceId], - 'message' => 'تم الرفع ولكن فشل استخراج البيانات. ' . $aiError->getMessage() - ]); - } + Response::json([ + 'success' => true, + 'data' => ['invoice_id' => $invoiceId], + 'message' => 'تم رفع الفاتورة بنجاح وجاري استخراج البيانات بالذكاء الاصطناعي' + ], 202); } catch (Throwable $e) { Response::error($e->getMessage(), 'UPLOAD_FAILED', 500); diff --git a/app/Modules/Subscriptions/SubscriptionController.php b/app/Modules/Subscriptions/SubscriptionController.php new file mode 100644 index 0000000..769b0bf --- /dev/null +++ b/app/Modules/Subscriptions/SubscriptionController.php @@ -0,0 +1,29 @@ +tenantId; + $subscription = $this->subscriptionModel->findByTenantId($tenantId); + + if (!$subscription) { + Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404); + return; + } + + Response::json([ + 'success' => true, + 'data' => $subscription + ]); + } +} diff --git a/app/Modules/Subscriptions/SubscriptionModel.php b/app/Modules/Subscriptions/SubscriptionModel.php new file mode 100644 index 0000000..cf895a0 --- /dev/null +++ b/app/Modules/Subscriptions/SubscriptionModel.php @@ -0,0 +1,19 @@ +db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1"); + $stmt->execute([$tenantId]); + return $stmt->fetch() ?: null; + } +} diff --git a/app/Modules/Tenants/TenantController.php b/app/Modules/Tenants/TenantController.php new file mode 100644 index 0000000..18b5de4 --- /dev/null +++ b/app/Modules/Tenants/TenantController.php @@ -0,0 +1,29 @@ +tenantId; + $tenant = $this->tenantModel->find($tenantId); + + if (!$tenant) { + Response::error('المستأجر غير موجود', 'NOT_FOUND', 404); + return; + } + + Response::json([ + 'success' => true, + 'data' => $tenant + ]); + } +} diff --git a/app/Modules/Tenants/TenantModel.php b/app/Modules/Tenants/TenantModel.php new file mode 100644 index 0000000..6e792a5 --- /dev/null +++ b/app/Modules/Tenants/TenantModel.php @@ -0,0 +1,19 @@ +db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->execute([$email]); + return $stmt->fetch() ?: null; + } +} diff --git a/app/Modules/Users/UserController.php b/app/Modules/Users/UserController.php new file mode 100644 index 0000000..cf89589 --- /dev/null +++ b/app/Modules/Users/UserController.php @@ -0,0 +1,42 @@ +tenantId; + $users = $this->userModel->findAllByTenant($tenantId); + + Response::json([ + 'success' => true, + 'data' => $users + ]); + } + + public function detail(Request $request, array $vars): void + { + $tenantId = $request->tenantId; + $userId = $vars['id']; + + $user = $this->userModel->findById($userId, $tenantId); + + if (!$user) { + Response::error('المستخدم غير موجود', 'NOT_FOUND', 404); + return; + } + + Response::json([ + 'success' => true, + 'data' => $user + ]); + } +} diff --git a/app/Modules/Users/UserModel.php b/app/Modules/Users/UserModel.php index 450d82b..1e3bc0b 100644 --- a/app/Modules/Users/UserModel.php +++ b/app/Modules/Users/UserModel.php @@ -24,4 +24,18 @@ final class UserModel extends BaseModel $stmt->execute($params); return $stmt->fetch() ?: null; } + + public function findAllByTenant(string $tenantId): array + { + $stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL"); + $stmt->execute([$tenantId]); + return $stmt->fetchAll(); + } + + public function findById(string $id, string $tenantId): ?array + { + $stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->execute([$id, $tenantId]); + return $stmt->fetch() ?: null; + } } diff --git a/app/Services/Security/JwtService.php b/app/Services/Security/JwtService.php index 724dfc0..ac46c59 100644 --- a/app/Services/Security/JwtService.php +++ b/app/Services/Security/JwtService.php @@ -32,8 +32,9 @@ final class JwtService public function issueRefreshToken(string $userId): string { - // Refresh token is a random string stored in DB (hashed) - return bin2hex(random_bytes(64)); + // Refresh token is a random string prefixed with userId for lookup + $random = bin2hex(random_bytes(32)); + return $userId . '.' . $random; } public function verifyToken(string $token): array diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..2490e65 --- /dev/null +++ b/config/app.php @@ -0,0 +1,10 @@ + $_ENV['APP_NAME'] ?? 'مُصادَق', + 'env' => $_ENV['APP_ENV'] ?? 'production', + 'url' => $_ENV['APP_URL'] ?? 'https://musadeq2.intaleqapp.com', + 'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Asia/Amman', +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..a63106f --- /dev/null +++ b/config/auth.php @@ -0,0 +1,11 @@ + [ + 'secret' => $_ENV['JWT_SECRET'] ?? '', + 'access_expiry' => (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900), + 'refresh_expiry' => (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800), + ], +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..8aa9835 --- /dev/null +++ b/config/database.php @@ -0,0 +1,12 @@ + $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? 'musadaq_db', + 'username' => $_ENV['DB_USERNAME'] ?? 'musadaq_user', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', +]; diff --git a/config/secrets.php b/config/secrets.php new file mode 100644 index 0000000..72b76c2 --- /dev/null +++ b/config/secrets.php @@ -0,0 +1,7 @@ + 'bgMQU/L8QYMd+8Sqh3AvsAXi+Fr+fMyJO+VAdakVoc8=', +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..8621264 --- /dev/null +++ b/config/services.php @@ -0,0 +1,28 @@ + [ + 'gemini' => [ + 'key' => $_ENV['GEMINI_API_KEY'] ?? '', + 'model' => $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash', + ], + 'openai' => [ + 'key' => $_ENV['OPENAI_API_KEY'] ?? '', + 'model' => $_ENV['OPENAI_MODEL'] ?? 'gpt-4o', + ], + ], + 'jofotara' => [ + 'base_url' => $_ENV['JOFOTARA_BASE_URL'] ?? 'https://backend.jofotara.gov.jo/core/invoices', + 'env' => $_ENV['JOFOTARA_ENV'] ?? 'production', + ], + 'mail' => [ + 'host' => $_ENV['MAIL_HOST'] ?? '', + 'port' => (int)($_ENV['MAIL_PORT'] ?? 587), + 'username' => $_ENV['MAIL_USERNAME'] ?? '', + 'password' => $_ENV['MAIL_PASSWORD'] ?? '', + 'from' => $_ENV['MAIL_FROM'] ?? 'noreply@musadaq.app', + 'from_name' => $_ENV['MAIL_FROM_NAME'] ?? 'مُصادَق', + ], +]; diff --git a/database/migrations/001_initial_schema.sql b/database/migrations/001_initial_schema.sql new file mode 100644 index 0000000..39734e5 --- /dev/null +++ b/database/migrations/001_initial_schema.sql @@ -0,0 +1,41 @@ +-- ─── Tenants ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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; diff --git a/database/migrations/002_core_modules.sql b/database/migrations/002_core_modules.sql new file mode 100644 index 0000000..f413544 --- /dev/null +++ b/database/migrations/002_core_modules.sql @@ -0,0 +1,47 @@ +-- ─── Companies ──────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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; diff --git a/database/migrations/003_invoices.sql b/database/migrations/003_invoices.sql new file mode 100644 index 0000000..c1e70a8 --- /dev/null +++ b/database/migrations/003_invoices.sql @@ -0,0 +1,69 @@ +-- ─── Invoices ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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; diff --git a/database/migrations/004_system.sql b/database/migrations/004_system.sql new file mode 100644 index 0000000..b2771d4 --- /dev/null +++ b/database/migrations/004_system.sql @@ -0,0 +1,80 @@ +-- ─── Audit Logs ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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; + +-- ─── API Keys ───────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS 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; diff --git a/database/seed.sql b/database/seed.sql new file mode 100644 index 0000000..38415c6 --- /dev/null +++ b/database/seed.sql @@ -0,0 +1,26 @@ +-- ─── 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', + '$argon2id$v=19$m=65536,t=3,p=4$VEpSbmRXNXBaV3REYTJodg$jZ8/X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xg', -- Placeholder hash + '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/assets/js/api.js b/public/assets/js/api.js index 779a3da..9570c51 100644 --- a/public/assets/js/api.js +++ b/public/assets/js/api.js @@ -1,73 +1,55 @@ -/** - * مُصادَق — API Client with JWT Auth & Refresh Flow - */ -window.API = { - baseUrl: '/Application/public/api.php', - 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 API = { + baseUrl: '/api/v1', + + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + const token = localStorage.getItem('access_token'); + const headers = { 'Accept': 'application/json', + ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...options.headers }; - 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) { - const refreshed = await this.refreshToken(); - if (refreshed) { - return this._request(method, path, body, isFormData); - } else { - window.location.href = '/login'; - } + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401 && !options._retry) { + // Attempt token refresh + const refreshed = await this.refresh(); + if (refreshed) { + return this.request(endpoint, { ...options, _retry: true }); } - - const data = await response.json(); - if (!response.ok) throw data; - return data; - } catch (error) { - console.error('API Error:', error); - throw error; } + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || 'حدث خطأ ما'); + } + return data; }, - async refreshToken() { + async login(email, password) { + const data = await this.request('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + localStorage.setItem('access_token', data.data.access_token); + return data; + }, + + async refresh() { 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); + const data = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST' }); + if (data.ok) { + const result = await data.json(); + localStorage.setItem('access_token', result.data.access_token); return true; } } catch (e) { - return false; + console.error('Refresh failed', e); } + localStorage.removeItem('access_token'); return false; } }; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2fbecea --- /dev/null +++ b/public/index.html @@ -0,0 +1,102 @@ + + + + + + مُصادَق — أتمتة الفواتير الضريبية + + + + + + + + + + + + +
+
+
+

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

+

+ مُصادَق هو شريكك التقني المعتمد للربط مع نظام "جوفوتارا" الأردني، استخرج بيانات فواتيرك آلياً وامتثل للأنظمة الضريبية بثوانٍ. +

+
+ + +
+
+ + +
+
+
+ +
+

استخراج ذكي (OCR)

+

استخدام Gemini 2.0 لاستخراج كافة بنود الفواتير من الصور والـ PDF بدقة تصل لـ 99%.

+
+
+
+ +
+

توافق جو-فواتير

+

ربط مباشر مع منصة الفوترة الوطنية الأردنية وإصدار ملفات UBL 2.1 المعتمدة.

+
+
+
+ +
+

حماية البيانات

+

تشفير AES-256 للبيانات الحساسة وعزل كامل لبيانات المستأجرين (Multi-tenancy).

+
+
+
+ + + + + + diff --git a/public/index.php b/public/index.php index f990e7a..3414a62 100644 --- a/public/index.php +++ b/public/index.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../app/Core/helpers.php'; use App\Core\Application; use App\Modules\Auth\AuthController; @@ -13,6 +14,7 @@ $router = $app->getRouter(); // ══ Auth Routes ══════════════════════════════════════════════ $router->addRoute('POST', '/api/v1/auth/login', [AuthController::class, 'login']); +$router->addRoute('POST', '/api/v1/auth/register', [AuthController::class, 'register']); // ══ Company Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/companies', [ @@ -52,6 +54,12 @@ $router->addRoute('GET', '/api/v1/invoices/{id}', [ 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'detail'] ]); +// ══ Subscriptions ═════════════════════════════════════════════════ +$router->addRoute('GET', '/api/v1/subscriptions/me', [ + 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], + 'handler' => [\App\Modules\Subscriptions\SubscriptionController::class, 'me'] +]); + // ══ External API (HMAC) ══════════════════════════════════════ $router->addRoute('POST', '/api/v1/external/invoices/upload', [ 'middleware' => [\App\Middleware\HmacMiddleware::class], diff --git a/queue/Jobs/ExtractInvoiceJob.php b/queue/Jobs/ExtractInvoiceJob.php new file mode 100644 index 0000000..9a7eaf1 --- /dev/null +++ b/queue/Jobs/ExtractInvoiceJob.php @@ -0,0 +1,48 @@ +invoiceModel->update($invoiceId, ['status' => 'extracting']); + + try { + $extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType); + + // Map AI data to schema columns if needed, or just store in ai_raw_response + $this->invoiceModel->update($invoiceId, [ + 'status' => 'extracted', + 'invoice_number' => $extractedData['invoice_number'] ?? null, + 'invoice_date' => $extractedData['invoice_date'] ?? null, + 'grand_total' => $extractedData['total_amount'] ?? 0, + 'tax_amount' => $extractedData['tax_amount'] ?? 0, + 'supplier_name' => $extractedData['vendor_name'] ?? null, + 'supplier_tin' => $extractedData['vendor_tax_number'] ?? null, + 'ai_raw_response' => json_encode($extractedData, JSON_UNESCAPED_UNICODE) + ]); + } catch (Throwable $e) { + $this->invoiceModel->update($invoiceId, [ + 'status' => 'validation_failed' + ]); + throw $e; + } + } +} diff --git a/queue/worker.php b/queue/worker.php index 9dab516..d94090c 100644 --- a/queue/worker.php +++ b/queue/worker.php @@ -26,16 +26,24 @@ while ($keepRunning) { if ($job) { echo "[+] Processing job: {$job['type']} ({$job['id']})\n"; try { - // Process based on type - // match($job['type']) { ... } + $container = $app->getContainer(); + + switch($job['type']) { + case 'invoice_extraction': + $handler = $container->get(\Queue\Jobs\ExtractInvoiceJob::class); + $handler->handle($job['payload']); + break; + + default: + echo "[!] Unknown job type: {$job['type']}\n"; + } echo "[✓] Job completed: {$job['id']}\n"; } catch (\Throwable $e) { echo "[✗] Job failed: {$job['id']} - {$e->getMessage()}\n"; - // Handle retries or DLQ + // In a real app, you'd handle retries or move to a failed_jobs table } } else { - // Sleep if no jobs usleep(500000); // 0.5s } } diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..524f4ac --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,64 @@ +exec("CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + + $stmt = $db->query("SELECT migration FROM migrations"); + $executed = $stmt->fetchAll(PDO::FETCH_COLUMN); + + $migrationsDir = dirname(__DIR__) . '/database/migrations'; + $files = glob($migrationsDir . '/*.sql'); + sort($files); // Ensure order + + $count = 0; + foreach ($files as $file) { + $name = basename($file); + if (!in_array($name, $executed)) { + echo "🚀 Running: $name... "; + + $sql = file_get_contents($file); + + // Execute the SQL. Since it might contain multiple statements, + // and PDO::exec doesn't always handle them well in one go + // depending on the driver, we'll try to run it. + $db->exec($sql); + + $stmt = $db->prepare("INSERT INTO migrations (migration) VALUES (?)"); + $stmt->execute([$name]); + + echo "✅ Done\n"; + $count++; + } + } + + if ($count === 0) { + echo "✨ Nothing to migrate. Database is up to date.\n"; + } else { + echo "🎉 Migrations completed successfully ($count ran).\n"; + } +} catch (Exception $e) { + echo "❌ Error: " . $e->getMessage() . "\n"; + exit(1); +} +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";