🚀 مُصادَق: تحديث برمجي جديد 2026-05-03 14:27

This commit is contained in:
Hamza-Ayed
2026-05-03 14:27:28 +03:00
parent cb69abe221
commit 31bb1bf565
15 changed files with 6142 additions and 172 deletions

View File

@@ -4,56 +4,50 @@ declare(strict_types=1);
namespace App\Modules\ApiKeys; namespace App\Modules\ApiKeys;
use App\Core\{Request, Response}; use App\Core\{Request, Response, Database};
use App\Modules\ApiKeys\ApiKeyModel; use Ramsey\Uuid\Uuid;
final class ApiKeyController final class ApiKeyController
{ {
public function __construct(private readonly ApiKeyModel $apiKeyModel) {}
public function list(Request $request): void public function list(Request $request): void
{ {
$tenantId = $request->tenantId; $tenantId = $request->tenantId;
$keys = $this->apiKeyModel->findAllByTenant($tenantId); $db = Database::getInstance();
$stmt = $db->prepare("SELECT id, name, public_key, created_at, last_used_at, is_active FROM api_keys WHERE tenant_id = ? ORDER BY created_at DESC");
$stmt->execute([$tenantId]);
Response::json([ Response::json([
'success' => true, 'success' => true,
'data' => $keys 'data' => $stmt->fetchAll()
]); ]);
} }
public function create(Request $request): void public function create(Request $request): void
{ {
$tenantId = $request->tenantId; $tenantId = $request->tenantId;
$data = $request->getBody(); $userId = $request->user->user_id;
$name = $request->input('name');
if (empty($data['name'])) { if (!$name) {
Response::error('اسم المفتاح مطلوب', 'VALIDATION_ERROR', 422); Response::error('يرجى إدخال اسم المفتاح', 'VALIDATION_ERROR', 422);
return; return;
} }
$id = \Ramsey\Uuid\Uuid::uuid4()->toString(); $id = Uuid::uuid4()->toString();
// Generate a random key $publicKey = bin2hex(random_bytes(16));
$rawKey = bin2hex(random_bytes(32)); $secretKey = bin2hex(random_bytes(32));
$prefix = substr($rawKey, 0, 8); $secretHash = password_hash($secretKey, PASSWORD_BCRYPT);
$hashedKey = hash('sha256', $rawKey);
$this->apiKeyModel->create([ $db = Database::getInstance();
'id' => $id, $stmt = $db->prepare("INSERT INTO api_keys (id, tenant_id, user_id, name, public_key, secret_hash, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)");
'tenant_id' => $tenantId, $stmt->execute([$id, $tenantId, $userId, $name, $publicKey, $secretHash]);
'name' => $data['name'],
'key_hash' => $hashedKey,
'prefix' => $prefix,
'is_active' => 1
]);
Response::json([ Response::json([
'success' => true, 'success' => true,
'message' => 'تم إنشاء مفتاح API بنجاح', 'message' => 'تم إنشاء مفتاح API بنجاح. يرجى حفظ السر لأنه لن يظهر مرة أخرى.',
'data' => [ 'data' => [
'id' => $id, 'id' => $id,
'name' => $data['name'], 'key' => "msq_{$publicKey}.{$secretKey}"
'key' => $rawKey // Only shown once!
] ]
], 201); ], 201);
} }

View File

@@ -18,7 +18,9 @@ final class DashboardController
$where = "WHERE tenant_id = ?"; $where = "WHERE tenant_id = ?";
$params = [$tenantId]; $params = [$tenantId];
if ($role !== 'super_admin') { // Fix: Only accountants should be restricted to a single company if assigned.
// Admins and Super Admins should see all companies in their tenant.
if ($role === 'accountant' && $assignedCompanyId) {
$where .= " AND company_id = ?"; $where .= " AND company_id = ?";
$params[] = $assignedCompanyId; $params[] = $assignedCompanyId;
} }
@@ -26,7 +28,7 @@ final class DashboardController
// 1. Total Invoices this month // 1. Total Invoices this month
$stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices {$where} AND MONTH(created_at) = MONTH(CURRENT_DATE)"); $stmt = $db->prepare("SELECT COUNT(*) as count FROM invoices {$where} AND MONTH(created_at) = MONTH(CURRENT_DATE)");
$stmt->execute($params); $stmt->execute($params);
$thisMonth = $stmt->fetch()['count']; $thisMonth = (int) $stmt->fetch()['count'];
// 2. Approved vs Rejected // 2. Approved vs Rejected
$stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices {$where} GROUP BY status"); $stmt = $db->prepare("SELECT status, COUNT(*) as count FROM invoices {$where} GROUP BY status");
@@ -34,17 +36,24 @@ final class DashboardController
$statusCounts = $stmt->fetchAll(); $statusCounts = $stmt->fetchAll();
// 3. Recent Activity - Fixed ambiguity // 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 = $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 === 'accountant' && $assignedCompanyId ? " AND i.company_id = ?" : "") . " ORDER BY i.created_at DESC LIMIT 5");
$stmt->execute($params); $stmt->execute($params);
$recent = $stmt->fetchAll(); $recent = $stmt->fetchAll();
// 4. Calculate Subscription Usage
$stmt = $db->prepare("SELECT max_invoices_per_month FROM subscriptions WHERE tenant_id = ?");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
$maxInvoices = (int) ($sub['max_invoices_per_month'] ?? 100);
$usage = $maxInvoices > 0 ? round(($thisMonth / $maxInvoices) * 100, 1) : 0;
Response::json([ Response::json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'total_this_month' => $thisMonth, 'total_this_month' => $thisMonth,
'status_distribution' => $statusCounts, 'status_distribution' => $statusCounts,
'recent_invoices' => $recent, 'recent_invoices' => $recent,
'subscription_usage' => 45 // Placeholder 'subscription_usage' => $usage
] ]
]); ]);
} }

