# مُصادَق — ملخص كود المشروع الكامل هذا الملف يحتوي على كافة ملفات المصدر للمشروع مجمعة لتسهيل المراجعة. ## الملف: `phpunit.xml` ``` tests/Unit tests/Feature ``` --- ## الملف: `scratch.js` ```javascript const appRouter = () => ({ isLoggedIn: !!localStorage.getItem('access_token'), pageHtml: 'جاري التحميل...', async init() { console.log('App Initialized'); await this.navigate(window.location.pathname); window.onpopstate = () => this.navigate(window.location.pathname); }, async navigate(path) { console.log('Navigating to:', path); const isLogin = path.includes('login'); if (!this.isLoggedIn && !isLogin) { this.pageHtml = await this.loadPage('login'); } else if (isLogin) { this.pageHtml = await this.loadPage('login'); } else { this.pageHtml = await this.loadPage('dashboard'); } }, initCharts() { const ctx = document.getElementById('invoiceChart')?.getContext('2d'); }, async loadPage(page) { if (page === 'dashboard') { return `
`; } if (page === 'login') return `

مرحباً بك مجدداً

`; return '
الصفحة قيد الإنشاء
'; } }); ``` --- ## الملف: `.env` ``` APP_NAME="مُصادَق" APP_ENV=development APP_URL=http://localhost:8000 APP_TIMEZONE=Asia/Amman # MySQL (CloudPanel managed) DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=musadaqDb DB_USERNAME=musadaqUser DB_PASSWORD=FWVG3vx2fhrwUULXa6E4 DB_CHARSET=utf8mb4 # Redis (system service) REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= # JWT JWT_SECRET=super-secret-change-me-in-production JWT_ACCESS_EXPIRY=900 JWT_REFRESH_EXPIRY=604800 # AI Providers GEMINI_API_KEY= GEMINI_MODEL=gemini-2.0-flash OPENAI_API_KEY= OPENAI_MODEL=gpt-4o # JoFotara JOFOTARA_BASE_URL=https://backend.jofotara.gov.jo/core/invoices JOFOTARA_ENV=sandbox # Email MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM=noreply@musadaq.app MAIL_FROM_NAME="مُصادَق" # Storage STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage UPLOAD_MAX_SIZE=20971520 ``` --- ## الملف: `describe.php` ```php load(); $db = new PDO("mysql:host={$_ENV['DB_HOST']};port={$_ENV['DB_PORT']};dbname={$_ENV['DB_DATABASE']}", $_ENV['DB_USERNAME'], $_ENV['DB_PASSWORD']); $stmt = $db->query("DESCRIBE invoices"); print_r($stmt->fetchAll(PDO::FETCH_ASSOC)); ``` --- ## الملف: `composer.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 } } ``` --- ## الملف: `database/seed.sql` ```sql -- ─── 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' ); ``` --- ## الملف: `database/schema.sql` ```sql SET NAMES utf8mb4; SET CHARACTER SET utf8mb4; -- ─── Tenants ────────────────────────────────────────────── CREATE TABLE tenants ( id CHAR(36) NOT NULL DEFAULT (UUID()), name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(20) NULL, status ENUM('active','suspended','trial') NOT NULL DEFAULT 'trial', trial_ends_at DATETIME NULL, settings JSON DEFAULT (JSON_OBJECT()), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uq_tenants_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Users ──────────────────────────────────────────────── CREATE TABLE users ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('super_admin','admin','accountant','employee','viewer') NOT NULL, assigned_company_id CHAR(36) NULL, refresh_token_hash VARCHAR(255) NULL, totp_secret VARCHAR(64) NULL, totp_enabled TINYINT(1) NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, email_verified_at DATETIME NULL, last_login_at DATETIME NULL, last_login_ip VARCHAR(45) NULL, failed_login_count INT NOT NULL DEFAULT 0, locked_until DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uq_tenant_email (tenant_id, email), CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── API Keys ───────────────────────────────────────────── CREATE TABLE api_keys ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, user_id CHAR(36) NOT NULL, name VARCHAR(100) NOT NULL, public_key VARCHAR(64) NOT NULL, secret_hash VARCHAR(255) NOT NULL, permissions JSON DEFAULT (JSON_ARRAY('invoices:read','invoices:upload')), last_used_at DATETIME NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, expires_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uq_api_public_key (public_key), CONSTRAINT fk_apikeys_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, CONSTRAINT fk_apikeys_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Companies ──────────────────────────────────────────── CREATE TABLE companies ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, name_en VARCHAR(255) NULL, tax_identification_number VARCHAR(20) NOT NULL, commercial_registration_number VARCHAR(50) NULL, address TEXT NULL, city VARCHAR(100) NULL, contact_email VARCHAR(255) NULL, contact_phone VARCHAR(20) NULL, jofotara_client_id_encrypted TEXT NULL, jofotara_secret_key_encrypted TEXT NULL, jofotara_income_source_sequence VARCHAR(50) NULL, certificate_path VARCHAR(255) NULL, certificate_password_encrypted TEXT NULL, is_jofotara_linked TINYINT(1) NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, PRIMARY KEY (id), INDEX idx_companies_tenant (tenant_id), INDEX idx_companies_tin (tax_identification_number), CONSTRAINT fk_companies_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Subscriptions ──────────────────────────────────────── CREATE TABLE subscriptions ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, plan ENUM('free','basic','office','pro','enterprise') NOT NULL DEFAULT 'basic', max_companies INT NOT NULL DEFAULT 3, max_invoices_per_month INT NOT NULL DEFAULT 50, max_users INT NOT NULL DEFAULT 2, price_jod DECIMAL(10,2) NOT NULL DEFAULT 0.00, invoices_used_this_month INT NOT NULL DEFAULT 0, status ENUM('active','past_due','cancelled','trial') NOT NULL DEFAULT 'active', current_period_start DATETIME NULL, current_period_end DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uq_sub_tenant (tenant_id), CONSTRAINT fk_sub_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Invoices ───────────────────────────────────────────── CREATE TABLE invoices ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, company_id CHAR(36) NOT NULL, uploaded_by CHAR(36) NULL, invoice_number VARCHAR(100) NULL, invoice_date DATE NULL, invoice_type ENUM('cash','credit') NOT NULL DEFAULT 'cash', ubl_type_code CHAR(3) NOT NULL DEFAULT '388', payment_method_code CHAR(3) NOT NULL DEFAULT '013', supplier_tin VARCHAR(20) NULL, supplier_name VARCHAR(255) NULL, supplier_address TEXT NULL, buyer_tin VARCHAR(20) NULL, buyer_national_id VARCHAR(20) NULL, buyer_name VARCHAR(255) NULL, subtotal DECIMAL(15,3) NOT NULL DEFAULT 0, discount_total DECIMAL(15,3) NOT NULL DEFAULT 0, tax_amount DECIMAL(15,3) NOT NULL DEFAULT 0, grand_total DECIMAL(15,3) NOT NULL DEFAULT 0, currency_code CHAR(3) NOT NULL DEFAULT 'JOD', status ENUM('uploaded','extracting','extracted','validated', 'validation_failed','submitting','approved','rejected') NOT NULL DEFAULT 'uploaded', original_file_path TEXT NULL, original_file_hash VARCHAR(64) NULL, invoice_category VARCHAR(20) NOT NULL DEFAULT 'simplified', validation_errors JSON NULL, qr_code TEXT NULL, jofotara_response JSON NULL, ai_provider VARCHAR(20) NULL, ai_confidence_score DECIMAL(4,3) NULL, ai_prompt_tokens INT NOT NULL DEFAULT 0, ai_completion_tokens INT NOT NULL DEFAULT 0, ai_total_cost DECIMAL(10,6) NOT NULL DEFAULT 0, ai_raw_response JSON NULL, idempotency_key VARCHAR(64) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uq_idempotency (idempotency_key), INDEX idx_invoices_tenant (tenant_id), INDEX idx_invoices_company (company_id), INDEX idx_invoices_status (status), INDEX idx_invoices_date (invoice_date), INDEX idx_invoices_file_hash (original_file_hash), CONSTRAINT fk_inv_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, CONSTRAINT fk_inv_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, CONSTRAINT fk_inv_user FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Invoice Lines ──────────────────────────────────────── CREATE TABLE invoice_lines ( id CHAR(36) NOT NULL DEFAULT (UUID()), invoice_id CHAR(36) NOT NULL, line_number INT NOT NULL, description TEXT NOT NULL, quantity DECIMAL(15,3) NOT NULL, unit_price DECIMAL(15,3) NOT NULL, discount DECIMAL(15,3) NOT NULL DEFAULT 0, tax_rate DECIMAL(5,4) NOT NULL, tax_amount DECIMAL(15,3) NOT NULL DEFAULT 0, line_total DECIMAL(15,3) NOT NULL, PRIMARY KEY (id), INDEX idx_lines_invoice (invoice_id), CONSTRAINT fk_lines_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Audit Logs ─────────────────────────────────────────── CREATE TABLE audit_logs ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NULL, user_id CHAR(36) NULL, action VARCHAR(100) NOT NULL, entity_type VARCHAR(50) NULL, entity_id CHAR(36) NULL, old_data JSON NULL, new_data JSON NULL, ip_address VARCHAR(45) NULL, user_agent TEXT NULL, metadata JSON NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), INDEX idx_audit_tenant (tenant_id), INDEX idx_audit_action (action), INDEX idx_audit_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Risk Scores ────────────────────────────────────────── CREATE TABLE risk_scores ( id CHAR(36) NOT NULL DEFAULT (UUID()), tenant_id CHAR(36) NOT NULL, company_id CHAR(36) NOT NULL, invoice_id CHAR(36) NULL, risk_type VARCHAR(50) NOT NULL, score TINYINT UNSIGNED NOT NULL, reason TEXT NOT NULL, is_resolved TINYINT(1) NOT NULL DEFAULT 0, resolved_by CHAR(36) NULL, resolved_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), INDEX idx_risk_tenant (tenant_id), INDEX idx_risk_unresolved (is_resolved), CONSTRAINT fk_risk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, CONSTRAINT fk_risk_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, CONSTRAINT fk_risk_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL, CONSTRAINT fk_risk_resolver FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ─── Queue Jobs (MySQL fallback when Redis unavailable) ─── CREATE TABLE queue_jobs ( id CHAR(36) NOT NULL DEFAULT (UUID()), type VARCHAR(100) NOT NULL, payload JSON NOT NULL, priority INT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 3, status ENUM('pending','processing','completed','failed','dead') NOT NULL DEFAULT 'pending', error TEXT NULL, locked_at DATETIME NULL, locked_by VARCHAR(100) NULL, scheduled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), INDEX idx_queue_pending (status, priority DESC, scheduled_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` --- ## الملف: `database/migrations/002_core_modules.sql` ```sql -- ─── 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; ``` --- ## الملف: `database/migrations/003_invoices.sql` ```sql -- ─── 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; ``` --- ## الملف: `database/migrations/004_system.sql` ```sql -- ─── 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; ``` --- ## الملف: `database/migrations/001_initial_schema.sql` ```sql -- ─── 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; ``` --- ## الملف: `app/Middleware/CsrfMiddleware.php` ```php 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); } } ``` --- ## الملف: `app/Middleware/TenantMiddleware.php` ```php 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); } } ``` --- ## الملف: `app/Middleware/RoleMiddleware.php` ```php 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); } } ``` --- ## الملف: `app/Middleware/HmacMiddleware.php` ```php getHeader('X-Api-Key'); $signature = $request->getHeader('X-Signature'); $timestamp = $request->getHeader('X-Timestamp'); $nonce = $request->getHeader('X-Nonce'); if (!$publicKey || !$signature || !$timestamp || !$nonce) { Response::error('بيانات التوقيع (HMAC) ناقصة', 'HMAC_MISSING', 401); return null; } // 1. Lookup Secret by Public Key $db = Database::getInstance(); $stmt = $db->prepare("SELECT secret_hash, tenant_id FROM api_keys WHERE public_key = ? AND is_active = 1 LIMIT 1"); $stmt->execute([$publicKey]); $apiKey = $stmt->fetch(); if (!$apiKey) { Response::error('مفتاح API غير صالح', 'HMAC_INVALID_KEY', 401); return null; } // 2. Verify Signature // Note: secret_hash in DB is the actual secret for signing $isValid = $this->hmac->verify( $apiKey['secret_hash'], $request->getMethod(), $request->getPath(), $timestamp, $nonce, json_encode($request->getBody()), $signature ); if (!$isValid) { Response::error('توقيع الطلب غير صحيح', 'HMAC_INVALID_SIGNATURE', 401); return null; } // 3. Set context $request->tenantId = $apiKey['tenant_id']; return $next($request); } } ``` --- ## الملف: `app/Middleware/AuthMiddleware.php` ```php getHeader('Authorization'); if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401); return null; } $token = substr($authHeader, 7); try { $decoded = $this->jwtService->verifyToken($token); $request->user = (object) $decoded; $request->tenantId = $decoded['tenant_id'] ?? null; } catch (Exception $e) { Response::error('جلسة العمل منتهية أو غير صالحة', 'UNAUTHORIZED', 401); return null; } return $next($request); } } ``` --- ## الملف: `app/Middleware/RateLimitMiddleware.php` ```php getPath() . "|" . $ip); $current = $redis->get($key); if ($current && (int)$current >= $limit) { Response::error('لقد تجاوزت الحد المسموح من الطلبات، يرجى المحاولة لاحقاً', 'RATE_LIMIT_EXCEEDED', 429); return null; } if (!$current) { $redis->setex($key, $window, 1); } else { $redis->incr($key); } return $next($request); } } ``` --- ## الملف: `app/Core/Application.php` ```php load(); // 2. Set Timezone date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Asia/Amman'); // 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 $this->container->set(Container::class, $this->container); $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; } 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); } catch (\Throwable $e) { // Global Exception Handler Response::error( 'حدث خطأ غير متوقع في النظام', 'INTERNAL_SERVER_ERROR', 500, [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ] ); } } } ``` --- ## الملف: `app/Core/Container.php` ```php instances[$id] = $concrete; } public function get(string $id): mixed { if (isset($this->instances[$id])) { if ($this->instances[$id] instanceof \Closure) { $this->instances[$id] = ($this->instances[$id])($this); } return $this->instances[$id]; } return $this->resolve($id); } public function resolve(string $id): mixed { if (!class_exists($id)) { throw new Exception("Class {$id} cannot be resolved."); } $reflection = new ReflectionClass($id); if (!$reflection->isInstantiable()) { throw new Exception("Class {$id} is not instantiable."); } $constructor = $reflection->getConstructor(); if (is_null($constructor)) { return new $id(); } $parameters = $constructor->getParameters(); $dependencies = []; foreach ($parameters as $parameter) { $type = $parameter->getType(); if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { if ($parameter->isDefaultValueAvailable()) { $dependencies[] = $parameter->getDefaultValue(); continue; } throw new Exception("Unable to resolve parameter '{$parameter->getName()}' in class {$id}"); } $dependencies[] = $this->get($type->getName()); } $instance = $reflection->newInstanceArgs($dependencies); $this->instances[$id] = $instance; return $instance; } } ``` --- ## الملف: `app/Core/Response.php` ```php 'application/json; charset=utf-8'], $headers)); } public static function error(string $messageAr, string $code, int $status = 400, ?array $details = null): void { $data = [ 'success' => false, 'error' => [ 'message_ar' => $messageAr, 'code' => $code, 'details' => $details ] ]; self::json($data, $status); } private static function send(mixed $data, int $status, array $headers): void { http_response_code($status); foreach ($headers as $name => $value) { header("$name: $value"); } // Apply Security Headers header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: DENY'); header('X-XSS-Protection: 1; mode=block'); header('Referrer-Policy: strict-origin-when-cross-origin'); header_remove('X-Powered-By'); echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); exit; } } ``` --- ## الملف: `app/Core/Database.php` ```php PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { self::$instance = new PDO($dsn, $user, $pass, $options); } catch (PDOException $e) { throw new Exception("Database Connection Error: " . $e->getMessage()); } } return self::$instance; } } ``` --- ## الملف: `app/Core/Request.php` ```php method = $_SERVER['REQUEST_METHOD']; // Read API path from query string: index.php?route=/api/v1/auth/login $this->path = $_GET['route'] ?? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $this->headers = getallheaders(); $this->queryParams = $_GET; $this->files = $_FILES; $contentType = $this->getHeader('Content-Type') ?? $_SERVER['CONTENT_TYPE'] ?? ''; if ($contentType && str_contains(strtolower($contentType), 'application/json')) { $this->body = json_decode(file_get_contents('php://input'), true) ?? []; } else { $this->body = $_POST; } } public function getMethod(): string { return $this->method; } public function getPath(): string { return $this->path; } public function getHeaders(): array { return $this->headers; } public function getQueryParams(): array { return $this->queryParams; } public function getBody(): array { return $this->body; } public function getFiles(): array { return $this->files; } public function getHeader(string $name): ?string { $name = strtolower($name); foreach ($this->headers as $key => $value) { if (strtolower($key) === $name) { return $value; } } return null; } public function input(string $key, mixed $default = null): mixed { return $this->body[$key] ?? $this->queryParams[$key] ?? $default; } } ``` --- ## الملف: `app/Core/Redis.php` ```php 'tcp', 'host' => $_ENV['REDIS_HOST'] ?? '127.0.0.1', 'port' => $_ENV['REDIS_PORT'] ?? 6379, 'password' => $_ENV['REDIS_PASSWORD'] ?: null, ]); } catch (Exception $e) { // If Redis fails, we might want to log it or handle gracefully // depending on how critical it is. throw new Exception("Redis Connection Error: " . $e->getMessage()); } } return self::$instance; } } ``` --- ## الملف: `app/Core/Router.php` ```php container = $container; } public function addRoute(string $method, string $path, array|callable $handler): void { $this->routes[] = [$method, $path, $handler]; } public function dispatch(Request $request): void { $dispatcher = simpleDispatcher(function (RouteCollector $r) { foreach ($this->routes as $route) { $r->addRoute($route[0], $route[1], $route[2]); } }); $routeInfo = $dispatcher->dispatch($request->getMethod(), $request->getPath()); switch ($routeInfo[0]) { case \FastRoute\Dispatcher::NOT_FOUND: Response::error('المسار غير موجود', 'NOT_FOUND', 404); break; case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: Response::error('الطريقة غير مسموح بها', 'METHOD_NOT_ALLOWED', 405); break; case \FastRoute\Dispatcher::FOUND: $handler = $routeInfo[1]; $vars = $routeInfo[2]; $this->executeHandler($handler, $request, $this->container, $vars); break; } } private function executeHandler(mixed $handler, Request $request, Container $container, array $vars): void { if (is_array($handler) && isset($handler['middleware'])) { $middlewares = (array) $handler['middleware']; $finalHandler = $handler['handler']; $pipeline = $this->createPipeline($middlewares, $finalHandler, $container, $vars); $pipeline($request); } else { $this->callHandler($handler, $request, $container, $vars); } } private function createPipeline(array $middlewares, mixed $handler, Container $container, array $vars): callable { return array_reduce( array_reverse($middlewares), function ($next, $middleware) use ($container) { return function ($request) use ($next, $middleware, $container) { $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) { $this->callHandler($handler, $request, $container, $vars); } ); } private function callHandler(mixed $handler, Request $request, Container $container, array $vars): void { if (is_array($handler)) { [$controllerClass, $method] = $handler; $controller = $container->get($controllerClass); $controller->$method($request, ...array_values($vars)); } else { $handler($request, ...array_values($vars)); } } } ``` --- ## الملف: `app/Core/helpers.php` ```php db()->prepare("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ? AND deleted_at IS NULL LIMIT 1"); $stmt->execute([$id]); return $stmt->fetch() ?: null; } public function create(array $data): string|bool { $columns = implode(', ', array_keys($data)); $placeholders = implode(', ', array_fill(0, count($data), '?')); $sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; $stmt = $this->db()->prepare($sql); if ($stmt->execute(array_values($data))) { return $data[$this->primaryKey] ?? $this->db()->lastInsertId(); } return false; } public function update(string $id, array $data): bool { $sets = []; foreach (array_keys($data) as $column) { $sets[] = "{$column} = ?"; } $setString = implode(', ', $sets); $sql = "UPDATE {$this->table} SET {$setString} WHERE {$this->primaryKey} = ?"; $stmt = $this->db()->prepare($sql); $params = array_values($data); $params[] = $id; return $stmt->execute($params); } public function delete(string $id): bool { $sql = "UPDATE {$this->table} SET deleted_at = NOW() WHERE {$this->primaryKey} = ?"; $stmt = $this->db()->prepare($sql); return $stmt->execute([$id]); } } ``` --- ## الملف: `app/Modules/Invoices/InvoiceModel.php` ```php db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC"); $stmt->execute([$tenantId]); return $stmt->fetchAll(); } public function findByStatus(string $status, ?string $tenantId = null): array { $sql = "SELECT * FROM {$this->table} WHERE status = ? AND deleted_at IS NULL"; $params = [$status]; if ($tenantId) { $sql .= " AND tenant_id = ?"; $params[] = $tenantId; } $stmt = $this->db()->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(); } } ``` --- ## الملف: `app/Modules/Invoices/InvoiceController.php` ```php tenantId; $role = $request->user->role ?? 'viewer'; $assignedCompanyId = $request->user->assigned_company_id ?? null; if ($role === 'super_admin') { $stmt = $db->prepare("SELECT i.*, c.name as company_name FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.tenant_id = ? AND i.deleted_at IS NULL ORDER BY i.created_at DESC"); $stmt->execute([$tenantId]); $invoices = $stmt->fetchAll(); } else { $stmt = $db->prepare("SELECT i.*, c.name as company_name FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.tenant_id = ? AND i.company_id = ? AND i.deleted_at IS NULL ORDER BY i.created_at DESC"); $stmt->execute([$tenantId, $assignedCompanyId]); $invoices = $stmt->fetchAll(); } Response::json([ 'success' => true, 'data' => $invoices ]); } public function upload(Request $request): void { $files = $request->getFiles(); if (empty($files['invoice'])) { Response::error('يرجى اختيار ملف الفاتورة', 'MISSING_FILE', 422); return; } $companyId = $request->input('company_id'); if (!$companyId) { Response::error('يرجى تحديد الشركة', 'MISSING_COMPANY', 422); return; } try { $tenantId = $request->tenantId; $filePath = $this->storage->store($files['invoice'], $tenantId, $companyId); $fileHash = $this->storage->getHash($filePath); // Create invoice record $invoiceId = \Ramsey\Uuid\Uuid::uuid4()->toString(); $this->invoiceModel->create([ 'id' => $invoiceId, 'tenant_id' => $tenantId, 'company_id' => $companyId, 'uploaded_by' => $request->user->user_id, 'status' => 'uploaded', // Match schema ENUM 'original_file_path' => $filePath, 'original_file_hash' => $fileHash, 'idempotency_key' => bin2hex(random_bytes(16)) ]); // 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], 'message' => 'تم رفع الفاتورة بنجاح وجاري استخراج البيانات بالذكاء الاصطناعي' ], 202); } catch (Throwable $e) { Response::error($e->getMessage(), 'UPLOAD_FAILED', 500); } } public function detail(Request $request, array $vars): void { $tenantId = $request->tenantId; $invoiceId = $vars['id'] ?? null; $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1"); $stmt->execute([$invoiceId, $tenantId]); $invoice = $stmt->fetch(); if (!$invoice) { Response::error('الفاتورة غير موجودة', 'NOT_FOUND', 404); return; } // Additional authorization check based on assigned company if needed $role = $request->user->role ?? 'viewer'; if ($role !== 'super_admin' && $invoice['company_id'] !== $request->user->assigned_company_id) { Response::error('غير مصرح لك بمشاهدة هذه الفاتورة', 'FORBIDDEN', 403); return; } // Fetch lines $stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY id ASC"); $stmt->execute([$invoiceId]); $invoice['lines'] = $stmt->fetchAll(); Response::json([ 'success' => true, 'data' => $invoice ]); } public function submit(Request $request, array $vars): void { $tenantId = $request->tenantId; $invoiceId = $vars['id']; // Push to Queue for JoFotara Submission \App\Services\QueueService::push('submit_jofotara', [ 'invoice_id' => $invoiceId ]); Response::json([ 'success' => true, 'message' => 'Invoice submission queued.' ]); } } ``` --- ## الملف: `app/Modules/Auth/AuthService.php` ```php userModel->findByEmail($email); if (!$user || !password_verify($password, $user['password_hash'])) { throw new Exception("البريد الإلكتروني أو كلمة المرور غير صحيحة"); } if (!$user['is_active']) { throw new Exception("هذا الحساب معطل حالياً"); } $accessToken = $this->jwtService->issueAccessToken([ 'user_id' => $user['id'], 'tenant_id' => $user['tenant_id'], 'role' => $user['role'], 'assigned_company_id' => $user['assigned_company_id'] ]); $refreshToken = $this->jwtService->issueRefreshToken($user['id']); // Update refresh token hash in DB $this->userModel->update($user['id'], [ 'refresh_token_hash' => password_hash($refreshToken, PASSWORD_BCRYPT), 'last_login_at' => date('Y-m-d H:i:s'), 'last_login_ip' => $_SERVER['REMOTE_ADDR'] ?? null ]); return [ 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'user' => [ 'id' => $user['id'], 'name' => $user['name'], 'email' => $user['email'], 'role' => $user['role'], 'assigned_company_id' => $user['assigned_company_id'] ] ]; } 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']); } } ``` --- ## الملف: `app/Modules/Auth/AuthController.php` ```php input('email'); $password = $request->input('password'); if (!$email || !$password) { Response::error('يرجى إدخال البريد الإلكتروني وكلمة المرور', 'VALIDATION_ERROR', 422); return; } try { $result = $this->authService->login($email, $password); // 2FA Check if ($result['user']->totp_enabled) { Response::json([ 'success' => true, 'requires_2fa' => true, 'temp_token' => $result['access_token'] ]); return; } // Set refresh token in HttpOnly cookie setcookie('refresh_token', $result['refresh_token'], [ 'expires' => time() + (60 * 60 * 24 * 7), 'path' => '/api/v1/auth/refresh', 'httponly' => true, 'samesite' => 'Strict', 'secure' => true ]); unset($result['refresh_token']); Response::json([ 'success' => true, 'data' => $result, 'message' => 'تم تسجيل الدخول بنجاح' ]); } catch (Throwable $e) { Response::error($e->getMessage(), 'AUTH_FAILED', 401); } } public function me(Request $request): void { $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("SELECT id, tenant_id, name, email, role, totp_enabled FROM users WHERE id = ?"); $stmt->execute([$request->user->user_id]); $user = $stmt->fetch(); Response::json([ 'success' => true, 'data' => $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); } } public function enable2FA(Request $request): void { $user = $request->user; $totpService = new \App\Services\TotpService(); $secret = $totpService->generateSecret(); $qrUrl = $totpService->getQrCodeUrl($user->email, $secret); Response::json([ 'success' => true, 'data' => [ 'secret' => $secret, 'qr_url' => $qrUrl ] ]); } public function verify2FA(Request $request): void { $data = $request->getBody(); $code = $data['code'] ?? ''; $secret = $data['secret'] ?? ''; $totpService = new \App\Services\TotpService(); if ($totpService->verify($secret, $code)) { $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?"); $stmt->execute([$secret, $request->user->user_id]); Response::json(['success' => true, 'message' => 'تم تفعيل التحقق الثنائي بنجاح']); } else { Response::error('رمز التحقق غير صحيح', 'INVALID_CODE', 400); } } public function disable2FA(Request $request): void { $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?"); $stmt->execute([$request->user->user_id]); Response::json(['success' => true, 'message' => 'تم تعطيل التحقق الثنائي']); } } ``` --- ## الملف: `app/Modules/ApiKeys/ApiKeyModel.php` ```php db()->prepare("SELECT id, name, prefix, expires_at, last_used_at, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL"); $stmt->execute([$tenantId]); return $stmt->fetchAll(); } } ``` --- ## الملف: `app/Modules/ApiKeys/ApiKeyController.php` ```php tenantId; $keys = $this->apiKeyModel->findAllByTenant($tenantId); Response::json([ 'success' => true, 'data' => $keys ]); } public function create(Request $request): void { $tenantId = $request->tenantId; $data = $request->getBody(); if (empty($data['name'])) { Response::error('اسم المفتاح مطلوب', 'VALIDATION_ERROR', 422); return; } $id = \Ramsey\Uuid\Uuid::uuid4()->toString(); // Generate a random key $rawKey = bin2hex(random_bytes(32)); $prefix = substr($rawKey, 0, 8); $hashedKey = hash('sha256', $rawKey); $this->apiKeyModel->create([ 'id' => $id, 'tenant_id' => $tenantId, 'name' => $data['name'], 'key_hash' => $hashedKey, 'prefix' => $prefix, 'is_active' => 1 ]); Response::json([ 'success' => true, 'message' => 'تم إنشاء مفتاح API بنجاح', 'data' => [ 'id' => $id, 'name' => $data['name'], 'key' => $rawKey // Only shown once! ] ], 201); } } ``` --- ## الملف: `app/Modules/Admin/AdminController.php` ```php user->role ?? '') !== 'super_admin') { Response::error('غير مصرح', 'FORBIDDEN', 403); return; } $db = Database::getInstance(); $stmt = $db->prepare("SELECT COUNT(*) as count FROM tenants"); $stmt->execute(); $totalTenants = $stmt->fetch()['count']; $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices"); $stmt->execute(); $totalInvoices = $stmt->fetch()['count']; // Simple Health Check $redisHealth = 'ok'; try { $redis = \App\Core\Redis::getInstance(); $redis->ping(); } catch (\Throwable $e) { $redisHealth = 'failed'; } Response::json([ 'success' => true, 'data' => [ 'total_tenants' => $totalTenants, 'total_invoices' => $totalInvoices, 'system_health' => [ 'database' => 'ok', 'redis' => $redisHealth ] ] ]); } } ``` --- ## الملف: `app/Modules/Tenants/TenantModel.php` ```php db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1"); $stmt->execute([$email]); return $stmt->fetch() ?: null; } } ``` --- ## الملف: `app/Modules/Tenants/TenantController.php` ```php tenantId; $tenant = $this->tenantModel->find($tenantId); if (!$tenant) { Response::error('المستأجر غير موجود', 'NOT_FOUND', 404); return; } Response::json([ 'success' => true, 'data' => $tenant ]); } } ``` --- ## الملف: `app/Modules/Subscriptions/SubscriptionController.php` ```php tenantId; $subscription = $this->subscriptionModel->findByTenantId($tenantId); if (!$subscription) { Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404); return; } Response::json([ 'success' => true, 'data' => $subscription ]); } } ``` --- ## الملف: `app/Modules/Subscriptions/SubscriptionModel.php` ```php db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1"); $stmt->execute([$tenantId]); return $stmt->fetch() ?: null; } } ``` --- ## الملف: `app/Modules/Dashboard/DashboardController.php` ```php tenantId; $role = $request->user->role ?? 'viewer'; $assignedCompanyId = $request->user->assigned_company_id ?? null; $db = Database::getInstance(); $where = "WHERE tenant_id = ?"; $params = [$tenantId]; if ($role !== 'super_admin') { $where .= " AND company_id = ?"; $params[] = $assignedCompanyId; } // 1. Total Invoices this month $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices {$where} AND MONTH(created_at) = MONTH(CURRENT_DATE)"); $stmt->execute($params); $thisMonth = $stmt->fetch()['count']; // 2. Approved vs Rejected $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices {$where} GROUP BY status"); $stmt->execute($params); $statusCounts = $stmt->fetchAll(); // 3. Recent Activity - Fixed ambiguity $stmt = $db->prepare("SELECT i.*, c.name as company_name FROM invoices i JOIN companies c ON i.company_id = c.id WHERE i.tenant_id = ? " . ($role !== 'super_admin' ? " AND i.company_id = ?" : "") . " ORDER BY i.created_at DESC LIMIT 5"); $stmt->execute($params); $recent = $stmt->fetchAll(); Response::json([ 'success' => true, 'data' => [ 'total_this_month' => $thisMonth, 'status_distribution' => $statusCounts, 'recent_invoices' => $recent, 'subscription_usage' => 45 // Placeholder ] ]); } } ``` --- ## الملف: `app/Modules/AI/AIController.php` ```php httpClient = new Client(); $this->apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; } public function query(Request $request): void { $userQuery = $request->input('query'); if (!$userQuery) { Response::error('يرجى تقديم استفسار', 'MISSING_QUERY', 422); return; } try { // 1. Fetch current context data (Summary of stats) $stats = $this->getQuickStats($request->tenantId); // 2. Ask Gemini to interpret and answer $prompt = "You are Musadaq AI Assistant for a Jordanian E-Invoicing SaaS. " . "The user is asking: \"{$userQuery}\". " . "Current User Context: Tenant ID {$request->tenantId}. " . "Current Data Summary: " . json_encode($stats) . ". " . "Answer the user in a friendly Arabic tone (Jordanian dialect is okay). " . "Keep it professional and concise. If you don't have the specific data, say so politely."; $response = $this->httpClient->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [ 'json' => [ 'contents' => [['parts' => [['text' => $prompt]]]] ] ]); $data = json_decode($response->getBody()->getContents(), true); $answer = $data['candidates'][0]['content']['parts'][0]['text'] ?? 'عذراً، لم أستطع فهم الاستفسار حالياً.'; Response::json([ 'success' => true, 'data' => [ 'answer' => $answer ] ]); } catch (Throwable $e) { Response::error('فشل معالجة الاستعلام الذكي', 'AI_QUERY_FAILED', 500, [ 'error' => $e->getMessage() ]); } } private function getQuickStats(string $tenantId): array { $db = Database::getInstance(); $totalInvoices = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ?"); $totalInvoices->execute([$tenantId]); $approvedCount = $db->prepare("SELECT COUNT(*) as total FROM invoices WHERE tenant_id = ? AND status = 'approved'"); $approvedCount->execute([$tenantId]); return [ 'total_invoices' => $totalInvoices->fetch()['total'], 'approved_invoices' => $approvedCount->fetch()['total'], 'current_month' => date('F Y') ]; } } ``` --- ## الملف: `app/Modules/Users/UserController.php` ```php 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 ]); } public function create(Request $request): void { $tenantId = $request->tenantId; $data = $request->getBody(); if (empty($data['email']) || empty($data['password']) || empty($data['name']) || empty($data['role'])) { Response::error('جميع الحقول مطلوبة', 'VALIDATION_ERROR', 422); return; } if ($this->userModel->findByEmail($data['email'])) { Response::error('البريد الإلكتروني مستخدم مسبقاً', 'DUPLICATE_EMAIL', 409); return; } $userId = \Ramsey\Uuid\Uuid::uuid4()->toString(); $this->userModel->create([ 'id' => $userId, 'tenant_id' => $tenantId, 'name' => $data['name'], 'email' => $data['email'], 'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID), 'role' => $data['role'], 'assigned_company_id' => $data['assigned_company_id'] ?? null, 'is_active' => 1 ]); Response::json([ 'success' => true, 'message' => 'تم إضافة المستخدم بنجاح', 'data' => ['id' => $userId] ], 201); } } ``` --- ## الملف: `app/Modules/Users/UsersController.php` ```php user->role ?? 'viewer'; if (!in_array($currentUserRole, ['super_admin', 'admin'])) { Response::error('ليس لديك صلاحية لعرض المستخدمين', 'FORBIDDEN', 403); return; } try { $tenantId = $request->tenantId; $db = Database::getInstance(); $stmt = $db->prepare("SELECT id, name, email, role, is_active, created_at FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at DESC"); $stmt->execute([$tenantId]); $users = $stmt->fetchAll(); Response::json([ 'success' => true, 'data' => $users ]); } catch (Throwable $e) { Response::error('Failed to load users: ' . $e->getMessage(), 'USERS_FETCH_ERROR', 500); } } public function create(Request $request): void { $currentUserRole = $request->user->role ?? 'viewer'; $currentAssignedCompanyId = $request->user->assigned_company_id ?? null; if (!in_array($currentUserRole, ['super_admin', 'admin'])) { Response::error('ليس لديك صلاحية لإضافة مستخدمين', 'FORBIDDEN', 403); return; } $name = $request->input('name'); $email = $request->input('email'); $password = $request->input('password'); $role = $request->input('role', 'accountant'); $assignedCompanyId = $request->input('assigned_company_id'); // Admin can only create accountants and employees. Only super_admin can create admins. if ($currentUserRole === 'admin') { if (in_array($role, ['admin', 'super_admin'])) { Response::error('لا تملك الصلاحية لإضافة مدراء', 'FORBIDDEN', 403); return; } // Admin automatically assigns their own company to the new user $assignedCompanyId = $currentAssignedCompanyId; } // Validate valid roles $validRoles = ['super_admin', 'admin', 'accountant', 'employee', 'viewer']; if (!in_array($role, $validRoles)) { Response::error('صلاحية غير صالحة', 'VALIDATION_ERROR', 422); return; } if (!$name || !$email || !$password) { Response::error('الاسم والبريد وكلمة المرور مطلوبة', 'VALIDATION_ERROR', 422); return; } try { // Check if email exists if ($this->userModel->findByEmail($email)) { Response::error('البريد الإلكتروني مستخدم بالفعل', 'EMAIL_EXISTS', 409); return; } $userId = \Ramsey\Uuid\Uuid::uuid4()->toString(); $this->userModel->create([ 'id' => $userId, 'tenant_id' => $request->tenantId, 'name' => $name, 'email' => $email, 'password_hash' => password_hash($password, PASSWORD_BCRYPT), 'role' => $role, 'assigned_company_id' => $assignedCompanyId, 'is_active' => 1 ]); Response::json([ 'success' => true, 'message' => 'تم إنشاء المستخدم بنجاح', 'data' => ['id' => $userId] ]); } catch (Throwable $e) { Response::error($e->getMessage(), 'USER_CREATE_ERROR', 500); } } } ``` --- ## الملف: `app/Modules/Users/UserModel.php` ```php table} WHERE email = ? AND deleted_at IS NULL"; $params = [$email]; if ($tenantId) { $sql .= " AND tenant_id = ?"; $params[] = $tenantId; } $stmt = $this->db()->prepare($sql); $stmt->execute($params); return $stmt->fetch() ?: null; } 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; } } ``` --- ## الملف: `app/Modules/Companies/CompanyService.php` ```php toString(); } // Encrypt sensitive JoFotara credentials if (isset($data['jofotara_client_id'])) { $data['jofotara_client_id_encrypted'] = $this->encryption->encrypt($data['jofotara_client_id']); unset($data['jofotara_client_id']); } if (isset($data['jofotara_secret_key'])) { $data['jofotara_secret_key_encrypted'] = $this->encryption->encrypt($data['jofotara_secret_key']); unset($data['jofotara_secret_key']); } return (string)$this->companyModel->create($data); } public function updateJoFotara(string $id, array $data): bool { if (isset($data['jofotara_client_id'])) { $data['jofotara_client_id_encrypted'] = $this->encryption->encrypt($data['jofotara_client_id']); unset($data['jofotara_client_id']); } if (isset($data['jofotara_secret_key'])) { $data['jofotara_secret_key_encrypted'] = $this->encryption->encrypt($data['jofotara_secret_key']); unset($data['jofotara_secret_key']); } return $this->companyModel->update($id, $data); } public function getJoFotaraCredentials(string $companyId): array { $company = $this->companyModel->find($companyId); if (!$company) return []; return [ 'clientId' => $company['jofotara_client_id_encrypted'] ? $this->encryption->decrypt($company['jofotara_client_id_encrypted']) : null, 'secretKey' => $company['jofotara_secret_key_encrypted'] ? $this->encryption->decrypt($company['jofotara_secret_key_encrypted']) : null, ]; } } ``` --- ## الملف: `app/Modules/Companies/CompanyController.php` ```php tenantId; $role = $request->user->role ?? 'viewer'; $assignedCompanyId = $request->user->assigned_company_id ?? null; if ($role === 'super_admin') { $companies = $this->companyModel->findByTenant($tenantId); } else { // Filter by assigned company $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("SELECT * FROM companies WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL"); $stmt->execute([$tenantId, $assignedCompanyId]); $companies = $stmt->fetchAll(); } Response::json([ 'success' => true, 'data' => $companies ]); } public function create(Request $request): void { $data = $request->getBody(); $data['tenant_id'] = $request->tenantId; try { $companyId = $this->companyService->createCompany($data); Response::json([ 'success' => true, 'data' => ['id' => $companyId], 'message' => 'تم إضافة الشركة بنجاح' ], 201); } catch (Throwable $e) { Response::error('فشل إضافة الشركة', 'CREATE_FAILED', 500); } } public function updateJoFotara(Request $request, string $id): void { $data = [ 'jofotara_client_id' => $request->input('client_id'), 'jofotara_secret_key' => $request->input('secret_key'), 'is_jofotara_linked' => 1 ]; try { $this->companyService->updateJoFotara($id, $data); Response::json([ 'success' => true, 'message' => 'تم تحديث بيانات جو-فواتير بنجاح' ]); } catch (Throwable $e) { Response::error('فشل تحديث البيانات', 'UPDATE_FAILED', 500); } } } ``` --- ## الملف: `app/Modules/Companies/CompanyModel.php` ```php db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL"); $stmt->execute([$tenantId]); return $stmt->fetchAll(); } } ``` --- ## الملف: `app/Services/AiExtractionService.php` ```php apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; } public function extractInvoiceData(string $filePath, string $mimeType): array { if (empty($this->apiKey)) { throw new Exception("Gemini API Key is missing. Please configure it in .env"); } $fileContent = file_get_contents($filePath); if ($fileContent === false) { throw new Exception("Could not read uploaded invoice file."); } $base64Data = base64_encode($fileContent); $prompt = "Please extract the following information from this invoice and return it strictly as JSON without markdown blocks or backticks:\n" . "- invoice_number\n" . "- invoice_date (YYYY-MM-DD)\n" . "- total_amount\n" . "- tax_amount\n" . "- vendor_name\n" . "- vendor_tax_number"; $payload = [ 'contents' => [ [ 'parts' => [ ['text' => $prompt], [ 'inline_data' => [ 'mime_type' => $mimeType, 'data' => $base64Data ] ] ] ] ], 'generationConfig' => [ 'temperature' => 0.1, 'response_mime_type' => 'application/json' ] ]; $url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}"; $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new Exception("AI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}"); } $result = json_decode($response, true); $text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; $data = json_decode($text, true); if (!is_array($data)) { throw new Exception("Failed to parse AI output as JSON: {$text}"); } return $data; } } ``` --- ## الملف: `app/Services/FileStorageService.php` ```php storagePath = dirname(__DIR__, 2) . '/storage'; } public function store(array $file, string $tenantId, string $companyId): string { // 1. Validate MIME $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'application/json', 'text/plain', 'text/xml', 'application/xml']; if (!in_array($mime, $allowedMimes)) { throw new Exception("نوع الملف غير مسموح به ({$mime})"); } // 2. Generate path $dir = $this->storagePath . '/invoices/' . $tenantId . '/' . $companyId; if (!is_dir($dir)) { if (!mkdir($dir, 0777, true)) { $err = error_get_last(); throw new Exception("فشل إنشاء مجلد الحفظ: " . $dir . " - " . ($err['message'] ?? '')); } } $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = hash('sha256', $file['name'] . time() . uniqid()) . '.' . $extension; $targetPath = $dir . '/' . $filename; if (isset($file['error']) && $file['error'] !== UPLOAD_ERR_OK) { throw new Exception("حدث خطأ أثناء رفع الملف من المتصفح. كود الخطأ: " . $file['error']); } if (!move_uploaded_file($file['tmp_name'], $targetPath)) { // Fallback for some non-standard PHP environments if (!copy($file['tmp_name'], $targetPath)) { $err = error_get_last(); throw new Exception("فشل نقل الملف إلى: " . $targetPath . " - " . ($err['message'] ?? '')); } } return $targetPath; } public function getHash(string $filePath): string { return hash_file('sha256', $filePath); } } ``` --- ## الملف: `app/Services/QueueService.php` ```php bin2hex(random_bytes(16)), 'type' => $type, 'payload' => $payload, 'priority' => $priority, 'attempts' => 0, 'created_at' => time() ]; try { $redis = Redis::getInstance(); $redis->lpush(self::REDIS_QUEUE, json_encode($job)); } catch (\Throwable $e) { // Fallback to MySQL self::pushToDatabase($job); } } private static function pushToDatabase(array $job): void { $db = Database::getInstance(); $stmt = $db->prepare("INSERT INTO queue_jobs (id, type, payload, priority, status) VALUES (?, ?, ?, ?, 'pending')"); $stmt->execute([ $job['id'], $job['type'], json_encode($job['payload']), $job['priority'] ]); } public static function pop(): ?array { try { $redis = Redis::getInstance(); $data = $redis->rpop(self::REDIS_QUEUE); return $data ? json_decode($data, true) : null; } catch (\Throwable $e) { // Fallback to MySQL return self::popFromDatabase(); } } private static function popFromDatabase(): ?array { $db = Database::getInstance(); $db->beginTransaction(); try { $stmt = $db->prepare("SELECT * FROM queue_jobs WHERE status = 'pending' ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE"); $stmt->execute(); $job = $stmt->fetch(); if ($job) { $db->prepare("UPDATE queue_jobs SET status = 'processing', locked_at = NOW() WHERE id = ?")->execute([$job['id']]); $db->commit(); return [ 'id' => $job['id'], 'type' => $job['type'], 'payload' => json_decode($job['payload'], true), 'attempts' => $job['attempts'] ]; } $db->commit(); } catch (\Throwable $e) { $db->rollBack(); } return null; } } ``` --- ## الملف: `app/Services/SubscriptionService.php` ```php prepare("SELECT * FROM subscriptions WHERE tenant_id = ? LIMIT 1"); $stmt->execute([$tenantId]); $sub = $stmt->fetch(); if (!$sub) throw new Exception("لا يوجد اشتراك فعال"); if ($type === 'invoices') { if ($sub['invoices_used_this_month'] >= $sub['max_invoices_per_month']) { throw new Exception("لقد وصلت للحد الأقصى من الفواتير المسموح بها في خطتك الحالية"); } } if ($type === 'companies') { $countStmt = $db->prepare("SELECT COUNT(*) as total FROM companies WHERE tenant_id = ? AND deleted_at IS NULL"); $countStmt->execute([$tenantId]); $count = $countStmt->fetch()['total']; if ($count >= $sub['max_companies']) { throw new Exception("لقد وصلت للحد الأقصى من الشركات المسموح بها في خطتك الحالية"); } } } public function incrementUsage(string $tenantId, string $type): void { if ($type === 'invoices') { $db = Database::getInstance(); $stmt = $db->prepare("UPDATE subscriptions SET invoices_used_this_month = invoices_used_this_month + 1 WHERE tenant_id = ?"); $stmt->execute([$tenantId]); } } } ``` --- ## الملف: `app/Services/TotpService.php` ```php calculateCode($secret, (int)($currentTime + $i)) === $code) { return true; } } return false; } private function calculateCode(string $secret, int $time): string { $key = $this->base32Decode($secret); $timeHex = str_pad(dechex($time), 16, '0', STR_PAD_LEFT); $timeBin = pack('H*', $timeHex); $hash = hash_hmac('sha1', $timeBin, $key, true); $offset = ord($hash[19]) & 0xf; $otp = ( ((ord($hash[$offset]) & 0x7f) << 24) | ((ord($hash[$offset + 1]) & 0xff) << 16) | ((ord($hash[$offset + 2]) & 0xff) << 8) | (ord($hash[$offset + 3]) & 0xff) ) % 1000000; return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); } private function base32Decode(string $base32): string { $base32 = strtoupper($base32); $buffer = 0; $bufferSize = 0; $decoded = ''; for ($i = 0; $i < strlen($base32); $i++) { $char = $base32[$i]; $pos = strpos(self::ALPHABET, $char); if ($pos === false) continue; $buffer = ($buffer << 5) | $pos; $bufferSize += 5; if ($bufferSize >= 8) { $bufferSize -= 8; $decoded .= chr(($buffer >> $bufferSize) & 0xff); } } return $decoded; } public function getQrCodeUrl(string $userEmail, string $secret, string $issuer = 'Musadaq'): string { $label = urlencode($issuer . ':' . $userEmail); $otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . urlencode($issuer); return "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=" . urlencode($otpauth); } } ``` --- ## الملف: `app/Services/RiskAnalysisService.php` ```php prepare("SELECT status, COUNT(*) as count FROM invoices WHERE company_id = ? GROUP BY status"); $stmt->execute([$companyId]); $stats = $stmt->fetchAll(); $total = 0; $rejected = 0; foreach ($stats as $stat) { $total += $stat['count']; if ($stat['status'] === 'rejected' || $stat['status'] === 'validation_failed') { $rejected += $stat['count']; } } if ($total > 0) { $rejectionRate = $rejected / $total; if ($rejectionRate > 0.10) { // More than 10% rejections $penalty = min(30, (int)(($rejectionRate - 0.10) * 100)); $score -= $penalty; $factors[] = "نسبة رفض عالية: " . round($rejectionRate * 100, 1) . "% (خصم {$penalty} نقطة)"; } } // 2. High Value Cash Invoices $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND invoice_type = 'cash' AND grand_total > 5000"); $stmt->execute([$companyId]); $highValueCash = $stmt->fetch()['count']; if ($highValueCash > 0) { $penalty = min(20, $highValueCash * 2); $score -= $penalty; $factors[] = "وجود فواتير نقدية بقيم عالية: {$highValueCash} فاتورة (خصم {$penalty} نقطة)"; } // 3. Late submissions (invoice_date is much older than created_at) $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices WHERE company_id = ? AND DATEDIFF(created_at, invoice_date) > 7"); $stmt->execute([$companyId]); $lateInvoices = $stmt->fetch()['count']; if ($lateInvoices > 0) { $penalty = min(15, $lateInvoices * 1); $score -= $penalty; $factors[] = "تأخير في رفع الفواتير: {$lateInvoices} فاتورة متأخرة بأكثر من 7 أيام (خصم {$penalty} نقطة)"; } // Determine Risk Level $riskLevel = 'low'; if ($score < 50) { $riskLevel = 'high'; } elseif ($score < 80) { $riskLevel = 'medium'; } return [ 'score' => max(0, $score), 'level' => $riskLevel, 'factors' => $factors, 'calculated_at' => date('Y-m-d H:i:s') ]; } } ``` --- ## الملف: `app/Services/AuditService.php` ```php prepare("INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); // This would be populated from the global Request context $tenantId = $GLOBALS['current_tenant_id'] ?? null; $userId = $GLOBALS['current_user_id'] ?? null; $stmt->execute([ $tenantId, $userId, $action, $entityType, $entityId, $oldData ? json_encode($oldData) : null, $newData ? json_encode($newData) : null, $_SERVER['REMOTE_ADDR'] ?? null, $_SERVER['HTTP_USER_AGENT'] ?? null, $metadata ? json_encode($metadata) : null ]); } } ``` --- ## الملف: `app/Services/TaxValidationService.php` ```php 0.01) { $errors[] = ['code' => 'RULE_001', 'message_ar' => 'مجموع سطور الفاتورة لا يطابق المجموع الكلي']; } // Rule 002: Tax integrity (tax_amount = subtotal × tax_rate) foreach ($lines as $line) { $expectedTax = round($line['quantity'] * $line['unit_price'] * $line['tax_rate'], 3); if (abs($line['tax_amount'] - $expectedTax) > 0.01) { $errors[] = ['code' => 'RULE_002', 'message_ar' => "خطأ في حساب الضريبة للسطر {$line['line_number']}"]; } } // Rule 003: Invoice number required if (empty($invoice['invoice_number'])) { $errors[] = ['code' => 'RULE_003', 'message_ar' => 'رقم الفاتورة مطلوب']; } // Rule 004: No future dates if (strtotime($invoice['invoice_date']) > time()) { $errors[] = ['code' => 'RULE_004', 'message_ar' => 'تاريخ الفاتورة لا يمكن أن يكون في المستقبل']; } // Rule 005: Valid JO Tax Rates $validRates = [0.16, 0.10, 0.05, 0.04, 0.02, 0.00]; foreach ($lines as $line) { if (!in_array(round((float)$line['tax_rate'], 2), $validRates)) { $errors[] = ['code' => 'RULE_005', 'message_ar' => "نسبة الضريبة ({$line['tax_rate']}) غير صالحة في الأردن"]; } } // Rule 006: Buyer ID for large invoices (> 10,000 JOD) if ($invoice['grand_total'] > 10000 && empty($invoice['buyer_tin']) && empty($invoice['buyer_national_id'])) { $errors[] = ['code' => 'RULE_006', 'message_ar' => 'يجب تزويد الرقم الضريبي أو الوطني للمشتري للفواتير التي تتجاوز 10,000 دينار']; } // Rule 007: Discount integrity $expectedSubtotal = $invoice['subtotal'] - $invoice['discount_total']; // This is a simplified check for Rule 007 if ($expectedSubtotal < 0) { $errors[] = ['code' => 'RULE_007', 'message_ar' => 'إجمالي الخصم لا يمكن أن يتجاوز المجموع الفرعي']; } return [ 'is_valid' => empty($errors), 'errors' => $errors ]; } } ``` --- ## الملف: `app/Services/Security/JwtService.php` ```php secret = $_ENV['JWT_SECRET'] ?? 'change-me'; $this->accessExpiry = (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900); $this->refreshExpiry = (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800); } public function issueAccessToken(array $payload): string { $payload['exp'] = time() + $this->accessExpiry; $payload['iat'] = time(); $payload['jti'] = bin2hex(random_bytes(16)); return JWT::encode($payload, $this->secret, 'HS256'); } public function issueRefreshToken(string $userId): string { // Refresh token is a random string prefixed with userId for lookup $random = bin2hex(random_bytes(32)); return $userId . '.' . $random; } public function verifyToken(string $token): array { try { $decoded = JWT::decode($token, new Key($this->secret, 'HS256')); return (array) $decoded; } catch (Exception $e) { throw new Exception("Invalid or expired token: " . $e->getMessage()); } } } ``` --- ## الملف: `app/Services/Security/EncryptionService.php` ```php key = $_ENV['ENCRYPTION_KEY'] ?? ''; if (strlen($this->key) !== 32) { // In a real app, this would be in config/secrets.php // For now, we use a fallback if not set, but warn in production $this->key = hash('sha256', $_ENV['JWT_SECRET'] ?? 'fallback-key'); } } public function encrypt(string $plaintext): string { $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length(self::METHOD)); $ciphertext = openssl_encrypt($plaintext, self::METHOD, $this->key, 0, $iv, $tag); if ($ciphertext === false) { throw new Exception("Encryption failed."); } return base64_encode($iv) . ':' . base64_encode($ciphertext) . ':' . base64_encode($tag); } public function decrypt(string $encryptedData): string { $parts = explode(':', $encryptedData); if (count($parts) !== 3) { throw new Exception("Invalid encrypted data format."); } [$ivBase64, $ciphertextBase64, $tagBase64] = $parts; $iv = base64_decode($ivBase64); $ciphertext = base64_decode($ciphertextBase64); $tag = base64_decode($tagBase64); $plaintext = openssl_decrypt($ciphertext, self::METHOD, $this->key, 0, $iv, $tag); if ($plaintext === false) { throw new Exception("Decryption failed."); } return $plaintext; } } ``` --- ## الملف: `app/Services/Security/HmacService.php` ```php 300) { return false; } // 2. Replay protection using Nonce in Redis // Note: Redis::getInstance() would be used here // If nonce exists, reject // 3. Calculate Signature $bodyHash = hash('sha256', $body); $stringToSign = strtoupper($method) . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $bodyHash; $calculatedSignature = hash_hmac('sha256', $stringToSign, $secret); return hash_equals($calculatedSignature, $providedSignature); } public function sign(string $secret, string $method, string $path, string $timestamp, string $nonce, string $body): string { $bodyHash = hash('sha256', $body); $stringToSign = strtoupper($method) . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $bodyHash; return hash_hmac('sha256', $stringToSign, $secret); } } ``` --- ## الملف: `app/Services/JoFotara/UBLGeneratorService.php` ```php '); $xml->addChild('cbc:UBLVersionID', '2.1'); $xml->addChild('cbc:ID', $invoice['invoice_number']); $xml->addChild('cbc:IssueDate', $invoice['invoice_date']); $xml->addChild('cbc:InvoiceTypeCode', $invoice['ubl_type_code']); // e.g. 388 // Supplier (AccountingSupplierParty) $supplier = $xml->addChild('cac:AccountingSupplierParty'); $party = $supplier->addChild('cac:Party'); $party->addChild('cbc:EndpointID', $company['tax_identification_number'])->addAttribute('schemeID', 'TN'); // ... (Adding more UBL fields like totals, lines, etc.) // Note: For brevity, this is a simplified structure. In production, // we follow the exact ISTD XML Schema for Jordan. $legalMonetaryTotal = $xml->addChild('cac:LegalMonetaryTotal'); $legalMonetaryTotal->addChild('cbc:LineExtensionAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:TaxExclusiveAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:TaxInclusiveAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:PayableAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); foreach ($lines as $line) { $invoiceLine = $xml->addChild('cac:InvoiceLine'); $invoiceLine->addChild('cbc:ID', (string)$line['line_number']); $invoiceLine->addChild('cbc:InvoicedQuantity', (string)$line['quantity']); $price = $invoiceLine->addChild('cac:Price'); $price->addChild('cbc:PriceAmount', (string)$line['unit_price'])->addAttribute('currencyID', 'JOD'); } return $xml->asXML(); } } ``` --- ## الملف: `app/Services/JoFotara/JoFotaraGateway.php` ```php client = new Client(); $this->baseUrl = $_ENV['JOFOTARA_BASE_URL'] ?? 'https://backend.jofotara.gov.jo/core/invoices'; } /** * Submit invoice to JoFotara with Circuit Breaker */ public function submitInvoice(string $companyId, string $xmlBase64, array $credentials): array { $cbKey = "cb:jofotara:{$companyId}"; if ($this->isCircuitOpen($cbKey)) { throw new Exception("بوابة جو-فواتير غير متاحة حالياً لهذه الشركة، يرجى المحاولة لاحقاً"); } try { $response = $this->client->post($this->baseUrl, [ 'json' => [ 'clientId' => $credentials['clientId'], 'secretKey' => $credentials['secretKey'], 'invoiceType' => 'invoice', 'invoiceData' => $xmlBase64 ], 'timeout' => 30 ]); $result = json_decode($response->getBody()->getContents(), true); $this->resetFailures($cbKey); return $result; } catch (\Throwable $e) { $this->recordFailure($cbKey); throw $e; } } private function isCircuitOpen(string $key): bool { $redis = Redis::getInstance(); return (bool)$redis->get("{$key}:open"); } private function recordFailure(string $key): void { $redis = Redis::getInstance(); $failures = (int)$redis->incr("{$key}:failures"); if ($failures >= 5) { $redis->setex("{$key}:open", 300, 1); // Open for 5 minutes } } private function resetFailures(string $key): void { $redis = Redis::getInstance(); $redis->del(["{$key}:failures", "{$key}:open"]); } } ``` --- ## الملف: `app/Services/AI/GeminiProvider.php` ```php client = new Client(); $this->apiKey = $_ENV['GEMINI_API_KEY'] ?? ''; $this->model = $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash'; } public function extractFromFile(string $filePath, string $mimeType): ExtractionResultDTO { $fileData = base64_encode(file_get_contents($filePath)); $prompt = "Extract invoice data from this file. Return ONLY valid JSON (no markdown). " . "Fields: invoice_number, invoice_date (YYYY-MM-DD), supplier_name, supplier_tin, supplier_address, " . "buyer_name, buyer_tin, lines (description, quantity, unit_price, line_total, tax_rate), " . "subtotal, tax_amount, grand_total, currency (JOD), confidence (0-1)."; $response = $this->client->post("https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent?key={$this->apiKey}", [ 'json' => [ 'contents' => [ [ 'parts' => [ ['text' => $prompt], [ 'inline_data' => [ 'mime_type' => $mimeType, 'data' => $fileData ] ] ] ] ], 'generationConfig' => [ 'response_mime_type' => 'application/json' ] ] ]); $data = json_decode($response->getBody()->getContents(), true); $jsonStr = $data['candidates'][0]['content']['parts'][0]['text'] ?? '{}'; $result = json_decode($jsonStr, true); return new ExtractionResultDTO( $result['invoice_number'] ?? '', $result['invoice_date'] ?? '', $result['supplier_name'] ?? '', $result['supplier_tin'] ?? null, $result['supplier_address'] ?? '', $result['buyer_name'] ?? null, $result['buyer_tin'] ?? null, $result['lines'] ?? [], (float)($result['subtotal'] ?? 0), (float)($result['tax_amount'] ?? 0), (float)($result['grand_total'] ?? 0), $result['currency'] ?? 'JOD', (float)($result['confidence'] ?? 0), $data['usageMetadata'] ?? [] ); } public function getProviderName(): string { return 'gemini'; } } ``` --- ## الملف: `app/Services/AI/OpenAIProvider.php` ```php apiKey = $_ENV['OPENAI_API_KEY'] ?? ''; $this->model = $_ENV['OPENAI_MODEL'] ?? 'gpt-4o-mini'; } public function isConfigured(): bool { return !empty($this->apiKey); } public function extractInvoiceData(string $fileContent, string $mimeType, string $prompt): array { if (!$this->isConfigured()) { throw new Exception("OpenAI API Key is missing. Please configure it in .env"); } $base64Data = base64_encode($fileContent); $payload = [ 'model' => $this->model, 'messages' => [ [ 'role' => 'user', 'content' => [ [ 'type' => 'text', 'text' => $prompt ], [ 'type' => 'image_url', 'image_url' => [ 'url' => "data:{$mimeType};base64,{$base64Data}" ] ] ] ] ], 'response_format' => ['type' => 'json_object'], 'temperature' => 0.1 ]; $ch = curl_init('https://api.openai.com/v1/chat/completions'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', "Authorization: Bearer {$this->apiKey}" ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { throw new Exception("OpenAI Extraction failed. HTTP Code: {$httpCode}. Response: {$response}"); } $result = json_decode($response, true); $text = $result['choices'][0]['message']['content'] ?? '{}'; $data = json_decode($text, true); if (!is_array($data)) { throw new Exception("Failed to parse OpenAI output as JSON: {$text}"); } return $data; } } ``` --- ## الملف: `app/Services/AI/Contracts/AIProviderInterface.php` ```php [ 'secret' => $_ENV['JWT_SECRET'] ?? '', 'access_expiry' => (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900), 'refresh_expiry' => (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800), ], ]; ``` --- ## الملف: `config/app.php` ```php $_ENV['APP_NAME'] ?? 'مُصادَق', 'env' => $_ENV['APP_ENV'] ?? 'production', 'url' => $_ENV['APP_URL'] ?? 'https://musadeq2.intaleqapp.com', 'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Asia/Amman', ]; ``` --- ## الملف: `config/services.php` ```php [ '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'] ?? 'مُصادَق', ], ]; ``` --- ## الملف: `config/database.php` ```php $_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', ]; ``` --- ## الملف: `config/secrets.php` ```php 'bgMQU/L8QYMd+8Sqh3AvsAXi+Fr+fMyJO+VAdakVoc8=', ]; ``` --- ## الملف: `tests/Unit/TotpServiceTest.php` ```php generateSecret(); $this->assertEquals(16, strlen($secret)); $this->assertMatchesRegularExpression('/^[A-Z2-7]+$/', $secret); } public function test_it_verifies_correct_code(): void { $service = new TotpService(); $secret = 'JBSWY3DPEHPK3PXP'; // Known secret // We can't easily test the code without a time mocker or calculation // but we can check if it fails with an obviously wrong code $this->assertFalse($service->verify($secret, '000000')); } } ``` --- ## الملف: `tests/Unit/HmacTest.php` ```php service = new HmacService(); } public function test_it_verifies_valid_signature(): void { $secret = 'test-secret'; $nonce = 'nonce-123'; $timestamp = (string)time(); $payload = json_encode(['foo' => 'bar']); $signature = $this->service->sign($secret, 'POST', '/api/v1/test', $timestamp, $nonce, $payload); $this->assertTrue($this->service->verify($secret, 'POST', '/api/v1/test', $timestamp, $nonce, $payload, $signature)); } public function test_it_rejects_tampered_payload(): void { $secret = 'test-secret'; $nonce = 'nonce-123'; $timestamp = (string)time(); $payload = json_encode(['foo' => 'bar']); $signature = $this->service->sign($secret, 'POST', '/api/v1/test', $timestamp, $nonce, $payload); $tamperedPayload = json_encode(['foo' => 'baz']); $this->assertFalse($this->service->verify($secret, 'POST', '/api/v1/test', $timestamp, $nonce, $tamperedPayload, $signature)); } } ``` --- ## الملف: `tests/Unit/TaxValidationTest.php` ```php service = new TaxValidationService(); } public function test_it_validates_standard_invoice(): void { $invoice = [ 'invoice_number' => 'INV-001', 'invoice_date' => date('Y-m-d'), 'subtotal' => 100, 'discount_total' => 0, 'grand_total' => 116 ]; $lines = [ ['line_number' => 1, 'quantity' => 1, 'unit_price' => 100, 'tax_rate' => 0.16, 'tax_amount' => 16, 'line_total' => 116] ]; $result = $this->service->validate($invoice, $lines); $this->assertTrue($result['is_valid']); } public function test_it_detects_mismatching_totals(): void { $invoice = [ 'invoice_number' => 'INV-002', 'invoice_date' => date('Y-m-d'), 'subtotal' => 100, 'discount_total' => 0, 'grand_total' => 110 // Mismatch ]; $lines = [ ['line_number' => 1, 'quantity' => 1, 'unit_price' => 100, 'tax_rate' => 0.16, 'tax_amount' => 16, 'line_total' => 116] ]; $result = $this->service->validate($invoice, $lines); $this->assertFalse($result['is_valid']); } } ``` --- ## الملف: `tests/Feature/AuthTest.php` ```php assertTrue(true); } } ``` --- ## الملف: `public/index.html` ``` مُصادَق — أتمتة الفواتير الضريبية

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

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

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

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

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

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

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

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

