Files
musadaq-saas/PROJECT_DOCUMENTATION.md
2026-05-03 22:35:31 +03:00

2226 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Musadaq Project Documentation
This file contains the complete source code of the project (excluding dependencies and sensitive data).
## File: `push.sh`
```sh
#!/bin/bash
# Get current timestamp
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
echo "🚀 Starting Git Push Process..."
echo "📅 Timestamp: $TIMESTAMP"
# Add all changes
git add .
# Commit with timestamp
git commit -m "Update: $TIMESTAMP"
# Push to origin main explicitly
git push origin main
echo "✅ Done!"
```
## File: `composer.json`
```json
{
"name": "musadaq/platform",
"description": "Jordanian E-Invoicing Automation SaaS",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.4",
"ext-pdo": "*",
"ext-pdo_mysql": "*",
"ext-openssl": "*",
"ext-sodium": "*",
"ext-curl": "*",
"ext-mbstring": "*",
"ext-json": "*",
"vlucas/phpdotenv": "^5.6",
"monolog/monolog": "^3.5",
"firebase/php-jwt": "^6.10",
"ramsey/uuid": "^4.7",
"nikic/fast-route": "^1.3",
"predis/predis": "^2.2",
"guzzlehttp/guzzle": "^7.9",
"respect/validation": "^2.3",
"league/flysystem": "^3.28",
"symfony/mailer": "^7.1"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.12",
"squizlabs/php_codesniffer": "^3.10"
},
"autoload": {
"psr-4": { "App\\": "app/" }
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}
```
## File: `app/modules_app/auth/login.php`
```php
<?php
/**
* Auth Login Endpoint
*/
use App\Core\Database;
use App\Core\JWT;
use App\Core\Validator;
use App\Middleware\RateLimitMiddleware;
use App\Core\Security;
// 0. Rate Limiting (5 attempts per minute per IP)
RateLimitMiddleware::check(5, 60);
$data = Security::sanitize(input());
// 1. Validation
$errors = Validator::validate($data, [
'email' => 'required|email',
'password' => 'required'
]);
if ($errors) {
json_error('Validation Failed', 422, $errors);
}
$email = $data['email'];
$password = $data['password'];
// 2. DB Check (Using hash for lookup since email is encrypted)
$db = Database::getInstance();
$emailHash = hash('sha256', strtolower($email));
$stmt = $db->prepare("SELECT * FROM users WHERE email_hash = ? LIMIT 1");
$stmt->execute([$emailHash]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
json_error('بيانات الدخول غير صحيحة', 401);
}
// 3. Issue Token
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'exp' => time() + (15 * 60) // 15 minutes
];
$token = JWT::encode($payload, $secret);
// 4. Update Refresh Token (Hashed before storage for security)
$refreshToken = bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
$stmt->execute([$refreshTokenHash, $user['id']]);
json_success([
'access_token' => $token,
'refresh_token' => $refreshToken,
'user' => [
'id' => $user['id'],
'name' => (App\Core\Encryption::decrypt($user['name']) ?: $user['name']),
'email' => (App\Core\Encryption::decrypt($user['email']) ?: $user['email'])
]
], 'تم تسجيل الدخول بنجاح');
```
## File: `app/modules_app/auth/logout.php`
```php
<?php
/**
* Auth Logout Endpoint
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Check Authentication
$decoded = AuthMiddleware::check();
$userId = $decoded['user_id'];
// 2. Invalidate Refresh Token
$db = Database::getInstance();
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = NULL WHERE id = ?");
$stmt->execute([$userId]);
json_success(null, 'تم تسجيل الخروج بنجاح');
```
## File: `app/modules_app/auth/refresh.php`
```php
<?php
/**
* Auth Refresh Endpoint
*/
use App\Core\Database;
use App\Core\JWT;
$data = input();
$refreshToken = $data['refresh_token'] ?? null;
if (!$refreshToken) {
json_error('Refresh token is required', 400);
}
$db = Database::getInstance();
$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
$stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch();
if (!$user) {
json_error('Invalid refresh token', 401);
}
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short in .env');
json_error('Server configuration error', 500);
}
$payload = [
'user_id' => $user['id'],
'role' => $user['role'],
'exp' => time() + (15 * 60)
];
$newToken = JWT::encode($payload, $secret);
$newRefreshToken = bin2hex(random_bytes(32));
$newRefreshTokenHash = hash('sha256', $newRefreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
$stmt->execute([$newRefreshTokenHash, $user['id']]);
json_success([
'access_token' => $newToken,
'refresh_token' => $newRefreshToken
], 'تم تجديد الجلسة بنجاح');
```
## File: `app/modules_app/dashboard/stats.php`
```php
<?php
/**
* Dashboard Stats Endpoint
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
AuthMiddleware::check();
$db = Database::getInstance();
try {
// Total Invoices
$stmt = $db->query("SELECT COUNT(*) FROM invoices");
$total = $stmt->fetchColumn();
// Pending Invoices
$stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'pending'");
$pending = $stmt->fetchColumn();
// Approved Invoices
$stmt = $db->query("SELECT COUNT(*) FROM invoices WHERE status = 'approved'");
$approved = $stmt->fetchColumn();
} catch (\Exception $e) {
// Fallback if table doesn't exist yet
$total = 0;
$pending = 0;
$approved = 0;
}
json_success([
'total' => $total,
'pending' => $pending,
'approved' => $approved
]);
```
## File: `app/modules_app/users/index.php`
```php
<?php
/**
* Users List Endpoint (with Decryption)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
// 1. Auth Check
$decoded = AuthMiddleware::check();
// 2. Simple Role-Based Access Control (RBAC)
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('غير مصرح لك بالوصول لهذه البيانات', 403);
}
// 3. Fetch Data
$db = Database::getInstance();
$stmt = $db->prepare("SELECT id, name, email, role, is_active, created_at FROM users");
$stmt->execute();
$users = $stmt->fetchAll();
// 4. Decrypt sensitive data for the UI
foreach ($users as &$user) {
// Try to decrypt. If it fails (e.g. data was plain text), keep original.
$decryptedName = Encryption::decrypt($user['name']);
$user['name'] = $decryptedName !== false ? $decryptedName : $user['name'];
$decryptedEmail = Encryption::decrypt($user['email']);
$user['email'] = $decryptedEmail !== false ? $decryptedEmail : $user['email'];
}
json_success($users);
```
## File: `app/modules_app/users/create.php`
```php
<?php
/**
* Create User Endpoint (with Encryption)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Middleware\AuthMiddleware;
// 1. Auth Check (Only super_admin or admin can create users)
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('Unauthorized', 403);
}
$data = input();
// 2. Validation
$errors = Validator::validate($data, [
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
'role' => 'required'
]);
if ($errors) {
json_error('Validation Failed', 422, $errors);
}
$db = Database::getInstance();
// 3. Encrypt sensitive data
$encryptedName = Encryption::encrypt($data['name']);
$encryptedEmail = Encryption::encrypt($data['email']);
$emailHash = hash('sha256', strtolower($data['email'])); // For fast lookup during login
// 4. Save to Database
try {
$stmt = $db->prepare("INSERT INTO users (tenant_id, name, email, email_hash, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$decoded['tenant_id'],
$encryptedName,
$encryptedEmail,
$emailHash,
password_hash($data['password'], PASSWORD_DEFAULT),
$data['role'],
date('Y-m-d H:i:s')
]);
json_success(null, 'تم إضافة المستخدم بنجاح');
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Duplicate entry')) {
json_error('البريد الإلكتروني مسجل مسبقاً', 409);
}
json_error('حدث خطأ أثناء حفظ البيانات', 500);
}
```
## File: `app/modules_app/companies/index.php`
```php
<?php
/**
* List Companies Endpoint (Synchronized Schema)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
// 1. Super Admin sees ALL companies
if ($decoded['role'] === 'super_admin') {
$stmt = $db->query("SELECT * FROM companies WHERE deleted_at IS NULL");
}
// 2. Admin sees all companies in their tenant
else if ($decoded['role'] === 'admin') {
$stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND deleted_at IS NULL");
$stmt->execute([$decoded['tenant_id']]);
}
// 3. Others (accountant, etc) see only their assigned company
else {
// Need to get their assigned company_id from users table first
$stmtUser = $db->prepare("SELECT company_id FROM users WHERE id = ?");
$stmtUser->execute([$decoded['user_id']]);
$assignedCompanyId = $stmtUser->fetchColumn();
$stmt = $db->prepare("SELECT * FROM companies WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$assignedCompanyId]);
}
$companies = $stmt->fetchAll();
// 3. Decrypt fields
foreach ($companies as &$company) {
// Decrypt Name
$decryptedName = Encryption::decrypt($company['name']);
$company['name'] = $decryptedName !== false ? $decryptedName : $company['name'];
// Decrypt Name EN
if (!empty($company['name_en'])) {
$decryptedNameEn = Encryption::decrypt($company['name_en']);
$company['name_en'] = $decryptedNameEn !== false ? $decryptedNameEn : $company['name_en'];
}
// Redact JoFotara secrets if returned to UI (or just don't return them)
unset($company['jofotara_client_id_encrypted']);
unset($company['jofotara_secret_key_encrypted']);
unset($company['certificate_password_encrypted']);
}
json_success($companies);
```
## File: `app/modules_app/companies/create.php`
```php
<?php
/**
* Create Company Endpoint (Synchronized Schema)
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\Validator;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') {
json_error('Unauthorized', 403);
}
$data = input();
// 1. Validation
$errors = Validator::validate($data, [
'name' => 'required',
'tax_identification_number' => 'required'
]);
if ($errors) {
json_error('Validation Failed', 422, $errors);
}
$db = Database::getInstance();
try {
$db->beginTransaction();
// 2. Encrypt sensitive fields
$encryptedName = Encryption::encrypt($data['name']);
$encryptedNameEn = !empty($data['name_en']) ? Encryption::encrypt($data['name_en']) : null;
// Encrypt JoFotara keys if provided
$jofotaraClientId = !empty($data['jofotara_client_id']) ? Encryption::encrypt($data['jofotara_client_id']) : null;
$jofotaraSecretKey = !empty($data['jofotara_secret_key']) ? Encryption::encrypt($data['jofotara_secret_key']) : null;
// 3. Save to Database
$stmt = $db->prepare("
INSERT INTO companies (
tenant_id, name, name_en, tax_identification_number, commercial_registration_number,
city, address, contact_email, contact_phone,
jofotara_client_id_encrypted, jofotara_secret_key_encrypted,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$decoded['user_id'], // Using current admin as tenant_id
$encryptedName,
$encryptedNameEn,
$data['tax_identification_number'],
$data['commercial_registration_number'] ?? null,
$data['city'] ?? null,
$data['address'] ?? null,
$data['contact_email'] ?? null,
$data['contact_phone'] ?? null,
$jofotaraClientId,
$jofotaraSecretKey,
date('Y-m-d H:i:s')
]);
$db->commit();
json_success(null, 'تم إنشاء الشركة بنجاح');
} catch (\Exception $e) {
$db->rollBack();
json_error('حدث خطأ أثناء حفظ البيانات: ' . $e->getMessage(), 500);
}
```
## File: `app/middleware/HmacMiddleware.php`
```php
<?php
/**
* HMAC Request Signature Middleware
*
* Verifies that incoming requests are signed with a shared secret,
* preventing replay attacks and ensuring request integrity.
*
* Client must send:
* X-Timestamp: Unix timestamp (seconds)
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Security;
final class HmacMiddleware
{
/**
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
*/
public static function verify(int $maxAgeSeconds = 300): void
{
$headers = getallheaders();
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
// 1. Ensure both headers are present
if (empty($signature) || empty($timestamp)) {
json_error('Missing HMAC signature or timestamp', 401);
}
// 2. Validate timestamp is numeric
if (!ctype_digit((string)$timestamp)) {
json_error('Invalid timestamp format', 401);
}
// 3. Replay attack prevention — reject stale requests
$age = abs(time() - (int)$timestamp);
if ($age > $maxAgeSeconds) {
json_error('Request expired. Check your system clock.', 401);
}
// 4. Build the expected signature
$body = file_get_contents('php://input');
$payload = $timestamp . '.' . $body;
$secret = env('HMAC_SECRET_KEY');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
json_error('Server configuration error', 500);
}
// 5. Verify using constant-time comparison (prevents timing attacks)
if (!Security::verifySignature($payload, $signature, $secret)) {
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
json_error('Invalid request signature', 401);
}
}
}
```
## File: `app/middleware/AuthMiddleware.php`
```php
<?php
/**
* Simple Authentication Middleware
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\JWT;
final class AuthMiddleware
{
public static function check(): array
{
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
if (!str_starts_with($authHeader, 'Bearer ')) {
json_error('Unauthorized: Missing or invalid token', 401);
}
$token = substr($authHeader, 7);
$secret = env('JWT_SECRET');
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: JWT_SECRET is missing or too short');
json_error('Server configuration error', 500);
}
$decoded = JWT::decode($token, $secret);
if (!$decoded) {
json_error('Unauthorized: Invalid or expired token', 401);
}
return $decoded;
}
}
```
## File: `app/middleware/RateLimitMiddleware.php`
```php
<?php
/**
* Rate Limiting Middleware (File-based, Race-Condition Safe)
*/
declare(strict_types=1);
namespace App\Middleware;
final class RateLimitMiddleware
{
/**
* File-based rate limiter with file-lock to prevent race conditions.
* For multi-server deployments, replace with Redis.
*/
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$cacheDir = STORAGE_PATH . '/cache';
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
// M2 Fix: Use exclusive file lock to prevent race condition
$fp = fopen($cacheFile, 'c+');
if ($fp === false) {
// If we can't open the file, fail open (don't block all users)
return;
}
try {
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
$now = time();
$content = stream_get_contents($fp);
$requests = [];
if (!empty($content)) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
// Keep only requests within the time window
$requests = array_values(
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
);
}
}
if (count($requests) >= $maxRequests) {
flock($fp, LOCK_UN);
fclose($fp);
header('Retry-After: ' . $timeWindow);
json_error('Too Many Requests. Please slow down.', 429);
}
// Record this request
$requests[] = $now;
// Write updated data back
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($requests));
} finally {
flock($fp, LOCK_UN);
fclose($fp);
}
}
}
```
## File: `app/core/Validator.php`
```php
<?php
/**
* Simple Data Validator
*/
declare(strict_types=1);
namespace App\Core;
final class Validator
{
public static function validate(array $data, array $rules): array
{
$errors = [];
foreach ($rules as $field => $rule) {
if (str_contains($rule, 'required') && (empty($data[$field]) && $data[$field] !== '0')) {
$errors[$field] = "The {$field} field is required.";
}
if (str_contains($rule, 'email') && !empty($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "The {$field} must be a valid email address.";
}
}
return $errors;
}
}
```
## File: `app/core/Encryption.php`
```php
<?php
/**
* Advanced Encryption (AES-256-GCM) - System Level
*/
declare(strict_types=1);
namespace App\Core;
final class Encryption
{
private const CIPHER = 'aes-256-gcm';
/**
* Encrypts data using the system's ENCRYPTION_KEY from .env
*/
public static function encrypt(string $data): string
{
$key = env('ENCRYPTION_KEY');
if (!$key) {
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
}
$encryptionKey = hash('sha256', $key, true);
$iv = random_bytes(openssl_cipher_iv_length(self::CIPHER));
$tag = '';
$ciphertext = openssl_encrypt($data, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
if ($ciphertext === false) {
throw new \RuntimeException('Encryption failed');
}
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Decrypts AES-256-GCM encrypted data using the system's ENCRYPTION_KEY
*/
public static function decrypt(string $encryptedData): string|false
{
$key = env('ENCRYPTION_KEY');
if (!$key) {
throw new \RuntimeException('ENCRYPTION_KEY is missing from .env');
}
$encryptionKey = hash('sha256', $key, true);
$decoded = base64_decode($encryptedData);
if ($decoded === false) return false;
$ivLength = openssl_cipher_iv_length(self::CIPHER);
$tagLength = 16;
if (strlen($decoded) < $ivLength + $tagLength) return false;
$iv = substr($decoded, 0, $ivLength);
$tag = substr($decoded, $ivLength, $tagLength);
$ciphertext = substr($decoded, $ivLength + $tagLength);
return openssl_decrypt($ciphertext, self::CIPHER, $encryptionKey, OPENSSL_RAW_DATA, $iv, $tag);
}
}
```
## File: `app/core/Database.php`
```php
<?php
/**
* Simple PDO Database Wrapper
*/
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOException;
final class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
$config = require APP_PATH . '/config/database.php';
$dsn = sprintf(
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
try {
self::$instance = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Database connection failed']);
exit;
}
}
return self::$instance;
}
}
```
## File: `app/core/Security.php`
```php
<?php
/**
* Simple Security Helpers
*/
declare(strict_types=1);
namespace App\Core;
final class Security
{
/**
* Recursively sanitize input data (strings and arrays)
*/
public static function sanitize($data)
{
if (is_array($data)) {
foreach ($data as $key => $value) {
$data[$key] = self::sanitize($value);
}
} else if (is_string($data)) {
$data = htmlspecialchars(strip_tags(trim($data)), ENT_QUOTES, 'UTF-8');
}
return $data;
}
public static function generateRandomString(int $length = 64): string
{
return bin2hex(random_bytes($length / 2));
}
public static function sign(string $data, string $secret): string
{
return hash_hmac('sha256', $data, $secret);
}
public static function verifySignature(string $data, string $signature, string $secret): bool
{
$expected = self::sign($data, $secret);
return hash_equals($expected, $signature);
}
}
```
## File: `app/core/JWT.php`
```php
<?php
/**
* Simple JWT (HMAC SHA256)
*/
declare(strict_types=1);
namespace App\Core;
final class JWT
{
private static function base64UrlEncode(string $data): string
{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
public static function encode(array $payload, string $secret): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode(json_encode($payload));
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
public static function decode(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header, $payload, $signature] = $parts;
$expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $header . "." . $payload, $secret, true));
if (!hash_equals($expectedSignature, $signature)) return null;
$decodedPayload = json_decode(self::base64UrlDecode($payload), true);
// Check expiry
if (isset($decodedPayload['exp']) && $decodedPayload['exp'] < time()) return null;
return $decodedPayload;
}
}
```
## File: `app/bootstrap/init.php`
```php
<?php
/**
* Application Bootstrap Initialization
*/
declare(strict_types=1);
// 1. Basic Constants
define('ROOT_PATH', dirname(__DIR__, 2));
define('APP_PATH', ROOT_PATH . '/app');
define('STORAGE_PATH', ROOT_PATH . '/storage');
// 2. Load Environment & Helpers FIRST
require_once APP_PATH . '/bootstrap/env.php';
require_once APP_PATH . '/helpers/helpers.php';
// 3. Error Reporting (Secure for production)
if (env('APP_DEBUG', 'false') === 'true') {
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
error_reporting(0);
ini_set('display_errors', '0');
}
// 4. H2 Fix: CORS — Whitelist only known origins
$allowedOrigins = array_filter(array_map('trim', explode(',', env('CORS_ORIGIN', 'https://musadaq.intaleqapp.com'))));
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($requestOrigin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: {$requestOrigin}");
} else {
// Fallback to first allowed origin (for non-browser API clients)
header("Access-Control-Allow-Origin: " . ($allowedOrigins[0] ?? ''));
}
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-HMAC-Signature, X-Timestamp");
header("Access-Control-Allow-Credentials: true");
header("Vary: Origin");
// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// 5. Security Headers
header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: DENY");
header("X-XSS-Protection: 1; mode=block");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS
// 6. Intelligent Autoloader (Case-Insensitive for directories)
spl_autoload_register(function ($class) {
$prefix = 'App\\';
$base_dir = APP_PATH . '/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) return;
$relative_class = substr($class, $len);
$parts = explode('\\', $relative_class);
$filename = array_pop($parts) . '.php';
$dir = strtolower(implode('/', $parts));
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
if (file_exists($file)) {
require $file;
}
});
// 7. Response Utility
require_once APP_PATH . '/bootstrap/response.php';
// 8. Global Auth Helper
require_once APP_PATH . '/bootstrap/auth.php';
```
## File: `app/bootstrap/auth.php`
```php
<?php
/**
* Global Auth State (Optional Helper)
*/
declare(strict_types=1);
// This can be used to store the current user globally if needed
// after successful middleware check.
$GLOBALS['current_user'] = null;
function current_user() {
return $GLOBALS['current_user'];
}
function set_current_user(array $user) {
$GLOBALS['current_user'] = $user;
}
```
## File: `app/bootstrap/response.php`
```php
<?php
/**
* Standardized JSON Responses with Secure Logging
*/
declare(strict_types=1);
function json_response(bool $success, $data = null, ?string $message = null, int $code = 200) {
// H3 Fix: Redact sensitive fields before logging
$safeData = $data;
if (is_array($safeData)) {
$sensitiveKeys = ['access_token', 'refresh_token', 'password', 'password_hash', 'refresh_token_hash', 'token'];
array_walk_recursive($safeData, function (&$value, $key) use ($sensitiveKeys) {
if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
$value = '[REDACTED]';
}
});
}
// Log (safe — no secrets)
$logEntry = sprintf(
"API %s %s | %d | %s | %s",
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
$_SERVER['REQUEST_URI'] ?? '',
$code,
$success ? 'OK' : 'FAIL',
$message ?? 'N/A'
);
error_log($logEntry);
// Try custom log file
$logDir = STORAGE_PATH . '/logs';
$logFile = $logDir . '/app.log';
try {
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
if (is_writable($logDir) || is_writable($logFile)) {
@file_put_contents(
$logFile,
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
FILE_APPEND
);
}
} catch (\Exception $e) {
// Fallback silently
}
// HTTP Response
header('Content-Type: application/json; charset=utf-8');
http_response_code($code);
echo json_encode([
'success' => $success,
'data' => $data, // Return real data to client
'message' => $message,
'timestamp' => date('c')
], JSON_UNESCAPED_UNICODE);
exit;
}
function json_error(string $message, int $code = 400, $errors = null) {
json_response(false, $errors, $message, $code);
}
function json_success($data = null, ?string $message = 'Success', int $code = 200) {
json_response(true, $data, $message, $code);
}
```
## File: `app/bootstrap/env.php`
```php
<?php
/**
* Simple .env Loader
*/
// Primary environment file path as requested
$envFile = '/home/intaleqapp-musadaq/env/.env';
// Fallback for local development if the primary server path doesn't exist
if (!file_exists($envFile)) {
$envFile = ROOT_PATH . '/.env';
}
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), '#')) continue;
$parts = explode('=', $line, 2);
if (count($parts) !== 2) continue;
$name = trim($parts[0]);
$value = trim($parts[1], " \t\n\r\0\x0B\"'");
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
```
## File: `app/config/database.php`
```php
<?php
/**
* Database Configuration
*/
return [
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => $_ENV['DB_PORT'] ?? '3306',
'database' => $_ENV['DB_DATABASE'] ?? 'musadaqDb',
'username' => $_ENV['DB_USERNAME'] ?? 'musadaqUser',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
];
```
## File: `app/helpers/helpers.php`
```php
<?php
/**
* Global Helper Functions
*/
if (!function_exists('env')) {
function env(string $key, $default = null) {
return $_ENV[$key] ?? $default;
}
}
if (!function_exists('input')) {
function input(string $key = null, $default = null) {
static $inputData = null;
if ($inputData === null) {
$json = file_get_contents('php://input');
$inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []);
}
if ($key === null) return $inputData;
return $inputData[$key] ?? $default;
}
}
if (!function_exists('dd')) {
// M3 Fix: Guard dd() so it never leaks data in production
function dd(...$vars) {
if (env('APP_DEBUG', 'false') !== 'true') {
error_log('dd() called in production — suppressed. Check your code.');
json_error('Internal Server Error', 500);
}
header('Content-Type: text/html; charset=utf-8');
foreach ($vars as $v) {
echo "<pre style='background:#1e1e1e;color:#d4d4d4;padding:1rem;border-radius:4px;'>";
var_dump($v);
echo "</pre>";
}
die();
}
}
```
## File: `public/login.php`
```php
<!DOCTYPE html>
<html lang="ar" dir="rtl" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>تسجيل الدخول | مُصادَق</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
:root {
--emerald: #10b981;
--bg-base: #080c14;
--bg-surface: #0d1424;
--border-default: rgba(255,255,255,0.10);
--text-primary: #f0f6fc;
--text-secondary: #8b949e;
}
body {
font-family: 'IBM Plex Sans Arabic', sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
overflow: hidden;
}
.login-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-default);
padding: 3rem;
width: 100%;
max-width: 450px;
border-radius: 8px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.input-field {
background-color: #05080f;
border: 1px solid var(--border-default);
color: white;
width: 100%;
padding: 0.75rem 1rem;
border-radius: 4px;
outline: none;
transition: border-color 0.2s;
}
.input-field:focus {
border-color: var(--emerald);
}
.btn-primary {
background-color: var(--emerald);
color: #000;
width: 100%;
padding: 0.75rem;
border-radius: 4px;
font-weight: 700;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.glow {
position: absolute;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, rgba(0, 0, 0, 0) 70%);
z-index: -1;
}
</style>
</head>
<body x-data="loginForm">
<div class="glow"></div>
<div class="login-card">
<div class="text-center mb-10">
<div class="w-16 h-16 bg-emerald-500 rounded-xl flex items-center justify-center text-white text-3xl font-bold mx-auto mb-4 shadow-lg shadow-emerald-500/20">م</div>
<h1 class="text-2xl font-bold mb-2">مرحباً بك في مُصادَق</h1>
<p class="text-sm text-gray-500">نظام أتمتة الفواتير الضريبية الذكي</p>
</div>
<form @submit.prevent="submitLogin">
<div class="mb-6">
<label class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">البريد الإلكتروني</label>
<input type="email" x-model="email" class="input-field" placeholder="name@company.com" required>
</div>
<div class="mb-8">
<label class="block text-xs font-bold uppercase tracking-widest text-gray-500 mb-2">كلمة المرور</label>
<input type="password" x-model="password" class="input-field" placeholder="••••••••" required>
</div>
<template x-if="error">
<div class="mb-6 p-3 bg-red-950/50 border border-red-500/50 text-red-200 text-sm rounded" x-text="error"></div>
</template>
<button type="submit" class="btn-primary flex items-center justify-center gap-2" :disabled="loading">
<span x-show="loading" class="animate-spin text-lg"></span>
<span x-text="loading ? 'جاري التحقق...' : 'دخول النظام ↵'"></span>
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-800 text-center">
<p class="text-sm text-gray-500">ليس لديك حساب؟ <a href="#" class="text-emerald-500 hover:underline">ابدأ التجربة المجانية</a></p>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('loginForm', () => ({
email: '',
password: '',
error: '',
loading: false,
async submitLogin() {
this.loading = true;
this.error = '';
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: this.email,
password: this.password
})
});
const result = await response.json();
if (result.success) {
localStorage.setItem('access_token', result.data.access_token);
localStorage.setItem('user', JSON.stringify(result.data.user));
window.location.href = '/';
} else {
this.error = result.message || 'خطأ في تسجيل الدخول';
}
} catch (e) {
this.error = 'حدث خطأ في الاتصال بالخادم';
} finally {
this.loading = false;
}
}
}));
});
</script>
</body>
</html>
```
## File: `public/index.php`
```php
<?php
/**
* Simple Router & Entry Point
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
// Global Request Logging (non-sensitive)
error_log("Incoming Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '/'));
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$route = $_GET['route'] ?? str_replace('/api/', '', $uri);
$route = trim($route, '/');
error_log("Router: Resolved route '{$route}'");
// Route map: route => [allowed_method, module_file]
$routes = [
'v1/auth/login' => ['POST', 'auth/login.php'],
'v1/auth/refresh' => ['POST', 'auth/refresh.php'],
'v1/auth/logout' => ['POST', 'auth/logout.php'],
'v1/users' => ['GET', 'users/index.php'],
'v1/users/create' => ['POST', 'users/create.php'],
'v1/companies' => ['GET', 'companies/index.php'],
'v1/companies/create' => ['POST', 'companies/create.php'],
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],
];
if (isset($routes[$route])) {
[$allowedMethod, $moduleFile] = $routes[$route];
// H1 Fix: Enforce HTTP Method
if ($_SERVER['REQUEST_METHOD'] !== $allowedMethod) {
header("Allow: {$allowedMethod}");
json_error("Method Not Allowed. Use {$allowedMethod}.", 405);
}
$file = APP_PATH . '/modules_app/' . $moduleFile;
if (file_exists($file)) {
require_once $file;
} else {
json_error("Endpoint file missing: {$route}", 500);
}
} else {
if (str_starts_with($route, 'v1/')) {
json_error("Not Found: {$route}", 404);
} else {
include __DIR__ . '/shell.php';
exit;
}
}
```
## File: `public/tool-encrypt.php`
```php
<?php
/**
* Quick Encryption Tool
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Encryption;
$input = $_GET['text'] ?? '';
$encrypted = $input ? Encryption::encrypt($input) : '';
$hash = $input ? hash('sha256', strtolower($input)) : '';
?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<title>أداة التشفير | مُصادَق</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900 text-white p-10">
<div class="max-w-2xl mx-auto bg-slate-800 p-8 rounded-lg shadow-xl">
<h1 class="text-2xl font-bold mb-6 text-emerald-500">أداة تشفير البيانات</h1>
<form method="GET" class="space-y-4">
<div>
<label class="block text-sm text-slate-400 mb-1">النص المطلوب تشفيره (اسم أو بريد إلكتروني):</label>
<input type="text" name="text" value="<?= htmlspecialchars($input) ?>" class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-white outline-none focus:border-emerald-500">
</div>
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-6 py-2 rounded font-bold transition">تشفير الآن 🔒</button>
</form>
<?php if ($encrypted): ?>
<div class="mt-10 space-y-6">
<div>
<label class="block text-sm text-slate-400 mb-1">النص المشفّر (للحفظ في عمود name أو email):</label>
<textarea readonly class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-emerald-400 font-mono text-xs h-24"><?= $encrypted ?></textarea>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">الـ Hash (للحفظ في عمود email_hash - للبريد فقط):</label>
<input readonly type="text" value="<?= $hash ?>" class="w-full bg-slate-900 border border-slate-700 p-3 rounded text-emerald-400 font-mono text-xs">
</div>
</div>
<?php endif; ?>
<div class="mt-8 pt-6 border-t border-slate-700 text-sm text-slate-500">
استخدم هذه القيم لتحديث مستخدمي النظام الحاليين يدوياً في قاعدة البيانات إذا أردت.
</div>
</div>
</body>
</html>
```
## File: `public/shell.php`
```php
<!DOCTYPE html>
<html lang="ar" dir="rtl" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مُصادَق | لوحة التحكم</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
:root {
--emerald: #10b981;
--bg-base: #080c14;
--bg-surface: #0d1424;
--border-default: rgba(255,255,255,0.1);
--text-primary: #f0f6fc;
}
body { font-family: 'IBM Plex Sans Arabic', sans-serif; background-color: var(--bg-base); color: var(--text-primary); }
</style>
</head>
<body x-data="app" x-init="init()">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-surface border-l border-gray-800 flex flex-col">
<div class="p-6">
<h1 class="text-xl font-bold text-emerald-500">مُصادَق</h1>
</div>
<nav class="flex-1 px-4 space-y-2">
<a href="#" @click="page='dashboard'" class="block p-3 rounded hover:bg-gray-800" :class="page==='dashboard'?'bg-emerald-900/20 text-emerald-500':''">📊 لوحة التحكم</a>
<a href="#" @click="page='companies'" class="block p-3 rounded hover:bg-gray-800" :class="page==='companies'?'bg-emerald-900/20 text-emerald-500':''">🏢 الشركات</a>
<a href="#" @click="page='users'" class="block p-3 rounded hover:bg-gray-800" :class="page==='users'?'bg-emerald-900/20 text-emerald-500':''">👥 المستخدمون</a>
</nav>
<div class="p-6 border-t border-gray-800">
<button @click="logout()" class="w-full text-right text-red-400 text-sm">🚪 تسجيل الخروج</button>
</div>
</aside>
<!-- Main -->
<main class="flex-1 overflow-y-auto p-10">
<header class="mb-10 flex justify-between items-center">
<h2 class="text-2xl font-bold" x-text="title()"></h2>
<div class="flex items-center gap-4">
<button x-show="page==='users'" @click="showAddModal = true" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded text-sm font-bold transition"> إضافة مستخدم</button>
<button x-show="page==='companies'" @click="showAddCompanyModal = true" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-2 rounded text-sm font-bold transition"> إضافة شركة</button>
<div class="text-sm text-gray-500" x-text="user?.name"></div>
</div>
</header>
<div id="content">
<!-- Dashboard -->
<div x-show="page === 'dashboard'">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="p-6 bg-surface border border-gray-800 rounded">
<p class="text-gray-500 text-sm">إجمالي الفواتير</p>
<p class="text-3xl font-bold mt-2" x-text="stats.total"></p>
</div>
<div class="p-6 bg-surface border border-gray-800 rounded">
<p class="text-gray-500 text-sm">قيد المعالجة</p>
<p class="text-3xl font-bold mt-2 text-yellow-500" x-text="stats.pending"></p>
</div>
<div class="p-6 bg-surface border border-gray-800 rounded">
<p class="text-gray-500 text-sm">تم الاعتماد</p>
<p class="text-3xl font-bold mt-2 text-emerald-500" x-text="stats.approved"></p>
</div>
</div>
</div>
<!-- Companies -->
<div x-show="page === 'companies'">
<div class="bg-surface border border-gray-800 rounded overflow-hidden">
<table class="w-full text-right">
<thead class="bg-gray-900/50">
<tr>
<th class="p-4">اسم الشركة</th>
<th class="p-4">الرقم الضريبي</th>
<th class="p-4">رقم التسجيل</th>
<th class="p-4">تاريخ الإضافة</th>
</tr>
</thead>
<tbody>
<template x-for="c in companies" :key="c.id">
<tr class="border-t border-gray-800">
<td class="p-4 font-bold text-emerald-500" x-text="c.name"></td>
<td class="p-4" x-text="c.tax_number"></td>
<td class="p-4" x-text="c.registration_number"></td>
<td class="p-4 text-xs text-gray-500" x-text="c.created_at"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Users -->
<div x-show="page === 'users'">
<div class="bg-surface border border-gray-800 rounded overflow-hidden">
<table class="w-full text-right">
<thead class="bg-gray-900/50">
<tr>
<th class="p-4">الاسم</th>
<th class="p-4">البريد الإلكتروني</th>
<th class="p-4">الدور</th>
</tr>
</thead>
<tbody>
<template x-for="u in users" :key="u.id">
<tr class="border-t border-gray-800">
<td class="p-4" x-text="u.name"></td>
<td class="p-4" x-text="u.email"></td>
<td class="p-4 text-xs uppercase text-gray-500">
<span class="px-2 py-1 bg-gray-800 rounded" x-text="u.role"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Add User Modal -->
<div x-show="showAddModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50" x-cloak>
<div class="bg-surface border border-gray-800 w-full max-w-md p-8 rounded-lg shadow-2xl" @click.away="showAddModal = false">
<h3 class="text-xl font-bold mb-6">إضافة مستخدم جديد 👤</h3>
<form @submit.prevent="createUser" class="space-y-4">
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">الاسم الكامل</label>
<input type="text" x-model="newUser.name" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">البريد الإلكتروني</label>
<input type="email" x-model="newUser.email" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">كلمة المرور</label>
<input type="password" x-model="newUser.password" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">الدور</label>
<select x-model="newUser.role" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
<option value="employee">موظف</option>
<option value="accountant">محاسب</option>
<option value="admin">مدير نظام</option>
</select>
</div>
<div class="pt-4 flex gap-3">
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded font-bold transition">حفظ المستخدم</button>
<button type="button" @click="showAddModal = false" class="px-6 py-3 border border-gray-800 rounded hover:bg-gray-800 transition">إلغاء</button>
</div>
</form>
</div>
</div>
<!-- Add Company Modal -->
<div x-show="showAddCompanyModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50" x-cloak>
<div class="bg-surface border border-gray-800 w-full max-w-md p-8 rounded-lg shadow-2xl" @click.away="showAddCompanyModal = false">
<h3 class="text-xl font-bold mb-6">إنشاء شركة جديدة 🏢</h3>
<form @submit.prevent="createCompany" class="space-y-4">
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">اسم الشركة</label>
<input type="text" x-model="newCompany.name" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500" required>
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">الرقم الضريبي</label>
<input type="text" x-model="newCompany.tax_identification_number" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">رقم التسجيل</label>
<input type="text" x-model="newCompany.commercial_registration_number" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500">
</div>
<div>
<label class="block text-xs text-gray-500 uppercase mb-1">العنوان</label>
<textarea x-model="newCompany.address" class="w-full bg-gray-950 border border-gray-800 p-3 rounded outline-none focus:border-emerald-500"></textarea>
</div>
<div class="pt-4 flex gap-3">
<button type="submit" class="flex-1 bg-emerald-600 hover:bg-emerald-500 py-3 rounded font-bold transition">حفظ الشركة</button>
<button type="button" @click="showAddCompanyModal = false" class="px-6 py-3 border border-gray-800 rounded hover:bg-gray-800 transition">إلغاء</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
user: JSON.parse(localStorage.getItem('user')),
page: 'dashboard',
users: [],
companies: [],
stats: { total: 0, pending: 0, approved: 0 },
showAddModal: false,
showAddCompanyModal: false,
newUser: { name: '', email: '', password: '', role: 'employee' },
newCompany: { name: '', tax_identification_number: '', commercial_registration_number: '', address: '' },
init() {
if (!this.user) window.location.href = '/login.php';
this.loadUsers();
this.loadStats();
this.loadCompanies();
},
title() {
return { dashboard: 'لوحة التحكم', users: 'المستخدمون', companies: 'الشركات' }[this.page];
},
async loadUsers() {
const res = await fetch('/api/v1/users', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
});
if (res.status === 401) this.logout();
const json = await res.json();
if (json.success) this.users = json.data;
},
async loadCompanies() {
const res = await fetch('/api/v1/companies', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
});
const json = await res.json();
if (json.success) this.companies = json.data;
},
async loadStats() {
const res = await fetch('/api/v1/dashboard/stats', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }
});
const json = await res.json();
if (json.success) this.stats = json.data;
},
async createUser() {
const res = await fetch('/api/v1/users/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
},
body: JSON.stringify(this.newUser)
});
const json = await res.json();
if (json.success) {
this.showAddModal = false;
this.newUser = { name: '', email: '', password: '', role: 'employee' };
this.loadUsers();
alert('تم إضافة المستخدم بنجاح');
} else {
alert(json.message);
}
},
async createCompany() {
const res = await fetch('/api/v1/companies/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('access_token')
},
body: JSON.stringify(this.newCompany)
});
const json = await res.json();
if (json.success) {
this.showAddCompanyModal = false;
this.newCompany = { name: '', tax_identification_number: '', commercial_registration_number: '', address: '' };
this.loadCompanies();
alert('تم إنشاء الشركة بنجاح');
} else {
alert(json.message);
}
},
logout() {
localStorage.clear();
window.location.href = '/login.php';
}
}));
});
</script>
</body>
</html>
```
## File: `public/register.php`
```php
<!DOCTYPE html>
<html lang="ar" dir="rtl" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>إنشاء حساب | مُصادَق</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Alpine.js -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
:root {
--emerald: #10b981;
--bg-base: #080c14;
--bg-surface: #0d1424;
--border-default: rgba(255,255,255,0.10);
--text-primary: #f0f6fc;
}
body {
font-family: 'IBM Plex Sans Arabic', sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 2rem 0;
}
.login-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-default);
padding: 2.5rem;
width: 100%;
max-width: 500px;
border-radius: 8px;
}
.input-field {
background-color: #05080f;
border: 1px solid var(--border-default);
color: white;
width: 100%;
padding: 0.6rem 1rem;
border-radius: 4px;
outline: none;
}
.input-field:focus {
border-color: var(--emerald);
}
.btn-primary {
background-color: var(--emerald);
color: #000;
width: 100%;
padding: 0.75rem;
border-radius: 4px;
font-weight: 700;
}
</style>
</head>
<body x-data="registerForm">
<div class="login-card">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold mb-2">ابدأ مع مُصادَق</h1>
<p class="text-sm text-gray-500">سجل شركتك الآن وابدأ أتمتة فواتيرك</p>
</div>
<form @submit.prevent="submitRegister">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">اسم الشركة</label>
<input type="text" x-model="tenant_name" class="input-field" placeholder="شركة المثال" required>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">اسم المسؤول</label>
<input type="text" x-model="user_name" class="input-field" placeholder="حمزة" required>
</div>
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">البريد الإلكتروني</label>
<input type="email" x-model="email" class="input-field" placeholder="admin@company.com" required>
</div>
<div class="mb-6">
<label class="block text-xs font-bold text-gray-500 mb-2 uppercase">كلمة المرور</label>
<input type="password" x-model="password" class="input-field" placeholder="••••••••" required>
</div>
<template x-if="error">
<div class="mb-6 p-3 bg-red-950/50 border border-red-500/50 text-red-200 text-sm rounded" x-text="error"></div>
</template>
<button type="submit" class="btn-primary" :disabled="loading">
<span x-text="loading ? 'جاري إنشاء الحساب...' : 'إنشاء الحساب والبدء ↵'"></span>
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-800 text-center text-sm">
<p class="text-gray-500">لديك حساب بالفعل؟ <a href="/login.php" class="text-emerald-500 hover:underline">تسجيل الدخول</a></p>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('registerForm', () => ({
tenant_name: '',
user_name: '',
email: '',
password: '',
error: '',
loading: false,
async submitRegister() {
this.loading = true;
this.error = '';
try {
const response = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_name: this.tenant_name,
user_name: this.user_name,
email: this.email,
password: this.password
})
});
const result = await response.json();
if (result.success) {
localStorage.setItem('access_token', result.data.access_token);
localStorage.setItem('user', JSON.stringify(result.data.user));
window.location.href = '/';
} else {
this.error = result.message || 'خطأ في إنشاء الحساب';
}
} catch (e) {
this.error = 'حدث خطأ في الاتصال بالخادم';
} finally {
this.loading = false;
}
}
}));
});
</script>
</body>
</html>
```
## File: `public/api.php`
```php
```
## File: `scripts/schema.sql`
```sql
-- ════════════════════════════════════════════════════════════
-- مُصادَق — Database Schema v1.0
-- ════════════════════════════════════════════════════════════
-- Tenants (Accounting Offices)
CREATE TABLE tenants (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
phone VARCHAR(20),
status ENUM('active', 'suspended', 'trial') DEFAULT 'trial',
trial_ends_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Users
CREATE TABLE users (
id CHAR(36) PRIMARY KEY 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','viewer') NOT NULL,
company_id CHAR(36) NULL, -- assigned company for accountant
refresh_token_hash VARCHAR(255) NULL,
is_active BOOLEAN DEFAULT TRUE,
last_login_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_tenant_email (tenant_id, email),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- API Keys (for external integrations / mobile scanner)
CREATE TABLE api_keys (
id CHAR(36) PRIMARY KEY 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 UNIQUE,
secret_hash VARCHAR(255) NOT NULL, -- bcrypt hash of secret
last_used_at DATETIME NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Companies
CREATE TABLE companies (
id CHAR(36) PRIMARY KEY 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,
address TEXT 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 VARCHAR(500) NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name),
INDEX idx_tin (tax_identification_number),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Subscriptions
CREATE TABLE subscriptions (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL UNIQUE,
plan ENUM('basic','office','pro','enterprise') NOT NULL DEFAULT 'basic',
max_companies INT NOT NULL DEFAULT 5,
max_invoices_per_month INT NOT NULL DEFAULT 100,
price_jod DECIMAL(10,2) NOT NULL DEFAULT 0,
invoices_used_this_month INT NOT NULL DEFAULT 0,
status ENUM('active','past_due','cancelled') DEFAULT 'active',
current_period_start DATETIME NULL,
current_period_end DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Invoices
CREATE TABLE invoices (
id CHAR(36) PRIMARY KEY DEFAULT (UUID()),
tenant_id CHAR(36) NOT NULL,
company_id CHAR(36) NOT NULL,
invoice_number VARCHAR(100) NULL,
invoice_date DATE NULL,
invoice_type ENUM('cash','credit') DEFAULT 'cash',
ubl_type_code CHAR(3) DEFAULT '388',
payment_method_code CHAR(3) 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) DEFAULT 0,
discount_total DECIMAL(15,3) DEFAULT 0,
tax_amount DECIMAL(15,3) DEFAULT 0,
grand_total DECIMAL(15,3) DEFAULT 0,
currency_code CHAR(3) DEFAULT 'JOD',
status ENUM('uploaded','extracting','extracted','validated','validation_failed','submitting','approved','rejected') DEFAULT 'uploaded',
original_file_path TEXT NULL,
invoice_category VARCHAR(20) DEFAULT 'simplified',
validation_errors JSON NULL,
qr_code TEXT NULL,
ai_confidence_score DECIMAL(4,3) NULL,
ai_prompt_tokens INT DEFAULT 0,
ai_completion_tokens INT DEFAULT 0,
ai_total_cost DECIMAL(10,6) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tenant (tenant_id),
INDEX idx_company (company_id),
INDEX idx_status (status),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
-- Invoice Lines
CREATE TABLE invoice_lines (
id CHAR(36) PRIMARY KEY 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) DEFAULT 0,
tax_rate DECIMAL(5,4) NOT NULL,
line_total DECIMAL(15,3) NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);
-- Audit Logs
CREATE TABLE audit_logs (
id CHAR(36) PRIMARY KEY 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 VARCHAR(500) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant (tenant_id),
INDEX idx_action (action),
INDEX idx_created (created_at)
);
```
## File: `scripts/migrate.php`
```php
<?php
/**
* Advanced Migration Script: Schema Update + Data Encryption
*/
require_once __DIR__ . '/../app/bootstrap/init.php';
use App\Core\Database;
use App\Core\Encryption;
$db = Database::getInstance();
echo "--- Starting Security Migration ---\n";
// 1. Add email_hash column if it doesn't exist
try {
$db->exec("ALTER TABLE users ADD COLUMN email_hash VARCHAR(64) AFTER email, ADD INDEX (email_hash)");
echo "[OK] Added email_hash column and index.\n";
} catch (\Exception $e) {
echo "[SKIP] email_hash column might already exist.\n";
}
// 2. Fetch all users to encrypt their data
$stmt = $db->query("SELECT id, name, email FROM users");
$users = $stmt->fetchAll();
echo "Found " . count($users) . " users. Starting encryption...\n";
$updateStmt = $db->prepare("UPDATE users SET name = ?, email = ?, email_hash = ? WHERE id = ?");
foreach ($users as $user) {
// Check if data is already encrypted (to avoid double encryption)
$isAlreadyEncrypted = Encryption::decrypt($user['email']) !== false;
if ($isAlreadyEncrypted) {
echo "User ID {$user['id']} is already encrypted. Skipping.\n";
continue;
}
// Encrypt Name
$encryptedName = Encryption::encrypt($user['name']);
// Encrypt Email
$encryptedEmail = Encryption::encrypt($user['email']);
// Generate Hash for lookup
$emailHash = hash('sha256', strtolower($user['email']));
$updateStmt->execute([
$encryptedName,
$encryptedEmail,
$emailHash,
$user['id']
]);
echo "User ID {$user['id']} migrated successfully.\n";
}
// (Table creation logic removed because it is properly handled by schema.sql)
echo "--- Migration Complete ---\n";
```