Files
musadaq-saas/musadaq_full_code.md

5895 lines
204 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# مُصادَق — ملخص كود المشروع الكامل
هذا الملف يحتوي على كافة ملفات المصدر للمشروع مجمعة لتسهيل المراجعة.
## الملف: `phpunit.xml`
```
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_DATABASE" value="musadeq_test"/>
</php>
</phpunit>
```
---
## الملف: `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 `<div></div>`;
}
if (page === 'login') return `
<div class="flex flex-col items-center justify-center min-h-[60vh]">
<div class="w-full max-w-md p-8 glass rounded-3xl glow border-white/10">
<h2 class="text-3xl font-bold mb-2 text-center">مرحباً بك مجدداً</h2>
</div>
</div>
`;
return '<div>الصفحة قيد الإنشاء</div>';
}
});
```
---
## الملف: `.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
<?php
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->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
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
final class CsrfMiddleware
{
public function handle(Request $request, callable $next): mixed
{
// Skip CSRF check for safe methods
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
return $next($request);
}
// For APIs, we often use a custom header or check origin
// If we use sessions for tokens:
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$token = $request->getHeader('X-CSRF-TOKEN') ?: ($request->getBody()['_csrf'] ?? null);
$sessionToken = $_SESSION['csrf_token'] ?? null;
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) {
// For now, if we are purely API with Bearer token, we might skip this.
// But if the request has a session or cookie, it's mandatory.
// If the Authorization header is present, we might assume it's an API call
// that is naturally protected against CSRF if not using cookies for Auth.
if ($request->getHeader('Authorization')) {
return $next($request);
}
Response::error('رمز الحماية (CSRF) غير صالح أو مفقود', 'CSRF_INVALID', 403);
return null;
}
return $next($request);
}
}
```
---
## الملف: `app/Middleware/TenantMiddleware.php`
```php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response, Database};
final class TenantMiddleware
{
public function handle(Request $request, callable $next): mixed
{
$tenantId = $request->tenantId ?? null;
if (!$tenantId) {
Response::error('المستأجر غير معروف', 'TENANT_NOT_FOUND', 400);
return null;
}
// Check if tenant exists and is active
try {
$db = Database::getInstance();
$stmt = $db->prepare("SELECT status FROM tenants WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$tenantId]);
$tenant = $stmt->fetch();
if (!$tenant) {
Response::error('المستأجر غير موجود', 'TENANT_NOT_FOUND', 404);
return null;
}
if ($tenant['status'] === 'suspended') {
Response::error('تم إيقاف حساب المستأجر', 'TENANT_SUSPENDED', 403);
return null;
}
} catch (\Exception $e) {
Response::error('خطأ في الاتصال بقاعدة البيانات', 'DATABASE_ERROR', 500);
return null;
}
return $next($request);
}
}
```
---
## الملف: `app/Middleware/RoleMiddleware.php`
```php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
final class RoleMiddleware
{
/**
* Handle the request.
*
* @param Request $request
* @param callable $next
* @param string ...$roles
* @return mixed
*/
public function handle(Request $request, callable $next, string ...$roles): mixed
{
$user = $request->user ?? null;
if (!$user) {
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401);
return null;
}
// Check if user role is in the allowed roles
// $user->role is an object property since we cast it in AuthMiddleware
if (!in_array($user->role, $roles)) {
Response::error('غير مسموح لك بالقيام بهذا الإجراء', 'FORBIDDEN', 403);
return null;
}
return $next($request);
}
}
```
---
## الملف: `app/Middleware/HmacMiddleware.php`
```php
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response, Redis};
use App\Services\Security\HmacService;
use App\Core\Database;
final class HmacMiddleware
{
public function __construct(private readonly HmacService $hmac) {}
public function handle(Request $request, callable $next): mixed
{
$publicKey = $request->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
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response};
use App\Services\Security\JwtService;
use Exception;
final class AuthMiddleware
{
public function __construct(private readonly JwtService $jwtService) {}
public function handle(Request $request, callable $next): mixed
{
$authHeader = $request->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
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\{Request, Response, Redis};
final class RateLimitMiddleware
{
/**
* @param int $limit Requests allowed
* @param int $window Seconds window
*/
public function handle(Request $request, callable $next, int $limit = 60, int $window = 60): mixed
{
$redis = Redis::getInstance();
$ip = $_SERVER['REMOTE_ADDR'];
$key = "ratelimit:" . md5($request->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
<?php
declare(strict_types=1);
namespace App\Core;
use Dotenv\Dotenv;
use App\Core\{Request, Response, Router, Container};
final class Application
{
private Container $container;
private Router $router;
public static ?array $config = null;
public function __construct(string $basePath)
{
// 1. Load Environment Variables
// In local dev, .env is in the project root. In production, it might be moved.
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->load();
// 2. Set Timezone
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
<?php
declare(strict_types=1);
namespace App\Core;
use Exception;
use ReflectionClass;
use ReflectionNamedType;
final class Container
{
private array $instances = [];
public function set(string $id, mixed $concrete): void
{
$this->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
<?php
declare(strict_types=1);
namespace App\Core;
final class Response
{
public static function json(array $data, int $status = 200, array $headers = []): void
{
self::send($data, $status, array_merge(['Content-Type' => '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
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOException;
use Exception;
final class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
$host = $_ENV['DB_HOST'];
$db = $_ENV['DB_DATABASE'];
$user = $_ENV['DB_USERNAME'];
$pass = $_ENV['DB_PASSWORD'];
$port = $_ENV['DB_PORT'];
$charset = $_ENV['DB_CHARSET'] ?? 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;port=$port;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => 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
<?php
declare(strict_types=1);
namespace App\Core;
final class Request
{
private string $method;
private string $path;
private array $headers;
private array $queryParams;
private array $body;
private array $files;
public ?object $user = null; // Populated by AuthMiddleware
public ?string $tenantId = null; // Populated by TenantMiddleware
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Core;
use Predis\Client;
use Exception;
final class Redis
{
private static ?Client $instance = null;
public static function getInstance(): Client
{
if (self::$instance === null) {
try {
self::$instance = new Client([
'scheme' => '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
<?php
declare(strict_types=1);
namespace App\Core;
use FastRoute\RouteCollector;
use function FastRoute\simpleDispatcher;
final class Router
{
private array $routes = [];
public Container $container;
public function __construct(Container $container)
{
$this->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
<?php
declare(strict_types=1);
if (!function_exists('config')) {
/**
* Get a configuration value using dot notation.
* Example: config('app.name')
*/
function config(string $key, mixed $default = null): mixed
{
$configs = \App\Core\Application::$config;
if ($configs === null) {
return $default;
}
$parts = explode('.', $key);
$value = $configs;
foreach ($parts as $part) {
if (!isset($value[$part])) {
return $default;
}
$value = $value[$part];
}
return $value;
}
}
if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
}
```
---
## الملف: `app/Models/BaseModel.php`
```php
<?php
declare(strict_types=1);
namespace App\Models;
use App\Core\Database;
use PDO;
abstract class BaseModel
{
protected string $table;
protected string $primaryKey = 'id';
protected array $fillable = [];
protected function db(): PDO
{
return Database::getInstance();
}
public function find(string $id): ?array
{
$stmt = $this->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
<?php
declare(strict_types=1);
namespace App\Modules\Invoices;
use App\Models\BaseModel;
final class InvoiceModel extends BaseModel
{
protected string $table = 'invoices';
public function findByTenant(string $tenantId): array
{
$stmt = $this->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
<?php
declare(strict_types=1);
namespace App\Modules\Invoices;
use App\Core\{Request, Response};
use App\Services\FileStorageService;
use App\Services\AiExtractionService;
use App\Modules\Invoices\InvoiceModel;
use Throwable;
final class InvoiceController
{
public function __construct(
private readonly InvoiceModel $invoiceModel,
private readonly FileStorageService $storage,
private readonly AiExtractionService $aiExtraction
) {}
public function list(Request $request): void
{
$tenantId = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Modules\Users\UserModel;
use App\Modules\Tenants\TenantModel;
use App\Modules\Subscriptions\SubscriptionModel;
use App\Services\Security\JwtService;
use Ramsey\Uuid\Uuid;
use Exception;
final class AuthService
{
public function __construct(
private readonly UserModel $userModel,
private readonly JwtService $jwtService,
private readonly TenantModel $tenantModel,
private readonly SubscriptionModel $subscriptionModel
) {}
public function login(string $email, string $password): array
{
$user = $this->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
<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Core\{Request, Response};
use App\Modules\Auth\AuthService;
use Throwable;
final class AuthController
{
public function __construct(private readonly AuthService $authService) {}
public function login(Request $request): void
{
$email = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\ApiKeys;
use App\Models\BaseModel;
final class ApiKeyModel extends BaseModel
{
protected string $table = 'api_keys';
public function findAllByTenant(string $tenantId): array
{
$stmt = $this->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
<?php
declare(strict_types=1);
namespace App\Modules\ApiKeys;
use App\Core\{Request, Response};
use App\Modules\ApiKeys\ApiKeyModel;
final class ApiKeyController
{
public function __construct(private readonly ApiKeyModel $apiKeyModel) {}
public function list(Request $request): void
{
$tenantId = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\Admin;
use App\Core\{Request, Response, Database};
final class AdminController
{
public function getSystemStats(Request $request): void
{
// Must be super_admin
if (($request->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
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Models\BaseModel;
final class TenantModel extends BaseModel
{
protected string $table = 'tenants';
public function findByEmail(string $email): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$email]);
return $stmt->fetch() ?: null;
}
}
```
---
## الملف: `app/Modules/Tenants/TenantController.php`
```php
<?php
declare(strict_types=1);
namespace App\Modules\Tenants;
use App\Core\{Request, Response};
use App\Modules\Tenants\TenantModel;
final class TenantController
{
public function __construct(private readonly TenantModel $tenantModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$tenant = $this->tenantModel->find($tenantId);
if (!$tenant) {
Response::error('المستأجر غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $tenant
]);
}
}
```
---
## الملف: `app/Modules/Subscriptions/SubscriptionController.php`
```php
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Core\{Request, Response};
use App\Modules\Subscriptions\SubscriptionModel;
final class SubscriptionController
{
public function __construct(private readonly SubscriptionModel $subscriptionModel) {}
public function me(Request $request): void
{
$tenantId = $request->tenantId;
$subscription = $this->subscriptionModel->findByTenantId($tenantId);
if (!$subscription) {
Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $subscription
]);
}
}
```
---
## الملف: `app/Modules/Subscriptions/SubscriptionModel.php`
```php
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions;
use App\Models\BaseModel;
final class SubscriptionModel extends BaseModel
{
protected string $table = 'subscriptions';
public function findByTenantId(string $tenantId): ?array
{
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1");
$stmt->execute([$tenantId]);
return $stmt->fetch() ?: null;
}
}
```
---
## الملف: `app/Modules/Dashboard/DashboardController.php`
```php
<?php
declare(strict_types=1);
namespace App\Modules\Dashboard;
use App\Core\{Request, Response, Database};
final class DashboardController
{
public function getStats(Request $request): void
{
$tenantId = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\AI;
use App\Core\{Request, Response, Database};
use GuzzleHttp\Client;
use Throwable;
final class AIController
{
private Client $httpClient;
private string $apiKey;
private string $model;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Core\{Request, Response};
use App\Modules\Users\UserModel;
final class UserController
{
public function __construct(private readonly UserModel $userModel) {}
public function index(Request $request): void
{
$tenantId = $request->tenantId;
$users = $this->userModel->findAllByTenant($tenantId);
Response::json([
'success' => true,
'data' => $users
]);
}
public function detail(Request $request, array $vars): void
{
$tenantId = $request->tenantId;
$userId = $vars['id'];
$user = $this->userModel->findById($userId, $tenantId);
if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
return;
}
Response::json([
'success' => true,
'data' => $user
]);
}
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
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Core\{Request, Response, Database};
use Throwable;
final class UsersController
{
public function __construct(private readonly UserModel $userModel) {}
public function list(Request $request): void
{
$currentUserRole = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\Users;
use App\Models\BaseModel;
final class UserModel extends BaseModel
{
protected string $table = 'users';
public function findByEmail(string $email, ?string $tenantId = null): ?array
{
$sql = "SELECT * FROM {$this->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
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Services\Security\EncryptionService;
use App\Modules\Companies\CompanyModel;
use Ramsey\Uuid\Uuid;
final class CompanyService
{
public function __construct(
private readonly CompanyModel $companyModel,
private readonly EncryptionService $encryption
) {}
public function createCompany(array $data): string
{
if (!isset($data['id'])) {
$data['id'] = Uuid::uuid4()->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
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Core\{Request, Response};
use App\Modules\Companies\{CompanyModel, CompanyService};
use Throwable;
final class CompanyController
{
public function __construct(
private readonly CompanyModel $companyModel,
private readonly CompanyService $companyService
) {}
public function list(Request $request): void
{
$tenantId = $request->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
<?php
declare(strict_types=1);
namespace App\Modules\Companies;
use App\Models\BaseModel;
final class CompanyModel extends BaseModel
{
protected string $table = 'companies';
public function findByTenant(string $tenantId): array
{
$stmt = $this->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
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
final class AiExtractionService
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Services;
use Exception;
final class FileStorageService
{
private string $storagePath;
public function __construct()
{
// Use dynamic path to avoid issues if Mac .env is deployed to Linux server
$this->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
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Redis;
use App\Core\Database;
final class QueueService
{
private const REDIS_QUEUE = 'musadaq_jobs';
public static function push(string $type, array $payload, int $priority = 0, int $delay = 0): void
{
$job = [
'id' => 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
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
use Exception;
final class SubscriptionService
{
public function checkLimit(string $tenantId, string $type): void
{
$db = Database::getInstance();
$stmt = $db->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
<?php
declare(strict_types=1);
namespace App\Services;
final class TotpService
{
private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
public function generateSecret(): string
{
$secret = '';
for ($i = 0; $i < 16; $i++) {
$secret .= self::ALPHABET[random_int(0, 31)];
}
return $secret;
}
public function verify(string $secret, string $code): bool
{
$currentTime = floor(time() / 30);
// Check current, previous and next window (allow 30s clock drift)
for ($i = -1; $i <= 1; $i++) {
if ($this->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
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
final class RiskAnalysisService
{
public function calculateCompanyRiskScore(string $companyId): array
{
$db = Database::getInstance();
$score = 100;
$factors = [];
// 1. Rejection Rate
$stmt = $db->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
<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
final class AuditService
{
public static function log(
string $action,
?string $entityType = null,
?string $entityId = null,
?array $oldData = null,
?array $newData = null,
?array $metadata = null
): void {
$db = Database::getInstance();
$stmt = $db->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
<?php
declare(strict_types=1);
namespace App\Services;
final class TaxValidationService
{
/**
* Validate an invoice against Jordan ISTD rules (001-007)
*/
public function validate(array $invoice, array $lines): array
{
$errors = [];
// Rule 001: Total integrity (grand_total = Σ line_totals)
$lineSum = array_sum(array_column($lines, 'line_total'));
if (abs($invoice['grand_total'] - $lineSum) > 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
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
final class JwtService
{
private string $secret;
private int $accessExpiry;
private int $refreshExpiry;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Exception;
final class EncryptionService
{
private string $key;
private const METHOD = 'aes-256-gcm';
public function __construct()
{
// Key should be 32 bytes for aes-256-gcm
$this->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
<?php
declare(strict_types=1);
namespace App\Services\Security;
use App\Core\Redis;
final class HmacService
{
/**
* Verify HMAC signature for external API requests (Flutter)
*/
public function verify(
string $secret,
string $method,
string $path,
string $timestamp,
string $nonce,
string $body,
string $providedSignature
): bool {
// 1. Check timestamp (within 5 minutes)
if (abs(time() - (int)$timestamp) > 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
<?php
declare(strict_types=1);
namespace App\Services\JoFotara;
final class UBLGeneratorService
{
/**
* Generate UBL 2.1 XML for Jordan ISTD
*/
public function generate(array $invoice, array $lines, array $company): string
{
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"></Invoice>');
$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
<?php
declare(strict_types=1);
namespace App\Services\JoFotara;
use GuzzleHttp\Client;
use App\Core\Redis;
use Exception;
final class JoFotaraGateway
{
private Client $client;
private string $baseUrl;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Services\AI;
use App\Services\AI\Contracts\{AIProviderInterface, ExtractionResultDTO};
use GuzzleHttp\Client;
use Exception;
final class GeminiProvider implements AIProviderInterface
{
private Client $client;
private string $apiKey;
private string $model;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Services\AI;
use App\Services\AI\Contracts\AIProviderInterface;
use Exception;
final class OpenAIProvider implements AIProviderInterface
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->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
<?php
declare(strict_types=1);
namespace App\Services\AI\Contracts;
final class ExtractionResultDTO
{
public function __construct(
public string $invoiceNumber,
public string $invoiceDate,
public string $supplierName,
public ?string $supplierTin,
public string $supplierAddress,
public ?string $buyerName,
public ?string $buyerTin,
public array $lines,
public float $subtotal,
public float $taxAmount,
public float $grand_total,
public string $currency,
public float $confidence,
public array $usage
) {}
}
interface AIProviderInterface
{
public function extractFromFile(string $filePath, string $mimeType): ExtractionResultDTO;
public function getProviderName(): string;
}
```
---
## الملف: `config/auth.php`
```php
<?php
declare(strict_types=1);
return [
'jwt' => [
'secret' => $_ENV['JWT_SECRET'] ?? '',
'access_expiry' => (int)($_ENV['JWT_ACCESS_EXPIRY'] ?? 900),
'refresh_expiry' => (int)($_ENV['JWT_REFRESH_EXPIRY'] ?? 604800),
],
];
```
---
## الملف: `config/app.php`
```php
<?php
declare(strict_types=1);
return [
'name' => $_ENV['APP_NAME'] ?? 'مُصادَق',
'env' => $_ENV['APP_ENV'] ?? 'production',
'url' => $_ENV['APP_URL'] ?? 'https://musadeq2.intaleqapp.com',
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Asia/Amman',
];
```
---
## الملف: `config/services.php`
```php
<?php
declare(strict_types=1);
return [
'ai' => [
'gemini' => [
'key' => $_ENV['GEMINI_API_KEY'] ?? '',
'model' => $_ENV['GEMINI_MODEL'] ?? 'gemini-2.0-flash',
],
'openai' => [
'key' => $_ENV['OPENAI_API_KEY'] ?? '',
'model' => $_ENV['OPENAI_MODEL'] ?? 'gpt-4o',
],
],
'jofotara' => [
'base_url' => $_ENV['JOFOTARA_BASE_URL'] ?? 'https://backend.jofotara.gov.jo/core/invoices',
'env' => $_ENV['JOFOTARA_ENV'] ?? 'production',
],
'mail' => [
'host' => $_ENV['MAIL_HOST'] ?? '',
'port' => (int)($_ENV['MAIL_PORT'] ?? 587),
'username' => $_ENV['MAIL_USERNAME'] ?? '',
'password' => $_ENV['MAIL_PASSWORD'] ?? '',
'from' => $_ENV['MAIL_FROM'] ?? 'noreply@musadaq.app',
'from_name' => $_ENV['MAIL_FROM_NAME'] ?? 'مُصادَق',
],
];
```
---
## الملف: `config/database.php`
```php
<?php
declare(strict_types=1);
return [
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => $_ENV['DB_PORT'] ?? '3306',
'database' => $_ENV['DB_DATABASE'] ?? 'musadaq_db',
'username' => $_ENV['DB_USERNAME'] ?? 'musadaq_user',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4',
];
```
---
## الملف: `config/secrets.php`
```php
<?php
declare(strict_types=1);
return [
'encryption_key' => 'bgMQU/L8QYMd+8Sqh3AvsAXi+Fr+fMyJO+VAdakVoc8=',
];
```
---
## الملف: `tests/Unit/TotpServiceTest.php`
```php
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\TotpService;
final class TotpServiceTest extends TestCase
{
public function test_it_generates_valid_secret(): void
{
$service = new TotpService();
$secret = $service->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
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\Security\HmacService;
final class HmacTest extends TestCase
{
private HmacService $service;
protected function setUp(): void
{
$this->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
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\TaxValidationService;
final class TaxValidationTest extends TestCase
{
private TaxValidationService $service;
protected function setUp(): void
{
$this->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
<?php
declare(strict_types=1);
namespace Tests\Feature;
use PHPUnit\Framework\TestCase;
use App\Core\Application;
use App\Core\Request;
final class AuthTest extends TestCase
{
public function test_login_requires_credentials(): void
{
// This is a bit complex as we need to mock the DB or use a test DB
// For now, we can check the Controller logic or just a basic smoke test
$this->assertTrue(true);
}
}
```
---
## الملف: `public/index.html`
```
<!DOCTYPE html>
<html lang="ar" dir="rtl" x-data="{ darkMode: true }" :class="{ 'dark': darkMode }">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مُصادَق — أتمتة الفواتير الضريبية</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Noto+Sans+Arabic:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
[x-cloak] { display: none !important; }
body { font-family: 'Noto Sans Arabic', 'Outfit', sans-serif; }
.glass { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); }
.dark .glass { background: rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.05); }
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e' },
accent: '#FFD700'
}
}
}
}
</script>
</head>
<body class="bg-gray-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 min-h-screen transition-colors duration-500 overflow-x-hidden">
<!-- Navbar -->
<nav class="sticky top-0 z-50 glass px-6 py-4 flex justify-between items-center mx-4 mt-4 rounded-2xl shadow-xl">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/30">
<span class="text-white font-bold text-xl">م</span>
</div>
<h1 class="text-2xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-indigo-500 dark:from-primary-400 dark:to-indigo-400">مُصادَق</h1>
</div>
<div class="flex items-center gap-4">
<button @click="darkMode = !darkMode" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-slate-800 transition-all">
<template x-if="!darkMode">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
</template>
<template x-if="darkMode">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M16.071 16.071l.707.707M7.929 7.929l.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"></path></svg>
</template>
</button>
<a href="index.php" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-xl font-semibold transition-all shadow-lg shadow-primary-600/20">دخول</a>
</div>
</nav>
<!-- Main Content -->
<main class="container mx-auto px-4 py-12">
<section class="text-center py-20 relative">
<div class="absolute -top-20 left-1/2 -translate-x-1/2 w-64 h-64 bg-primary-500/20 blur-[100px] rounded-full"></div>
<h2 class="text-5xl md:text-7xl font-extrabold mb-6 leading-tight">
أتمتة <span class="text-primary-500">الفواتير</span> <br>بذكاء اصطناعي فائق
</h2>
<p class="text-xl text-slate-600 dark:text-slate-400 max-w-2xl mx-auto mb-10 leading-relaxed">
مُصادَق هو شريكك التقني المعتمد للربط مع نظام "جوفوتارا" الأردني، استخرج بيانات فواتيرك آلياً وامتثل للأنظمة الضريبية بثوانٍ.
</p>
<div class="flex flex-wrap justify-center gap-4">
<button class="px-10 py-4 bg-slate-900 dark:bg-white dark:text-slate-900 text-white rounded-2xl font-bold text-lg hover:scale-105 transition-all shadow-2xl">ابدأ التجربة المجانية</button>
<button class="px-10 py-4 glass rounded-2xl font-bold text-lg hover:bg-gray-100 dark:hover:bg-slate-800 transition-all">شاهد العرض</button>
</div>
</section>
<!-- Features Grid -->
<section class="grid md:grid-cols-3 gap-8 py-20">
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
<div class="w-14 h-14 bg-blue-100 dark:bg-blue-900/30 rounded-2xl flex items-center justify-center mb-6">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<h3 class="text-xl font-bold mb-3">استخراج ذكي (OCR)</h3>
<p class="text-slate-500">استخدام Gemini 2.0 لاستخراج كافة بنود الفواتير من الصور والـ PDF بدقة تصل لـ 99%.</p>
</div>
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
<div class="w-14 h-14 bg-green-100 dark:bg-green-900/30 rounded-2xl flex items-center justify-center mb-6">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<h3 class="text-xl font-bold mb-3">توافق جو-فواتير</h3>
<p class="text-slate-500">ربط مباشر مع منصة الفوترة الوطنية الأردنية وإصدار ملفات UBL 2.1 المعتمدة.</p>
</div>
<div class="p-8 glass rounded-3xl hover:-translate-y-2 transition-all duration-300">
<div class="w-14 h-14 bg-purple-100 dark:bg-purple-900/30 rounded-2xl flex items-center justify-center mb-6">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 00-2 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<h3 class="text-xl font-bold mb-3">حماية البيانات</h3>
<p class="text-slate-500">تشفير AES-256 للبيانات الحساسة وعزل كامل لبيانات المستأجرين (Multi-tenancy).</p>
</div>
</section>
</main>
<footer class="py-10 text-center text-slate-500 text-sm">
<p>© 2026 مُصادَق — جميع الحقوق محفوظة لشركة انتاليك للحلول البرمجية</p>
</footer>
<script src="assets/js/api.js"></script>
</body>
</html>
```
---
## الملف: `public/index.php`
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/Core/helpers.php';
use App\Core\Application;
use App\Modules\Auth\AuthController;
use App\Middleware\AuthMiddleware;
$app = new Application(dirname(__DIR__));
$router = $app->getRouter();
// ══ Auth Routes ══════════════════════════════════════════════
$router->addRoute('POST', '/api/v1/auth/login', [AuthController::class, 'login']);
$router->addRoute('POST', '/api/v1/auth/register', [AuthController::class, 'register']);
$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
<!DOCTYPE html>
<html lang="ar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مُصادَق منصة أتمتة الفواتير الإلكترونية</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;900&family=Noto+Kufi+Arabic:wght@400;700;900&display=swap" rel="stylesheet">
<style>
:root {
--primary: #10b981;
--primary-glow: rgba(16, 185, 129, 0.3);
--bg: #030712;
--panel: rgba(17, 24, 39, 0.8);
}
body {
background: var(--bg);
color: #f1f5f9;
font-family: 'Noto Kufi Arabic', sans-serif;
min-height: 100vh;
}
.glass { background: var(--panel); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.05); }
.nav-link { @apply flex items-center gap-3 px-6 py-4 rounded-2xl transition-all text-slate-400 hover:text-white hover:bg-white/5; }
.nav-link.active { @apply bg-primary/10 text-primary border border-primary/20 shadow-[0_0_30px_var(--primary-glow)]; }
.btn-primary { @apply px-6 py-3 bg-primary hover:bg-emerald-600 text-white rounded-2xl font-bold transition-all shadow-xl shadow-primary/20 flex items-center justify-center gap-2; }
.stat-card { @apply glass p-6 rounded-[2rem] hover:border-primary/50 transition-all duration-500; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: fadeIn 0.4s ease-out forwards; }
input, select { @apply bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-white focus:border-primary outline-none transition-all; }
</style>
</head>
<body dir="rtl" class="overflow-x-hidden">
<!-- Auth Wrapper -->
<div id="auth-container" class="fixed inset-0 z-[100] bg-bg flex items-center justify-center p-6 hidden">
<!-- Login/Register will be rendered here -->
</div>
<!-- Sidebar -->
<aside id="sidebar" class="fixed right-0 top-0 h-screen w-80 glass border-l border-white/5 flex flex-col p-8 z-50 translate-x-full transition-transform duration-500">
<div class="flex items-center gap-4 mb-12">
<div class="w-12 h-12 bg-primary rounded-2xl flex items-center justify-center shadow-2xl shadow-primary/30">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04m12.868 5.31c.477.81 1.437 1.29 2.43 1.014a11.903 11.903 0 01-1.565 3.523 11.91 11.91 0 01-3.01 3.01c-1.333.77-2.962.77-4.295 0a11.91 11.91 0 01-3.01-3.01 11.903 11.903 0 01-1.565-3.523c.993.276 1.953-.204 2.43-1.014a11.91 11.91 0 013.01-3.01 11.955 11.955 0 014.295 0 11.91 11.91 0 013.01 3.01z"></path></svg>
</div>
<h1 class="text-2xl font-black text-white">مُصادَق</h1>
</div>
<nav class="flex-1 space-y-2">
<a href="#" onclick="navigateTo('dashboard')" id="nav-dashboard" class="nav-link active">لوحة التحكم</a>
<a href="#" onclick="navigateTo('companies')" id="nav-companies" class="nav-link">الشركات</a>
<a href="#" onclick="navigateTo('invoices')" id="nav-invoices" class="nav-link">الفواتير</a>
<a href="#" onclick="navigateTo('risk-monitor')" id="nav-risk-monitor" class="nav-link">المخاطر</a>
<a href="#" onclick="navigateTo('users')" id="nav-users" class="nav-link">المستخدمين</a>
<a href="#" onclick="navigateTo('admin')" id="nav-admin" class="nav-link hidden text-secondary">الإدارة</a>
</nav>
<div class="pt-6 border-t border-white/5 space-y-2">
<a href="#" onclick="navigateTo('settings')" id="nav-settings" class="nav-link">الإعدادات</a>
<button onclick="logout()" class="w-full nav-link text-red-400 hover:bg-red-500/10">تسجيل الخروج</button>
</div>
</aside>
<!-- Main Content -->
<main id="main-content" class="mr-0 transition-all duration-500 min-h-screen opacity-0">
<header class="h-24 glass border-b border-white/5 flex items-center justify-between px-12 sticky top-0 z-40">
<h2 id="page-title" class="text-2xl font-bold">لوحة التحكم</h2>
<div class="flex items-center gap-6">
<div class="relative">
<input type="text" id="ai-chat" class="w-80 pr-12 text-sm" placeholder="اسأل الذكاء الاصطناعي...">
<svg class="w-5 h-5 absolute right-4 top-3.5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<button onclick="showAddInvoiceModal()" class="btn-primary">+ رفع فاتورة</button>
</div>
</header>
<div id="content" class="p-12 max-w-[1600px] mx-auto animate-in"></div>
</main>
<!-- Toast & Modals -->
<div id="toast-container" class="fixed top-8 left-1/2 -translate-x-1/2 z-[200] space-y-4"></div>
<div id="modals" class="fixed inset-0 z-[150] hidden items-center justify-center p-6 bg-black/80 backdrop-blur-md"></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const API = {
baseUrl: 'index.php?route=/api/v1',
get token() { return localStorage.getItem('access_token'); },
async req(method, path, body = null, files = false) {
const headers = { 'Accept': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
if (!files && body) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(body); }
const res = await fetch(`${this.baseUrl}${path}`, { method, headers, body });
const data = await res.json();
if (!res.ok) { if (res.status === 401) logout(); throw data; }
return data;
},
get(p) { return this.req('GET', p); },
post(p, b) { return this.req('POST', p, b); },
upload(p, fd) { return this.req('POST', p, fd, true); }
};
function showToast(msg, type = 'success') {
const container = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `px-8 py-4 rounded-2xl text-white font-bold shadow-2xl transition-all duration-500 translate-y-10 opacity-0 ${type === 'success' ? 'bg-emerald-500' : 'bg-red-500'}`;
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.classList.remove('translate-y-10', 'opacity-0'), 10);
setTimeout(() => { t.classList.add('-translate-y-10', 'opacity-0'); setTimeout(() => t.remove(), 500); }, 4000);
}
function logout() { localStorage.clear(); window.location.reload(); }
async function navigateTo(page) {
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
document.getElementById(`nav-${page}`)?.classList.add('active');
const content = document.getElementById('content');
content.innerHTML = '<div class="flex justify-center p-20"><div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div></div>';
try {
if (page === 'dashboard') await renderDashboard();
else if (page === 'companies') await renderCompanies();
else if (page === 'invoices') await renderInvoices();
else if (page === 'users') await renderUsers();
else if (page === 'risk-monitor') await renderRiskMonitor();
else if (page === 'settings') await renderSettings();
else if (page === 'admin') await renderAdmin();
} catch (e) { showToast('خطأ في تحميل الصفحة', 'error'); }
}
// ── View Renderers ───────────────────────────────────────
async function renderDashboard() {
document.getElementById('page-title').textContent = 'لوحة التحكم';
const res = await API.get('/dashboard');
const { data: s } = res;
document.getElementById('content').innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-12">
<div class="stat-card">
<p class="text-slate-500 text-sm mb-1">فواتير الشهر</p>
<p class="text-5xl font-black text-white">${s.total_this_month}</p>
</div>
<div class="stat-card">
<p class="text-slate-500 text-sm mb-1">استهلاك الباقة</p>
<p class="text-5xl font-black text-primary">${s.subscription_usage}%</p>
</div>
<div class="stat-card">
<p class="text-slate-500 text-sm mb-1">مؤشر المخاطر</p>
<p class="text-5xl font-black text-yellow-500">منخفض</p>
</div>
<div class="stat-card">
<p class="text-slate-500 text-sm mb-1">حالة الربط</p>
<p class="text-5xl font-black text-emerald-500">نشط</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 glass p-10 rounded-[3rem]">
<h3 class="text-xl font-bold mb-8">أحدث الفواتير</h3>
<div class="space-y-4">
${s.recent_invoices.map(i => `
<div onclick="renderInvoiceDetail('${i.id}')" class="flex justify-between items-center p-5 bg-white/5 rounded-3xl border border-transparent hover:border-primary/30 transition cursor-pointer">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary font-bold">INV</div>
<div>
<p class="font-bold">${i.invoice_number || '---'}</p>
<p class="text-xs text-slate-500">${i.company_name}</p>
</div>
</div>
<div class="text-left">
<p class="font-bold">${i.grand_total} JOD</p>
<p class="text-[10px] font-bold uppercase ${i.status==='approved'?'text-primary':'text-yellow-500'}">${i.status}</p>
</div>
</div>
`).join('')}
</div>
</div>
<div class="glass p-10 rounded-[3rem] flex flex-col items-center">
<h3 class="text-xl font-bold mb-8 self-start">توزيع الحالات</h3>
<div class="w-full max-w-[280px] aspect-square"><canvas id="dashChart"></canvas></div>
</div>
</div>
`;
new Chart(document.getElementById('dashChart'), {
type: 'doughnut',
data: {
labels: s.status_distribution.map(x=>x.status),
datasets: [{ data: s.status_distribution.map(x=>x.count), backgroundColor: ['#10b981', '#fbbf24', '#f87171', '#6366f1'], borderWidth: 0 }]
},
options: { cutout: '80%', plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8', font: { size: 10 } } } } }
});
}
async function renderCompanies() {
document.getElementById('page-title').textContent = 'إدارة الشركات';
const res = await API.get('/companies');
document.getElementById('content').innerHTML = `
<div class="flex justify-end mb-8"><button onclick="showAddCompanyModal()" class="btn-primary">+ إضافة شركة</button></div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
${res.data.map(c => `
<div class="glass p-8 rounded-[2.5rem] border-t-8 border-t-primary">
<h3 class="text-2xl font-bold mb-1">${c.name}</h3>
<p class="text-sm text-slate-500 mb-6">الرقم الضريبي: ${c.tax_identification_number}</p>
<div class="p-4 bg-black/20 rounded-2xl border border-white/5 flex justify-between items-center mb-6">
<span class="text-xs text-slate-400">JoFotara</span>
<span class="text-xs font-bold ${c.is_jofotara_linked?'text-primary':'text-red-400'}">${c.is_jofotara_linked?'مربوط ✓':'غير مربوط'}</span>
</div>
<button onclick="showJoFotaraModal('${c.id}')" class="w-full py-3 bg-white/5 hover:bg-white/10 rounded-2xl text-sm transition">إعدادات الربط</button>
</div>
`).join('')}
</div>
`;
}
async function renderInvoices() {
document.getElementById('page-title').textContent = 'الفواتير والتدقيق';
const res = await API.get('/invoices');
document.getElementById('content').innerHTML = `
<div class="glass rounded-[3rem] overflow-hidden">
<table class="w-full text-right">
<thead class="bg-white/5 text-slate-400 text-sm">
<tr><th class="p-6">الشركة</th><th class="p-6">الرقم</th><th class="p-6">التاريخ</th><th class="p-6">المجموع</th><th class="p-6 text-center">الحالة</th></tr>
</thead>
<tbody class="divide-y divide-white/5">
${res.data.map(i => `
<tr onclick="renderInvoiceDetail('${i.id}')" class="hover:bg-white/5 cursor-pointer transition">
<td class="p-6 font-bold">${i.company_name}</td>
<td class="p-6 font-mono text-sm">${i.invoice_number || '---'}</td>
<td class="p-6 text-slate-400 text-sm">${i.invoice_date || '---'}</td>
<td class="p-6 font-bold text-white">${i.grand_total} JOD</td>
<td class="p-6 text-center"><span class="px-4 py-1.5 rounded-full text-[10px] font-bold border ${i.status==='approved'?'border-primary text-primary':'border-yellow-500 text-yellow-500'} bg-white/5 uppercase">${i.status}</span></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
async function renderInvoiceDetail(id) {
const res = await API.get(`/invoices/${id}`);
const i = res.data;
document.getElementById('content').innerHTML = `
<div class="flex flex-col lg:flex-row gap-10 animate-in">
<div class="lg:w-1/2 glass rounded-[3rem] h-[750px] overflow-hidden flex flex-col">
<div class="p-5 bg-white/5 border-b border-white/5 flex justify-between text-sm"><span>المستند الأصلي</span><a href="${i.original_file_path}" target="_blank" class="text-primary">فتح في نافذة جديدة</a></div>
<div class="flex-1 bg-black/50 p-6 flex items-center justify-center">
${i.original_file_path.endsWith('.pdf') ? `<iframe src="${i.original_file_path}" class="w-full h-full rounded-2xl"></iframe>` : `<img src="${i.original_file_path}" class="max-w-full max-h-full rounded-2xl shadow-2xl">`}
</div>
</div>
<div class="lg:w-1/2 glass p-10 rounded-[3rem] overflow-y-auto custom-scrollbar">
<div class="flex justify-between items-start mb-10">
<div><h3 class="text-3xl font-black mb-2">${i.supplier_name || 'غير معروف'}</h3><p class="text-slate-400">رقم الفاتورة: <span class="text-white font-mono">${i.invoice_number || '---'}</span></p></div>
<button onclick="submitToJoFotara('${i.id}')" class="btn-primary">إرسال لـ JoFotara</button>
</div>
<div class="grid grid-cols-2 gap-6 mb-10">
<div class="p-6 bg-white/5 rounded-3xl border border-white/5"><p class="text-xs text-slate-500 mb-1">تاريخ الإصدار</p><p class="font-bold">${i.invoice_date || '---'}</p></div>
<div class="p-6 bg-white/5 rounded-3xl border border-white/5"><p class="text-xs text-slate-500 mb-1">الرقم الضريبي</p><p class="font-bold text-primary font-mono">${i.supplier_tin || '---'}</p></div>
</div>
<table class="w-full text-sm mb-10">
<thead class="text-slate-500 border-b border-white/10 text-right"><tr class="text-xs"><th class="pb-4">الوصف</th><th class="pb-4 text-center">الكمية</th><th class="pb-4 text-left">المجموع</th></tr></thead>
<tbody class="divide-y divide-white/5">${i.lines.map(l => `<tr><td class="py-4 text-slate-300">${l.description}</td><td class="py-4 text-center">${l.quantity}</td><td class="py-4 text-left font-bold text-primary">${l.line_total} JOD</td></tr>`).join('')}</tbody>
</table>
<div class="pt-6 border-t border-white/10 space-y-3">
<div class="flex justify-between"><span>المجموع الفرعي</span><span>${i.subtotal} JOD</span></div>
<div class="flex justify-between text-yellow-500"><span>الضريبة</span><span>${i.tax_amount} JOD</span></div>
<div class="flex justify-between text-3xl font-black pt-4 text-white"><span>الإجمالي الكلي</span><span class="text-primary">${i.grand_total} JOD</span></div>
</div>
</div>
</div>
`;
}
async function renderRiskMonitor() {
document.getElementById('page-title').textContent = 'مراقبة المخاطر';
document.getElementById('content').innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div class="glass p-10 rounded-[3rem]">
<h3 class="text-xl font-bold mb-8">تحليل الالتزام</h3>
<div class="space-y-6">
<div class="p-8 bg-emerald-500/5 border border-primary/20 rounded-3xl text-center">
<p class="text-slate-400 text-sm mb-2">مستوى الخطورة</p>
<p class="text-4xl font-black text-emerald-500 tracking-widest">منخفض جداً</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-6 bg-white/5 rounded-3xl border border-white/5"><p class="text-xs text-slate-500">دقة الذكاء الاصطناعي</p><p class="text-2xl font-bold">99.8%</p></div>
<div class="p-6 bg-white/5 rounded-3xl border border-white/5"><p class="text-xs text-slate-500">فواتير مرفوضة</p><p class="text-2xl font-bold text-red-400">0</p></div>
</div>
</div>
</div>
<div class="glass p-10 rounded-[3rem]">
<h3 class="text-xl font-bold mb-8">قواعد التدقيق الفعالة</h3>
<div class="space-y-4">
${['تطابق الرقم الضريبي', 'صحة احتساب الضريبة (16%)', 'تسلسل أرقام الفواتير', 'الحد الزمني للرفع (3 أيام)'].map(r => `
<div class="flex items-center gap-4 p-5 bg-white/5 rounded-3xl border border-white/5">
<div class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">✓</div>
<span class="font-bold">${r}</span>
<span class="mr-auto text-[10px] text-primary font-black uppercase">Active</span>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
async function renderSettings() {
document.getElementById('page-title').textContent = 'الإعدادات والأمان';
const res = await API.get('/auth/me');
const u = res.data;
document.getElementById('content').innerHTML = `
<div class="max-w-4xl mx-auto space-y-10">
<div class="glass p-10 rounded-[3rem] border-t-8 border-t-primary">
<div class="flex justify-between items-center mb-8">
<div><h3 class="text-2xl font-black mb-1">التحقق بخطوتين (2FA)</h3><p class="text-slate-400">تأمين إضافي لحسابك باستخدام Authenticator.</p></div>
<div class="px-4 py-2 bg-white/5 rounded-2xl text-xs font-bold border border-white/10 ${u.totp_enabled?'text-primary':'text-slate-500'}">${u.totp_enabled?'مُفعّل':'غير مُفعّل'}</div>
</div>
<div id="2fa-area">
${u.totp_enabled ? `
<button onclick="disable2FA()" class="px-8 py-3 bg-red-500/10 text-red-400 hover:bg-red-500/20 rounded-2xl transition font-bold border border-red-500/20">تعطيل الحماية</button>
` : `
<button onclick="start2FA()" class="btn-primary">تفعيل الآن</button>
`}
</div>
</div>
<div class="glass p-10 rounded-[3rem] border-t-8 border-t-indigo-500">
<div class="flex justify-between items-center mb-8">
<div><h3 class="text-2xl font-black mb-1">مفاتيح API</h3><p class="text-slate-400">للربط مع تطبيقات الموبايل والأنظمة الخارجية.</p></div>
<button onclick="createApiKey()" class="btn-primary bg-indigo-600 hover:bg-indigo-700 shadow-indigo-500/20">إنشاء مفتاح جديد</button>
</div>
<div id="api-keys-list" class="space-y-4"></div>
</div>
</div>
`;
loadApiKeys();
}
// ── Auth & Init ──────────────────────────────────────────
async function renderLogin() {
const auth = document.getElementById('auth-container');
auth.classList.remove('hidden');
auth.innerHTML = `
<div class="glass p-12 rounded-[3rem] w-full max-w-md shadow-2xl border-t-8 border-t-primary animate-in">
<div class="text-center mb-10">
<h2 class="text-4xl font-black mb-3">مرحباً بك</h2>
<p class="text-slate-500">سجل الدخول لمنصة مُصادَق</p>
</div>
<form id="login-form" class="space-y-6">
<input type="email" id="email" class="w-full" placeholder="البريد الإلكتروني" required>
<input type="password" id="password" class="w-full" placeholder="كلمة المرور" required>
<button type="submit" class="w-full btn-primary py-4 text-lg">دخول</button>
</form>
<div class="mt-8 text-center"><p class="text-sm text-slate-500">ليس لديك حساب؟ <a href="#" onclick="renderRegister()" class="text-primary font-bold">سجل الآن</a></p></div>
</div>
`;
document.getElementById('login-form').onsubmit = async (e) => {
e.preventDefault();
try {
const res = await API.post('/auth/login', { email: e.target.email.value, password: e.target.password.value });
if (res.requires_2fa) {
render2FAChallenge(res.temp_token);
} else {
saveAuth(res.data);
}
} catch (err) { showToast(err.error?.message_ar || 'بيانات الدخول غير صحيحة', 'error'); }
};
}
function saveAuth(data) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('user_role', data.user.role);
localStorage.setItem('user_name', data.user.name);
window.location.reload();
}
function initApp() {
if (localStorage.getItem('access_token')) {
document.getElementById('sidebar').classList.remove('translate-x-full');
document.getElementById('main-content').classList.replace('opacity-0', 'opacity-100');
if (localStorage.getItem('user_role') === 'super_admin') document.getElementById('nav-admin').classList.remove('hidden');
navigateTo('dashboard');
} else { renderLogin(); }
}
// Helpers for 2FA, API Keys, Modals...
async function start2FA() {
const area = document.getElementById('2fa-area');
const res = await API.post('/auth/2fa/enable', {});
const { secret, qr_url } = res.data;
area.innerHTML = `
<div class="flex gap-8 items-center bg-black/20 p-6 rounded-3xl border border-white/5">
<div class="bg-white p-2 rounded-2xl"><img src="${qr_url}" class="w-32 h-32"></div>
<div class="space-y-4">
<p class="text-sm">امسح الرمز أعلاه، ثم أدخل كود التحقق:</p>
<div class="flex gap-3"><input id="2fa-code" class="w-32 text-center font-mono text-xl tracking-widest" maxlength="6" placeholder="000000"><button onclick="confirm2FA('${secret}')" class="btn-primary">تأكيد</button></div>
</div>
</div>
`;
}
async function confirm2FA(secret) {
try { await API.post('/auth/2fa/verify', { secret, code: document.getElementById('2fa-code').value }); showToast('تم التفعيل!'); renderSettings(); } catch(e) { showToast('كود غير صحيح', 'error'); }
}
async function disable2FA() { if(confirm('هل أنت متأكد؟')) { await API.post('/auth/2fa/disable', {}); renderSettings(); } }
async function loadApiKeys() {
const res = await API.get('/api-keys');
document.getElementById('api-keys-list').innerHTML = res.data.map(k => `
<div class="flex justify-between items-center p-5 bg-black/20 rounded-3xl border border-white/5">
<div><p class="font-bold">${k.name}</p><p class="text-xs text-slate-500 font-mono">ID: ${k.id}</p></div>
<span class="text-[10px] text-primary font-bold px-3 py-1 bg-primary/10 rounded-full border border-primary/20">Active</span>
</div>
`).join('') || '<p class="text-center text-slate-500 py-4">لا توجد مفاتيح</p>';
}
async function createApiKey() {
const name = prompt('اسم المفتاح:'); if(!name) return;
try { const res = await API.post('/api-keys', { name }); alert(`احفظ مفتاحك الآن، لن يظهر مجدداً:\n\n${res.data.key}`); loadApiKeys(); } catch(e) { showToast('فشل إنشاء المفتاح', 'error'); }
}
async function submitToJoFotara(id) {
try { await API.post(`/invoices/${id}/submit`, {}); showToast('تم إرسال الفاتورة للطابور'); renderInvoiceDetail(id); } catch(e) { showToast(e.error?.message_ar || 'فشل الإرسال', 'error'); }
}
function showAddInvoiceModal() {
const m = document.getElementById('modals');
m.classList.replace('hidden', 'flex');
m.innerHTML = `<div class="glass p-10 rounded-[3rem] w-full max-w-md animate-in">
<h3 class="text-2xl font-black mb-8">رفع فاتورة جديدة</h3>
<form id="up-form" class="space-y-6">
<select id="up-comp" class="w-full" required><option value="">اختر الشركة...</option></select>
<div class="p-10 border-2 border-dashed border-white/10 rounded-3xl text-center bg-white/5"><input type="file" id="up-file" class="text-xs" required></div>
<div class="flex gap-4"><button type="button" onclick="document.getElementById('modals').classList.replace('flex','hidden')" class="flex-1 py-3 bg-white/5 rounded-2xl">إلغاء</button><button type="submit" class="flex-1 btn-primary">رفع ومعالجة</button></div>
</form>
</div>`;
API.get('/companies').then(r => {
document.getElementById('up-comp').innerHTML += r.data.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
});
document.getElementById('up-form').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(); fd.append('company_id', e.target['up-comp'].value); fd.append('invoice', e.target['up-file'].files[0]);
try {
const b = e.target.querySelector('button[type="submit"]'); b.disabled = true; b.textContent = 'جاري الرفع...';
await API.upload('/invoices/upload', fd);
showToast('تم الرفع بنجاح'); m.classList.replace('flex','hidden'); navigateTo('invoices');
} catch(err) { showToast(err.error?.message_ar || 'فشل الرفع', 'error'); }
};
}
function showJoFotaraModal(cid) {
const m = document.getElementById('modals');
m.classList.replace('hidden', 'flex');
m.innerHTML = `<div class="glass p-10 rounded-[3rem] w-full max-w-md animate-in">
<h3 class="text-2xl font-black mb-8">إعدادات JoFotara</h3>
<form id="jo-form" class="space-y-6">
<input type="text" id="jo-id" class="w-full" placeholder="Client ID" required>
<input type="password" id="jo-sec" class="w-full" placeholder="Secret Key" required>
<div class="flex gap-4"><button type="button" onclick="document.getElementById('modals').classList.replace('flex','hidden')" class="flex-1 py-3 bg-white/5 rounded-2xl">إلغاء</button><button type="submit" class="flex-1 btn-primary">حفظ الربط</button></div>
</form>
</div>`;
document.getElementById('jo-form').onsubmit = async (e) => {
e.preventDefault();
try {
await API.post(`/companies/${cid}/jofotara`, { client_id: e.target['jo-id'].value, secret_key: e.target['jo-sec'].value });
showToast('تم تحديث البيانات'); m.classList.replace('flex','hidden'); renderCompanies();
} catch(e) { showToast('فشل التحديث', 'error'); }
};
}
async function renderRegister() {
const auth = document.getElementById('auth-container');
auth.innerHTML = `
<div class="glass p-12 rounded-[3rem] w-full max-w-md shadow-2xl border-t-8 border-t-emerald-500 animate-in">
<div class="text-center mb-10">
<h2 class="text-4xl font-black mb-3">إنشاء حساب</h2>
<p class="text-slate-500">انضم لمنصة مُصادَق اليوم</p>
</div>
<form id="reg-form" class="space-y-4">
<input type="text" id="reg-name" class="w-full" placeholder="الاسم الكامل" required>
<input type="email" id="reg-email" class="w-full" placeholder="البريد الإلكتروني" required>
<input type="password" id="reg-pass" class="w-full" placeholder="كلمة المرور" required>
<button type="submit" class="w-full btn-primary py-4 text-lg">إنشاء الحساب</button>
</form>
<div class="mt-8 text-center"><p class="text-sm text-slate-500">لديك حساب بالفعل؟ <a href="#" onclick="renderLogin()" class="text-primary font-bold">دخول</a></p></div>
</div>
`;
document.getElementById('reg-form').onsubmit = async (e) => {
e.preventDefault();
try {
const res = await API.post('/auth/register', {
name: document.getElementById('reg-name').value,
email: document.getElementById('reg-email').value,
password: document.getElementById('reg-pass').value
});
saveAuth(res.data);
} catch (err) { showToast(err.error?.message_ar || 'فشل إنشاء الحساب', 'error'); }
};
}
function render2FAChallenge(tempToken) {
const auth = document.getElementById('auth-container');
auth.innerHTML = `
<div class="glass p-12 rounded-[3rem] w-full max-w-md shadow-2xl border-t-8 border-t-yellow-500 animate-in text-center">
<h2 class="text-3xl font-black mb-6">التحقق الثنائي</h2>
<p class="text-slate-400 mb-8">أدخل الكود من تطبيق المصادقة</p>
<input type="text" id="challenge-code" class="w-full text-center text-4xl tracking-[1rem] font-mono mb-8" maxlength="6" autofocus>
<button id="verify-btn" class="w-full btn-primary py-4">تحقق ودخول</button>
</div>
`;
document.getElementById('verify-btn').onclick = async () => {
try {
const code = document.getElementById('challenge-code').value;
const res = await fetch('index.php?route=/api/v1/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${tempToken}` },
body: JSON.stringify({ code })
});
const data = await res.json();
if (res.ok) saveAuth({ access_token: tempToken, user: data.user });
else showToast('كود غير صحيح', 'error');
} catch(e) { showToast('خطأ في النظام', 'error'); }
};
}
function showAddCompanyModal() {
const m = document.getElementById('modals');
m.classList.replace('hidden', 'flex');
m.innerHTML = `<div class="glass p-10 rounded-[3rem] w-full max-w-md animate-in">
<h3 class="text-2xl font-black mb-8">إضافة شركة جديدة</h3>
<form id="comp-form" class="space-y-6">
<input type="text" id="c-name" class="w-full" placeholder="اسم الشركة" required>
<input type="text" id="c-tin" class="w-full" placeholder="الرقم الضريبي" required maxlength="10">
<div class="flex gap-4"><button type="button" onclick="document.getElementById('modals').classList.replace('flex','hidden')" class="flex-1 py-3 bg-white/5 rounded-2xl">إلغاء</button><button type="submit" class="flex-1 btn-primary">إضافة</button></div>
</form>
</div>`;
document.getElementById('comp-form').onsubmit = async (e) => {
e.preventDefault();
try {
await API.post('/companies', { name: document.getElementById('c-name').value, tax_identification_number: document.getElementById('c-tin').value });
showToast('تمت إضافة الشركة'); m.classList.replace('flex','hidden'); renderCompanies();
} catch(e) { showToast('فشل الإضافة', 'error'); }
};
}
initApp();
</script>
</body>
</html>
```
---
## الملف: `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
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/Core/helpers.php';
use App\Core\{Application, Database};
// Initialize app to load .env and configs
$app = new Application(dirname(__DIR__));
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "🗄️ Musadaq Migration Tool\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
try {
$db = Database::getInstance();
// Create migrations table if not exists
$db->exec("CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
$stmt = $db->query("SELECT migration FROM migrations");
$executed = $stmt->fetchAll(PDO::FETCH_COLUMN);
$migrationsDir = dirname(__DIR__) . '/database/migrations';
$files = glob($migrationsDir . '/*.sql');
sort($files); // Ensure order
$count = 0;
foreach ($files as $file) {
$name = basename($file);
if (!in_array($name, $executed)) {
echo "🚀 Running: $name... ";
$sql = file_get_contents($file);
// Execute the SQL. Since it might contain multiple statements,
// and PDO::exec doesn't always handle them well in one go
// depending on the driver, we'll try to run it.
$db->exec($sql);
$stmt = $db->prepare("INSERT INTO migrations (migration) VALUES (?)");
$stmt->execute([$name]);
echo "✅ Done\n";
$count++;
}
}
if ($count === 0) {
echo "✨ Nothing to migrate. Database is up to date.\n";
} else {
echo "🎉 Migrations completed successfully ($count ran).\n";
}
} catch (Exception $e) {
echo "❌ Error: " . $e->getMessage() . "\n";
exit(1);
}
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
```
---
## الملف: `scripts/seed.php`
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Core\{Application, Database};
use Ramsey\Uuid\Uuid;
$app = new Application(dirname(__DIR__));
$db = Database::getInstance();
echo "🌱 Seeding initial data...\n";
try {
// 1. Create Tenant
$tenantId = Uuid::uuid4()->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
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Core\Application;
use App\Services\QueueService;
// Initialize App (loads .env, etc.)
$app = new Application(dirname(__DIR__));
echo "[*] Musadaq Queue Worker Started...\n";
// Signal handling for graceful shutdown
$keepRunning = true;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function() use (&$keepRunning) {
echo "[!] SIGTERM received, shutting down gracefully...\n";
$keepRunning = false;
});
while ($keepRunning) {
$job = QueueService::pop();
if ($job) {
echo "[+] Processing job: {$job['type']} ({$job['id']})\n";
try {
$container = $app->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
<?php
declare(strict_types=1);
namespace Queue\Jobs;
use App\Modules\Invoices\InvoiceModel;
use App\Modules\Companies\CompanyService;
use App\Services\JoFotara\JoFotaraGateway;
use App\Services\JoFotara\UBLGeneratorService;
use Throwable;
final class SubmitJoFotaraJob
{
public function __construct(
private readonly InvoiceModel $invoiceModel,
private readonly CompanyService $companyService,
private readonly UBLGeneratorService $ublGenerator,
private readonly JoFotaraGateway $jofotaraGateway
) {}
public function handle(array $payload): void
{
$invoiceId = $payload['invoice_id'];
try {
// 1. Update status to submitting
$this->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
<?php
declare(strict_types=1);
namespace Queue\Jobs;
use App\Modules\Invoices\InvoiceModel;
use App\Services\AiExtractionService;
use Throwable;
final class ExtractInvoiceJob
{
public function __construct(
private readonly InvoiceModel $invoiceModel,
private readonly AiExtractionService $aiExtraction
) {}
public function handle(array $payload): void
{
$invoiceId = $payload['invoice_id'];
$filePath = $payload['file_path'];
$mimeType = $payload['mime_type'];
// Update status to extracting
$this->invoiceModel->update($invoiceId, ['status' => 'extracting']);
try {
$extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType);
// Map AI data to schema columns if needed, or just store in ai_raw_response
$this->invoiceModel->update($invoiceId, [
'status' => 'extracted',
'invoice_number' => $extractedData['invoice_number'] ?? null,
'invoice_date' => $extractedData['invoice_date'] ?? null,
'grand_total' => $extractedData['total_amount'] ?? 0,
'tax_amount' => $extractedData['tax_amount'] ?? 0,
'supplier_name' => $extractedData['vendor_name'] ?? null,
'supplier_tin' => $extractedData['vendor_tax_number'] ?? null,
'ai_raw_response' => json_encode($extractedData, JSON_UNESCAPED_UNICODE)
]);
} catch (Throwable $e) {
$this->invoiceModel->update($invoiceId, [
'status' => 'validation_failed'
]);
throw $e;
}
}
}
```
---
## الملف: `queue/Jobs/SendNotificationJob.php`
```php
<?php
declare(strict_types=1);
namespace Queue\Jobs;
use App\Core\Database;
use Throwable;
final class SendNotificationJob
{
public function handle(array $payload): void
{
$userId = $payload['user_id'];
$title = $payload['title'];
$message = $payload['message'];
$type = $payload['type'] ?? 'info';
try {
$db = Database::getInstance();
$stmt = $db->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
<?php
declare(strict_types=1);
namespace Queue\Jobs;
use App\Services\RiskAnalysisService;
use App\Core\Database;
use Throwable;
final class RiskAnalysisJob
{
public function __construct(
private readonly RiskAnalysisService $riskService
) {}
public function handle(array $payload): void
{
$companyId = $payload['company_id'];
$tenantId = $payload['tenant_id'];
try {
$analysis = $this->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;
}
}
}
```
---