``` --- ## الملف: `public/index.php` ```php getRouter(); // ══ Auth Routes ══════════════════════════════════════════════ $router->addRoute('POST', '/api/v1/auth/login', [AuthController::class, 'login']); $router->addRoute('POST', '/api/v1/auth/register', [AuthController::class, 'register']); $router->addRoute('GET', '/api/v1/auth/me', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'me'] ]); $router->addRoute('POST', '/api/v1/auth/2fa/enable', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'enable2FA'] ]); $router->addRoute('POST', '/api/v1/auth/2fa/verify', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'verify2FA'] ]); $router->addRoute('POST', '/api/v1/auth/2fa/disable', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [AuthController::class, 'disable2FA'] ]); // ══ Company Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/companies', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Companies\CompanyController::class, 'list'] ]); $router->addRoute('POST', '/api/v1/companies', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Companies\CompanyController::class, 'create'] ]); $router->addRoute('POST', '/api/v1/companies/{id}/jofotara', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Companies\CompanyController::class, 'updateJoFotara'] ]); // ══ User Routes ══════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Users\UserController::class, 'index'] ]); $router->addRoute('POST', '/api/v1/users', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Users\UserController::class, 'create'] ]); // ══ Invoice Routes ═══════════════════════════════════════════ $router->addRoute('GET', '/api/v1/invoices', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'list'] ]); $router->addRoute('POST', '/api/v1/invoices/upload', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'upload'] ]); $router->addRoute('GET', '/api/v1/invoices/{id}', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'detail'] ]); $router->addRoute('POST', '/api/v1/invoices/{id}/submit', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'submit'] ]); // ══ Subscriptions ═════════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/subscriptions/me', [ 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], 'handler' => [\App\Modules\Subscriptions\SubscriptionController::class, 'me'] ]); // ══ API Keys ═══════════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/api-keys', [ 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'list'] ]); $router->addRoute('POST', '/api/v1/api-keys', [ 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], 'handler' => [\App\Modules\ApiKeys\ApiKeyController::class, 'create'] ]); // ══ External API (HMAC) ══════════════════════════════════════ $router->addRoute('POST', '/api/v1/external/invoices/upload', [ 'middleware' => [\App\Middleware\HmacMiddleware::class], 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'upload'] ]); // ══ Dashboard ════════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/dashboard', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Dashboard\DashboardController::class, 'getStats'] ]); // ══ Super Admin ══════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/admin/stats', [ 'middleware' => [\App\Middleware\AuthMiddleware::class], 'handler' => [\App\Modules\Admin\AdminController::class, 'getSystemStats'] ]); // ══ Health Check ═════════════════════════════════════════════ $router->addRoute('GET', '/api/v1/health', function($request) { \App\Core\Response::json([ 'status' => 'ok', 'timestamp' => date('c'), 'php' => PHP_VERSION, 'db' => 'connected' // Simple check ]); }); // ══ Determine if this is an API request ═════════════════════════════ $apiRoute = $_GET['route'] ?? null; if (!$apiRoute) { // Not an API call — serve the SPA shell include __DIR__ . '/shell.php'; exit; } $app->run(); ``` --- ## الملف: `public/shell.php` ```php مُصادَق — منصة أتمتة الفواتير الإلكترونية

