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";