🚀 مُصادَق: تحديث برمجي جديد 2026-05-03 13:19
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
37
app/Core/helpers.php
Normal file
37
app/Core/helpers.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!function_exists('config')) {
|
||||
/**
|
||||
* Get a configuration value using dot notation.
|
||||
* Example: config('app.name')
|
||||
*/
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$configs = \App\Core\Application::$config;
|
||||
|
||||
if ($configs === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$parts = explode('.', $key);
|
||||
$value = $configs;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($value[$part])) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_ENV[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
43
app/Middleware/CsrfMiddleware.php
Normal file
43
app/Middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
|
||||
final class CsrfMiddleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): mixed
|
||||
{
|
||||
// Skip CSRF check for safe methods
|
||||
if (in_array($request->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);
|
||||
}
|
||||
}
|
||||
37
app/Middleware/RoleMiddleware.php
Normal file
37
app/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
|
||||
final class RoleMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle the request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param callable $next
|
||||
* @param string ...$roles
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, callable $next, string ...$roles): mixed
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
43
app/Middleware/TenantMiddleware.php
Normal file
43
app/Middleware/TenantMiddleware.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response, Database};
|
||||
|
||||
final class TenantMiddleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): mixed
|
||||
{
|
||||
$tenantId = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
29
app/Modules/Subscriptions/SubscriptionController.php
Normal file
29
app/Modules/Subscriptions/SubscriptionController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Subscriptions;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Subscriptions\SubscriptionModel;
|
||||
|
||||
final class SubscriptionController
|
||||
{
|
||||
public function __construct(private readonly SubscriptionModel $subscriptionModel) {}
|
||||
|
||||
public function me(Request $request): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$subscription = $this->subscriptionModel->findByTenantId($tenantId);
|
||||
|
||||
if (!$subscription) {
|
||||
Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $subscription
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Modules/Subscriptions/SubscriptionModel.php
Normal file
19
app/Modules/Subscriptions/SubscriptionModel.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Subscriptions;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
final class SubscriptionModel extends BaseModel
|
||||
{
|
||||
protected string $table = 'subscriptions';
|
||||
|
||||
public function findByTenantId(string $tenantId): ?array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1");
|
||||
$stmt->execute([$tenantId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
29
app/Modules/Tenants/TenantController.php
Normal file
29
app/Modules/Tenants/TenantController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Tenants;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Tenants\TenantModel;
|
||||
|
||||
final class TenantController
|
||||
{
|
||||
public function __construct(private readonly TenantModel $tenantModel) {}
|
||||
|
||||
public function me(Request $request): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$tenant = $this->tenantModel->find($tenantId);
|
||||
|
||||
if (!$tenant) {
|
||||
Response::error('المستأجر غير موجود', 'NOT_FOUND', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $tenant
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Modules/Tenants/TenantModel.php
Normal file
19
app/Modules/Tenants/TenantModel.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Tenants;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
final class TenantModel extends BaseModel
|
||||
{
|
||||
protected string $table = 'tenants';
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
42
app/Modules/Users/UserController.php
Normal file
42
app/Modules/Users/UserController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Users;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Users\UserModel;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(private readonly UserModel $userModel) {}
|
||||
|
||||
public function index(Request $request): void
|
||||
{
|
||||
$tenantId = $request->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
10
config/app.php
Normal file
10
config/app.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => $_ENV['APP_NAME'] ?? 'مُصادَق',
|
||||
'env' => $_ENV['APP_ENV'] ?? 'production',
|
||||
'url' => $_ENV['APP_URL'] ?? 'https://musadeq2.intaleqapp.com',
|
||||
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Asia/Amman',
|
||||
];
|
||||
11
config/auth.php
Normal file
11
config/auth.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'jwt' => [
|
||||
'secret' => $_ENV['JWT_SECRET'] ?? '',
|
||||
'access_expiry' => (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900),
|
||||
'refresh_expiry' => (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800),
|
||||
],
|
||||
];
|
||||
12
config/database.php
Normal file
12
config/database.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'host' => $_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',
|
||||
];
|
||||
7
config/secrets.php
Normal file
7
config/secrets.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'encryption_key' => 'bgMQU/L8QYMd+8Sqh3AvsAXi+Fr+fMyJO+VAdakVoc8=',
|
||||
];
|
||||
28
config/services.php
Normal file
28
config/services.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'ai' => [
|
||||
'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'] ?? 'مُصادَق',
|
||||
],
|
||||
];
|
||||
41
database/migrations/001_initial_schema.sql
Normal file
41
database/migrations/001_initial_schema.sql
Normal file
@@ -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;
|
||||
47
database/migrations/002_core_modules.sql
Normal file
47
database/migrations/002_core_modules.sql
Normal file
@@ -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;
|
||||
69
database/migrations/003_invoices.sql
Normal file
69
database/migrations/003_invoices.sql
Normal file
@@ -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;
|
||||
80
database/migrations/004_system.sql
Normal file
80
database/migrations/004_system.sql
Normal file
@@ -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;
|
||||
26
database/seed.sql
Normal file
26
database/seed.sql
Normal file
@@ -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'
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
102
public/index.html
Normal file
102
public/index.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl" x-data="{ darkMode: true }" :class="{ 'dark': darkMode }">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>مُصادَق — أتمتة الفواتير الضريبية</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Noto+Sans+Arabic:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
body { font-family: 'Noto Sans Arabic', 'Outfit', sans-serif; }
|
||||
.glass { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); }
|
||||
.dark .glass { background: rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
</style>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e' },
|
||||
accent: '#FFD700'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 min-h-screen transition-colors duration-500 overflow-x-hidden">
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="sticky top-0 z-50 glass px-6 py-4 flex justify-between items-center mx-4 mt-4 rounded-2xl shadow-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/30">
|
||||
<span class="text-white font-bold text-xl">م</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-indigo-500 dark:from-primary-400 dark:to-indigo-400">مُصادَق</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="darkMode = !darkMode" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-slate-800 transition-all">
|
||||
<template x-if="!darkMode">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
||||
</template>
|
||||
<template x-if="darkMode">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M16.071 16.071l.707.707M7.929 7.929l.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"></path></svg>
|
||||
</template>
|
||||
</button>
|
||||
<a href="#login" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-xl font-semibold transition-all shadow-lg shadow-primary-600/20">دخول</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-12">
|
||||
<section class="text-center py-20 relative">
|
||||
<div class="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-primary-500/20 blur-[100px] rounded-full"></div>
|
||||
<h2 class="text-5xl md:text-7xl font-extrabold mb-6 leading-tight">
|
||||
أتمتة <span class="text-primary-500">الفواتير</span> <br>بذكاء اصطناعي فائق
|
||||
</h2>
|
||||
<p class="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
مُصادَق هو شريكك التقني المعتمد للربط مع نظام "جوفوتارا" الأردني، استخرج بيانات فواتيرك آلياً وامتثل للأنظمة الضريبية بثوانٍ.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<button class="px-10 py-4 bg-slate-900 dark:bg-white dark:text-slate-900 text-white rounded-2xl font-bold text-lg hover:scale-105 transition-all shadow-2xl">ابدأ التجربة المجانية</button>
|
||||
<button class="px-10 py-4 glass rounded-2xl font-bold text-lg hover:bg-gray-100 dark:hover:bg-slate-800 transition-all">شاهد العرض</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<section class="grid md:grid-cols-3 gap-8 py-20">
|
||||
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
|
||||
<div class="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">استخراج ذكي (OCR)</h3>
|
||||
<p class="text-slate-500">استخدام Gemini 2.0 لاستخراج كافة بنود الفواتير من الصور والـ PDF بدقة تصل لـ 99%.</p>
|
||||
</div>
|
||||
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
|
||||
<div class="w-14 h-14 bg-green-100 dark:bg-green-900/30 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">توافق جو-فواتير</h3>
|
||||
<p class="text-slate-500">ربط مباشر مع منصة الفوترة الوطنية الأردنية وإصدار ملفات UBL 2.1 المعتمدة.</p>
|
||||
</div>
|
||||
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
|
||||
<div class="w-14 h-14 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-3">حماية البيانات</h3>
|
||||
<p class="text-slate-500">تشفير AES-256 للبيانات الحساسة وعزل كامل لبيانات المستأجرين (Multi-tenancy).</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="py-10 text-center text-slate-500 text-sm">
|
||||
<p>© 2026 مُصادَق — جميع الحقوق محفوظة لشركة انتاليك للحلول البرمجية</p>
|
||||
</footer>
|
||||
|
||||
<script src="assets/js/api.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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],
|
||||
|
||||
48
queue/Jobs/ExtractInvoiceJob.php
Normal file
48
queue/Jobs/ExtractInvoiceJob.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Queue\Jobs;
|
||||
|
||||
use App\Modules\Invoices\InvoiceModel;
|
||||
use App\Services\AiExtractionService;
|
||||
use Throwable;
|
||||
|
||||
final class ExtractInvoiceJob
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvoiceModel $invoiceModel,
|
||||
private readonly AiExtractionService $aiExtraction
|
||||
) {}
|
||||
|
||||
public function handle(array $payload): void
|
||||
{
|
||||
$invoiceId = $payload['invoice_id'];
|
||||
$filePath = $payload['file_path'];
|
||||
$mimeType = $payload['mime_type'];
|
||||
|
||||
// Update status to extracting
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
64
scripts/migrate.php
Normal file
64
scripts/migrate.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../app/Core/helpers.php';
|
||||
|
||||
use App\Core\{Application, Database};
|
||||
|
||||
// Initialize app to load .env and configs
|
||||
$app = new Application(dirname(__DIR__));
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||||
echo "🗄️ Musadaq Migration Tool\n";
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Create migrations table if not exists
|
||||
$db->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";
|
||||
Reference in New Issue
Block a user