لوحة التحكم

``` --- ## الملف: `public/api.php` ```php ``` --- ## الملف: `public/assets/css/app.css` ``` @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=JetBrains+Mono&family=Inter:wght@400;500;600&display=swap'); :root { --primary: #10b981; --primary-hover: #059669; --primary-muted: rgba(16,185,129,0.1); --danger: #ef4444; --warning: #f59e0b; --info: #3b82f6; --success: #22c55e; /* Dark (default) */ --bg-app: #0a0f1a; --bg-card: rgba(15,23,42,0.8); --bg-sidebar: #060b14; --bg-input: rgba(15,23,42,0.6); --border: rgba(51,65,85,0.6); --text-primary: #f1f5f9; --text-secondary: #94a3b8; --text-muted: #475569; --glass: rgba(15,23,42,0.6); --glass-border: rgba(255,255,255,0.06); --shadow-glow: 0 0 40px rgba(16,185,129,0.08); } [data-theme="light"] { --bg-app: #f1f5f9; --bg-card: #ffffff; --bg-sidebar: #ffffff; --bg-input: #f8fafc; --border: #e2e8f0; --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; --glass: rgba(255,255,255,0.8); --glass-border: rgba(0,0,0,0.04); --shadow-glow: 0 4px 24px rgba(0,0,0,0.06); } * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Inter', 'IBM Plex Sans Arabic', sans-serif; } body { background-color: var(--bg-app); color: var(--text-primary); direction: rtl; min-height: 100vh; overflow-x: hidden; transition: background-color 0.3s, color 0.3s; } /* Glassmorphism Utilities */ .glass { background: var(--glass); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--glass-border); } .glow { box-shadow: var(--shadow-glow); } /* Custom Scrollbar */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } /* RTL Specifics */ [dir="rtl"] .ml-auto { margin-right: auto; margin-left: 0; } [dir="rtl"] .mr-auto { margin-left: auto; margin-right: 0; } ``` --- ## الملف: `public/assets/js/api.js` ```javascript 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 }; 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 new Error(data.message || 'حدث خطأ ما'); } return data; }, 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 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) { console.error('Refresh failed', e); } localStorage.removeItem('access_token'); return false; } }; ``` --- ## الملف: `scripts/migrate.php` ```php 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"; ``` --- ## الملف: `scripts/seed.php` ```php toString(); $db->prepare("INSERT INTO tenants (id, name, email, status) VALUES (?, ?, ?, 'active')") ->execute([$tenantId, 'شركة انطلاق للحلول الرقمية', 'admin@intaleqapp.com']); // 2. Create Super Admin User $userId = Uuid::uuid4()->toString(); $passwordHash = password_hash('Musadaq@2026', PASSWORD_ARGON2ID); $db->prepare("INSERT INTO users (id, tenant_id, name, email, password_hash, role, is_active) VALUES (?, ?, ?, ?, ?, 'super_admin', 1)") ->execute([$userId, $tenantId, 'Hamza Admin', 'admin@musadaq.app', $passwordHash]); // 3. Create initial subscription $db->prepare("INSERT INTO subscriptions (tenant_id, plan, max_companies, max_invoices_per_month, max_users) VALUES (?, 'pro', 10, 500, 5)") ->execute([$tenantId]); echo "✅ Success! You can now log in with:\n"; echo "📧 Email: admin@musadaq.app\n"; echo "🔑 Password: Musadaq@2026\n"; } catch (\Throwable $e) { echo "❌ Error: " . $e->getMessage() . "\n"; } ``` --- ## الملف: `queue/worker.php` ```php getContainer(); switch($job['type']) { case 'invoice_extraction': $handler = $container->get(\Queue\Jobs\ExtractInvoiceJob::class); $handler->handle($job['payload']); break; case 'submit_jofotara': $handler = $container->get(\Queue\Jobs\SubmitJoFotaraJob::class); $handler->handle($job['payload']); break; case 'risk_analysis': $handler = $container->get(\Queue\Jobs\RiskAnalysisJob::class); $handler->handle($job['payload']); break; case 'send_notification': $handler = $container->get(\Queue\Jobs\SendNotificationJob::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"; // In a real app, you'd handle retries or move to a failed_jobs table } } else { usleep(500000); // 0.5s } } echo "[*] Worker stopped.\n"; ``` --- ## الملف: `queue/Jobs/SubmitJoFotaraJob.php` ```php invoiceModel->update($invoiceId, ['status' => 'submitting']); // 2. Fetch Invoice $db = \App\Core\Database::getInstance(); $stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? LIMIT 1"); $stmt->execute([$invoiceId]); $invoice = $stmt->fetch(); if (!$invoice) { throw new \Exception("Invoice not found."); } // 3. Fetch Company Credentials $credentials = $this->companyService->getJoFotaraCredentials($invoice['company_id']); if (empty($credentials['clientId']) || empty($credentials['secretKey'])) { throw new \Exception("Company is not linked to JoFotara."); } // 4. Fetch Invoice Lines $stmt = $db->prepare("SELECT * FROM invoice_lines WHERE invoice_id = ?"); $stmt->execute([$invoiceId]); $lines = $stmt->fetchAll(); // 5. Generate UBL XML $xmlString = $this->ublGenerator->generate($invoice, $lines); $xmlBase64 = base64_encode($xmlString); // 6. Submit to JoFotara $response = $this->jofotaraGateway->submitInvoice($invoice['company_id'], $xmlBase64, $credentials); // 7. Process Response // Assuming response contains a success boolean and possibly qr_code if (isset($response['success']) && $response['success']) { $this->invoiceModel->update($invoiceId, [ 'status' => 'approved', 'qr_code' => $response['qr_code'] ?? null, 'jofotara_response' => json_encode($response, JSON_UNESCAPED_UNICODE) ]); } else { $this->invoiceModel->update($invoiceId, [ 'status' => 'rejected', 'jofotara_response' => json_encode($response, JSON_UNESCAPED_UNICODE) ]); } } catch (Throwable $e) { $this->invoiceModel->update($invoiceId, [ 'status' => 'validation_failed', 'validation_errors' => json_encode([['message_ar' => 'فشل الإرسال: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE) ]); throw $e; } } } ``` --- ## الملف: `queue/Jobs/ExtractInvoiceJob.php` ```php 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; } } } ``` --- ## الملف: `queue/Jobs/SendNotificationJob.php` ```php prepare("INSERT INTO notifications (id, user_id, title, message, type, is_read, created_at) VALUES (?, ?, ?, ?, ?, 0, NOW())"); $stmt->execute([ \Ramsey\Uuid\Uuid::uuid4()->toString(), $userId, $title, $message, $type ]); // Here we could also trigger WebSockets or push notifications if implemented } catch (Throwable $e) { echo "[!] Notification failed for user {$userId}: " . $e->getMessage() . "\n"; throw $e; } } } ``` --- ## الملف: `queue/Jobs/RiskAnalysisJob.php` ```php riskService->calculateCompanyRiskScore($companyId); // Store or update risk score $db = Database::getInstance(); $stmt = $db->prepare("SELECT id FROM risk_scores WHERE company_id = ? LIMIT 1"); $stmt->execute([$companyId]); $existing = $stmt->fetch(); if ($existing) { $stmt = $db->prepare("UPDATE risk_scores SET risk_level = ?, score = ?, factors = ?, calculated_at = NOW() WHERE company_id = ?"); $stmt->execute([ $analysis['level'], $analysis['score'], json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE), $companyId ]); } else { $stmt = $db->prepare("INSERT INTO risk_scores (id, tenant_id, company_id, risk_level, score, factors, calculated_at) VALUES (?, ?, ?, ?, ?, ?, NOW())"); $stmt->execute([ \Ramsey\Uuid\Uuid::uuid4()->toString(), $tenantId, $companyId, $analysis['level'], $analysis['score'], json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE) ]); } } catch (Throwable $e) { echo "[!] Risk Analysis failed for company {$companyId}: " . $e->getMessage() . "\n"; throw $e; } } } ``` ---