View File

@@ -24,7 +24,8 @@ final class InvoiceController
$role = $request->user->role ?? 'viewer'; $role = $request->user->role ?? 'viewer';
$assignedCompanyId = $request->user->assigned_company_id ?? null; $assignedCompanyId = $request->user->assigned_company_id ?? null;
if ($role === 'super_admin') { $db = \App\Core\Database::getInstance();
if ($role === 'super_admin' || $role === '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 = $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]); $stmt->execute([$tenantId]);
$invoices = $stmt->fetchAll(); $invoices = $stmt->fetchAll();
@@ -90,10 +91,10 @@ final class InvoiceController
} }
} }
public function detail(Request $request, array $vars): void public function detail(Request $request, string $id): void
{ {
$tenantId = $request->tenantId; $tenantId = $request->tenantId;
$invoiceId = $vars['id'] ?? null; $invoiceId = $id;
$db = \App\Core\Database::getInstance(); $db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1"); $stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
@@ -123,10 +124,10 @@ final class InvoiceController
]); ]);
} }
public function submit(Request $request, array $vars): void public function submit(Request $request, string $id): void
{ {
$tenantId = $request->tenantId; $tenantId = $request->tenantId;
$invoiceId = $vars['id']; $invoiceId = $id;
// Push to Queue for JoFotara Submission // Push to Queue for JoFotara Submission
\App\Services\QueueService::push('submit_jofotara', [ \App\Services\QueueService::push('submit_jofotara', [
@@ -137,5 +138,32 @@ final class InvoiceController
'success' => true, 'success' => true,
'message' => 'Invoice submission queued.' 'message' => 'Invoice submission queued.'
]); ]);
public function downloadFile(Request $request, string $id): void
{
$tenantId = $request->tenantId;
$db = \App\Core\Database::getInstance();
$stmt = $db->prepare("SELECT original_file_path, company_id FROM invoices WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
$stmt->execute([$id, $tenantId]);
$invoice = $stmt->fetch();
if (!$invoice || !file_exists($invoice['original_file_path'])) {
Response::error('الملف غير موجود', 'NOT_FOUND', 404);
return;
}
$role = $request->user->role ?? 'viewer';
if ($role !== 'super_admin' && $invoice['company_id'] !== $request->user->assigned_company_id) {
Response::error('غير مصرح لك بمشاهدة هذا الملف', 'FORBIDDEN', 403);
return;
}
$path = $invoice['original_file_path'];
$mime = mime_content_type($path);
header("Content-Type: $mime");
header("Content-Disposition: inline; filename=\"" . basename($path) . "\"");
header("Content-Length: " . filesize($path));
readfile($path);
exit;
} }
} }

View File

@@ -22,12 +22,10 @@ final class UserController
]); ]);
} }
public function detail(Request $request, array $vars): void public function detail(Request $request, string $id): void
{ {
$tenantId = $request->tenantId; $tenantId = $request->tenantId;
$userId = $vars['id']; $user = $this->userModel->findById($id, $tenantId);
$user = $this->userModel->findById($userId, $tenantId);
if (!$user) { if (!$user) {
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404); Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);

View File

@@ -1,104 +0,0 @@
<?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);
}
}
}

View File

@@ -4,43 +4,109 @@ declare(strict_types=1);
namespace App\Services\JoFotara; namespace App\Services\JoFotara;
/**
* UBLGeneratorService
*
* Generates UBL 2.1 compliant XML for the Jordanian Income and Sales Tax Department (ISTD).
* Based on the JoFotara Technical Specifications.
*/
final class UBLGeneratorService final class UBLGeneratorService
{ {
/**
* Generate UBL 2.1 XML for Jordan ISTD
*/
public function generate(array $invoice, array $lines, array $company): string 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 = 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>');
// 1. Basic Information
$xml->addChild('cbc:UBLVersionID', '2.1'); $xml->addChild('cbc:UBLVersionID', '2.1');
$xml->addChild('cbc:CustomizationID', 'TRADACO-2.1');
$xml->addChild('cbc:ProfileID', 'reporting:1.0');
$xml->addChild('cbc:ID', $invoice['invoice_number']); $xml->addChild('cbc:ID', $invoice['invoice_number']);
$xml->addChild('cbc:IssueDate', $invoice['invoice_date']); $xml->addChild('cbc:IssueDate', $invoice['invoice_date']);
$xml->addChild('cbc:InvoiceTypeCode', $invoice['ubl_type_code']); // e.g. 388 $xml->addChild('cbc:InvoiceTypeCode', $invoice['ubl_type_code'] ?? '388')->addAttribute('name', $invoice['invoice_category'] ?? '01');
$xml->addChild('cbc:DocumentCurrencyCode', 'JOD');
$xml->addChild('cbc:TaxCurrencyCode', 'JOD');
// Supplier (AccountingSupplierParty) // 2. AccountingSupplierParty (The Seller/Company)
$supplier = $xml->addChild('cac:AccountingSupplierParty'); $supplier = $xml->addChild('cac:AccountingSupplierParty');
$party = $supplier->addChild('cac:Party'); $sParty = $supplier->addChild('cac:Party');
$party->addChild('cbc:EndpointID', $company['tax_identification_number'])->addAttribute('schemeID', 'TN'); $sParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $company['tax_identification_number'])->addAttribute('schemeID', 'TN');
$sName = $sParty->addChild('cac:PartyName');
$sName->addChild('cbc:Name', $company['name']);
// ... (Adding more UBL fields like totals, lines, etc.) $sAddr = $sParty->addChild('cac:PostalAddress');
// Note: For brevity, this is a simplified structure. In production, $sAddr->addChild('cbc:CityName', $company['city'] ?? 'Amman');
// we follow the exact ISTD XML Schema for Jordan. $sAddr->addChild('cac:Country')->addChild('cbc:IdentificationCode', 'JO');
$sTaxScheme = $sParty->addChild('cac:PartyTaxScheme');
$sTaxScheme->addChild('cbc:RegistrationName', $company['name']);
$sTaxScheme->addChild('cbc:CompanyID', $company['tax_identification_number']);
$sTaxScheme->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT');
$sLegalEntity = $sParty->addChild('cac:PartyLegalEntity');
$sLegalEntity->addChild('cbc:RegistrationName', $company['name']);
$sLegalEntity->addChild('cbc:CompanyID', $company['tax_identification_number']);
// 3. AccountingCustomerParty (The Buyer)
$customer = $xml->addChild('cac:AccountingCustomerParty');
$cParty = $customer->addChild('cac:Party');
if (!empty($invoice['buyer_tin'])) {
$cParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $invoice['buyer_tin'])->addAttribute('schemeID', 'TN');
} elseif (!empty($invoice['buyer_national_id'])) {
$cParty->addChild('cac:PartyIdentification')->addChild('cbc:ID', $invoice['buyer_national_id'])->addAttribute('schemeID', 'NID');
}
$cName = $cParty->addChild('cac:PartyName');
$cName->addChild('cbc:Name', $invoice['buyer_name'] ?? 'General Customer');
$cTaxScheme = $cParty->addChild('cac:PartyTaxScheme');
$cTaxScheme->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT');
// 4. PaymentMeans
$payment = $xml->addChild('cac:PaymentMeans');
$payment->addChild('cbc:PaymentMeansCode', $invoice['payment_method_code'] ?? '10');
// 5. TaxTotal
$taxTotal = $xml->addChild('cac:TaxTotal');
$taxTotal->addChild('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$taxSubtotal = $taxTotal->addChild('cac:TaxSubtotal');
$taxSubtotal->addChild('cbc:TaxableAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$taxSubtotal->addChild('cbc:TaxAmount', number_format((float)$invoice['tax_amount'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$taxCategory = $taxSubtotal->addChild('cac:TaxCategory');
$taxCategory->addChild('cbc:ID', 'S');
$taxCategory->addChild('cbc:Percent', '16.00'); // Default Jordan VAT
$taxCategory->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT');
// 6. LegalMonetaryTotal
$legalMonetaryTotal = $xml->addChild('cac:LegalMonetaryTotal'); $legalMonetaryTotal = $xml->addChild('cac:LegalMonetaryTotal');
$legalMonetaryTotal->addChild('cbc:LineExtensionAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:LineExtensionAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$legalMonetaryTotal->addChild('cbc:TaxExclusiveAmount', (string)$invoice['subtotal'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:TaxExclusiveAmount', number_format((float)$invoice['subtotal'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$legalMonetaryTotal->addChild('cbc:TaxInclusiveAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:TaxInclusiveAmount', number_format((float)$invoice['grand_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$legalMonetaryTotal->addChild('cbc:PayableAmount', (string)$invoice['grand_total'])->addAttribute('currencyID', 'JOD'); $legalMonetaryTotal->addChild('cbc:AllowanceTotalAmount', number_format((float)($invoice['discount_total'] ?? 0), 3, '.', ''))->addAttribute('currencyID', 'JOD');
$legalMonetaryTotal->addChild('cbc:PayableAmount', number_format((float)$invoice['grand_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
// 7. Invoice Lines
foreach ($lines as $line) { foreach ($lines as $line) {
$invoiceLine = $xml->addChild('cac:InvoiceLine'); $invoiceLine = $xml->addChild('cac:InvoiceLine');
$invoiceLine->addChild('cbc:ID', (string)$line['line_number']); $invoiceLine->addChild('cbc:ID', (string)$line['line_number']);
$invoiceLine->addChild('cbc:InvoicedQuantity', (string)$line['quantity']); $invoiceLine->addChild('cbc:InvoicedQuantity', number_format((float)$line['quantity'], 3, '.', ''))->addAttribute('unitCode', 'PCE');
$invoiceLine->addChild('cbc:LineExtensionAmount', number_format((float)$line['line_total'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
$item = $invoiceLine->addChild('cac:Item');
$item->addChild('cbc:Description', $line['description']);
$itemTaxCategory = $item->addChild('cac:TaxCategory');
$itemTaxCategory->addChild('cbc:ID', 'S');
$itemTaxCategory->addChild('cbc:Percent', '16.00');
$itemTaxCategory->addChild('cac:TaxScheme')->addChild('cbc:ID', 'VAT');
$price = $invoiceLine->addChild('cac:Price'); $price = $invoiceLine->addChild('cac:Price');
$price->addChild('cbc:PriceAmount', (string)$line['unit_price'])->addAttribute('currencyID', 'JOD'); $price->addChild('cbc:PriceAmount', number_format((float)$line['unit_price'], 3, '.', ''))->addAttribute('currencyID', 'JOD');
} }
return $xml->asXML(); // Return formatted XML
$dom = dom_import_simplexml($xml)->ownerDocument;
$dom->formatOutput = true;
return $dom->saveXML();
} }
} }

View File

@@ -13,12 +13,17 @@ final class EncryptionService
public function __construct() public function __construct()
{ {
// Key should be 32 bytes for aes-256-gcm // Load encryption key from secrets config
$this->key = $_ENV['ENCRYPTION_KEY'] ?? ''; $secrets = require __DIR__ . '/../../../config/secrets.php';
$this->key = $secrets['encryption_key'] ?? '';
// Ensure key is hexadecimal and convert to binary (32 bytes)
if (strlen($this->key) === 64) {
$this->key = hex2bin($this->key);
}
if (strlen($this->key) !== 32) { if (strlen($this->key) !== 32) {
// In a real app, this would be in config/secrets.php throw new Exception("Security Error: Invalid ENCRYPTION_KEY length. Must be 32 bytes.");
// For now, we use a fallback if not set, but warn in production
$this->key = hash('sha256', $_ENV['JWT_SECRET'] ?? 'fallback-key');
} }
} }

View File

@@ -1,7 +1,13 @@
<?php <?php
declare(strict_types=1);
return [ return [
'encryption_key' => 'bgMQU/L8QYMd+8Sqh3AvsAXi+Fr+fMyJO+VAdakVoc8=', /*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
| This key is used by the EncryptionService to secure sensitive data like
| JoFotara credentials. It MUST be 32 bytes (256 bits) long.
|
*/
'encryption_key' => '8f9e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8', // Default for dev, should be unique per install
]; ];

View File

@@ -207,11 +207,14 @@ CREATE TABLE risk_scores (
company_id CHAR(36) NOT NULL, company_id CHAR(36) NOT NULL,
invoice_id CHAR(36) NULL, invoice_id CHAR(36) NULL,
risk_type VARCHAR(50) NOT NULL, risk_type VARCHAR(50) NOT NULL,
risk_level ENUM('low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'low',
score TINYINT UNSIGNED NOT NULL, score TINYINT UNSIGNED NOT NULL,
reason TEXT NOT NULL, reason TEXT NOT NULL,
factors JSON NULL,
is_resolved TINYINT(1) NOT NULL DEFAULT 0, is_resolved TINYINT(1) NOT NULL DEFAULT 0,
resolved_by CHAR(36) NULL, resolved_by CHAR(36) NULL,
resolved_at DATETIME NULL, resolved_at DATETIME NULL,
calculated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
INDEX idx_risk_tenant (tenant_id), INDEX idx_risk_tenant (tenant_id),
@@ -222,6 +225,20 @@ CREATE TABLE risk_scores (
CONSTRAINT fk_risk_resolver FOREIGN KEY (resolved_by) REFERENCES users(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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Notifications ────────────────────────────────────────
CREATE TABLE notifications (
id CHAR(36) NOT NULL DEFAULT (UUID()),
user_id CHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
type VARCHAR(50) NOT NULL DEFAULT 'info',
is_read TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_notifications_user (user_id),
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── Queue Jobs (MySQL fallback when Redis unavailable) ─── -- ─── Queue Jobs (MySQL fallback when Redis unavailable) ───
CREATE TABLE queue_jobs ( CREATE TABLE queue_jobs (
id CHAR(36) NOT NULL DEFAULT (UUID()), id CHAR(36) NOT NULL DEFAULT (UUID()),

View File

@@ -10,7 +10,7 @@ VALUES (
'd0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4', 'd0e4e4e4-e4e4-4e4e-ae4e-e4e4e4e4e4e4',
'Super Admin', 'Super Admin',
'admin@musadaq.app', 'admin@musadaq.app',
'$argon2id$v=19$m=65536,t=3,p=4$VEpSbmRXNXBaV3REYTJodg$jZ8/X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xf8X6Xg', -- Placeholder hash '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- Default: 'password'
'super_admin', 'super_admin',
1 1
); );

5894
musadaq_full_code.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,11 @@ $router->addRoute('POST', '/api/v1/invoices/{id}/submit', [
'handler' => [\App\Modules\Invoices\InvoiceController::class, 'submit'] 'handler' => [\App\Modules\Invoices\InvoiceController::class, 'submit']
]); ]);
$router->addRoute('GET', '/api/v1/invoices/{id}/file', [
'middleware' => [\App\Middleware\AuthMiddleware::class],
'handler' => [\App\Modules\Invoices\InvoiceController::class, 'downloadFile']
]);
// ══ Subscriptions ═════════════════════════════════════════════════ // ══ Subscriptions ═════════════════════════════════════════════════
$router->addRoute('GET', '/api/v1/subscriptions/me', [ $router->addRoute('GET', '/api/v1/subscriptions/me', [
'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class], 'middleware' => [\App\Middleware\AuthMiddleware::class, \App\Middleware\TenantMiddleware::class],

View File

@@ -241,9 +241,9 @@
document.getElementById('content').innerHTML = ` document.getElementById('content').innerHTML = `
<div class="flex flex-col lg:flex-row gap-10 animate-in"> <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="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="p-5 bg-white/5 border-b border-white/5 flex justify-between text-sm"><span>المستند الأصلي</span><a href="index.php?route=/api/v1/invoices/${i.id}/file" target="_blank" class="text-primary">فتح في نافذة جديدة</a></div>
<div class="flex-1 bg-black/50 p-6 flex items-center justify-center"> <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">`} ${i.original_file_path.endsWith('.pdf') ? `<iframe src="index.php?route=/api/v1/invoices/${i.id}/file" class="w-full h-full rounded-2xl"></iframe>` : `<img src="index.php?route=/api/v1/invoices/${i.id}/file" class="max-w-full max-h-full rounded-2xl shadow-2xl">`}
</div> </div>
</div> </div>
<div class="lg:w-1/2 glass p-10 rounded-[3rem] overflow-y-auto custom-scrollbar"> <div class="lg:w-1/2 glass p-10 rounded-[3rem] overflow-y-auto custom-scrollbar">

View File

@@ -38,11 +38,12 @@ final class RiskAnalysisJob
$companyId $companyId
]); ]);
} else { } else {
$stmt = $db->prepare("INSERT INTO risk_scores (id, tenant_id, company_id, risk_level, score, factors, calculated_at) VALUES (?, ?, ?, ?, ?, ?, NOW())"); $stmt = $db->prepare("INSERT INTO risk_scores (id, tenant_id, company_id, risk_type, risk_level, score, factors, calculated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())");
$stmt->execute([ $stmt->execute([
\Ramsey\Uuid\Uuid::uuid4()->toString(), \Ramsey\Uuid\Uuid::uuid4()->toString(),
$tenantId, $tenantId,
$companyId, $companyId,
'overall_company_risk', // risk_type is required
$analysis['level'], $analysis['level'],
$analysis['score'], $analysis['score'],
json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE) json_encode($analysis['factors'], JSON_UNESCAPED_UNICODE)

View File

@@ -0,0 +1,51 @@
import os
def aggregate_project(root_dir, output_file, exclude_dirs=None, exclude_files=None, extensions=None):
if exclude_dirs is None:
exclude_dirs = {'.git', 'vendor', 'node_modules', 'storage', '.gemini', 'artifacts', 'brain', 'scratch'}
if exclude_files is None:
exclude_files = {'composer.lock', 'package-lock.json', 'aggregate_project.py', output_file}
if extensions is None:
extensions = {'.php', '.js', '.css', '.html', '.sql', '.json', '.md', '.py', '.env.example', '.xml', '.env'}
with open(output_file, 'w', encoding='utf-8') as f:
f.write("# مُصادَق — ملخص كود المشروع الكامل\n\n")
f.write("هذا الملف يحتوي على كافة ملفات المصدر للمشروع مجمعة لتسهيل المراجعة.\n\n")
for root, dirs, files in os.walk(root_dir):
# Exclude directories
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for file in files:
if file in exclude_files:
continue
_, ext = os.path.splitext(file)
# Include specific files or extensions
if ext not in extensions and file not in {'.env', 'phpunit.xml'}:
continue
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, root_dir)
f.write(f"## الملف: `{rel_path}`\n\n")
# Determine language for markdown block
lang = ext.replace('.', '')
if lang == 'php': lang = 'php'
elif lang == 'js': lang = 'javascript'
elif lang == 'sql': lang = 'sql'
else: lang = ''
f.write(f"```{lang}\n")
try:
with open(full_path, 'r', encoding='utf-8') as src:
f.write(src.read())
except Exception as e:
f.write(f"// تعذر قراءة الملف: {str(e)}")
f.write("\n```\n\n")
f.write("---\n\n")
if __name__ == "__main__":
aggregate_project('.', 'musadaq_full_code.md')
print("تم تجميع الكود بنجاح في: musadaq_full_code.md")